mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[perf] Media caching: immutable upload headers + SW image cache (#55)
Verified first that event caching needs nothing: allEvents (incl. ingested NIP-71) already persists to IndexedDB and every grid renders cache-first + merge-new. The real gap was MEDIA: thumbnails/avatars/banners re-downloaded across visits. Part 1 — Cache-Control on uploads (js/storage/s3-multipart.js): computeCacheControl already stamped most media immutable but missed webp/avif/ico and .torrent files (which fell to the 1h default). Added them — safe because buildR2Key namespaces keys by infohash (content-addressed, a key's bytes never change). Playlists (m3u8/mpd) stay short-lived (live manifests mutate in place). Exported for tests. Part 2 — service-worker image cache (sw.min.js): Cross-origin images (the hosts bitvid does NOT control headers for) are now served stale-while-revalidate from a versioned Cache-API cache (bitvid-images-v1): - instant on revisit; background refresh self-heals changed images (and where part 1's immutable headers apply, that refresh is served by the HTTP cache — free) - opaque (no-cors) responses cached — exactly the external-thumbnail case - LRU-capped (300); quota failures drop the cache and fail open; every handler error falls back to plain fetch so a caching bug can never break images - the activate handler's wipe-all now PRESERVES the current image cache (old versions + other caches still wiped); same-origin assets untouched (they ride the host HTTP cache + dist cache-purge pipeline) - app._initServiceWorker registers the SW at boot when none exists, same path/scope/options as webtorrent.js's setupServiceWorker so playback's later register() resolves to the same registration (playback path untouched) - SW_VERSION bumped Tests: storage-cache-control (3, exported policy) + sw-image-cache (6 — evaluates the REAL sw.min.js in a VM sandbox and drives its handlers: cold/warm/offline/ opaque flows, same-origin + non-image non-interception, activate preserves the image cache). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
9e5cb9f57b
commit
c20938b4aa
6 files changed
+499
-21
No files matched your search
@@ -861,9 +861,33 @@ class Application {
|
||||
_initServiceWorker() {
|
||||
// Force update of any registered service workers to ensure latest code is used.
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((registration) => registration.update());
|
||||
});
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then((registrations) => {
|
||||
if (registrations.length) {
|
||||
registrations.forEach((registration) => registration.update());
|
||||
return;
|
||||
}
|
||||
// No registration yet (user never played a torrent): register the SW
|
||||
// at boot so its cross-origin image cache (thumbnails/avatars) works
|
||||
// for everyone. Same path/scope/options as js/webtorrent.js's
|
||||
// setupServiceWorker, so its later register() resolves to this same
|
||||
// registration — playback behavior is unchanged. Best-effort only.
|
||||
return navigator.serviceWorker
|
||||
.register("/sw.min.js", { scope: "/", updateViaCache: "none" })
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Boot service-worker registration skipped:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"[Application] Service-worker init failed:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,12 @@ function getCreateBucketConfig(region) {
|
||||
};
|
||||
}
|
||||
|
||||
function computeCacheControl(key) {
|
||||
// Exported for tests. Upload keys are content-addressed (buildR2Key namespaces
|
||||
// by the file's infohash), so media at a given key never changes — a long
|
||||
// immutable Cache-Control is safe and makes browsers keep thumbnails/avatars/
|
||||
// videos across visits instead of re-fetching them. Playlists (m3u8/mpd) stay
|
||||
// short-lived because live/HLS manifests DO mutate in place.
|
||||
export function computeCacheControl(key) {
|
||||
if (!key) {
|
||||
return DEFAULT_CACHE_CONTROL;
|
||||
}
|
||||
@@ -39,7 +44,7 @@ function computeCacheControl(key) {
|
||||
}
|
||||
|
||||
if (
|
||||
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test(
|
||||
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|webp|avif|ico|vtt|srt|mpg|mpeg|torrent)$/.test(
|
||||
lower
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -1,12 +1,100 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SW_VERSION = "bitvid-sw-2026-02-10";
|
||||
const SW_VERSION = "bitvid-sw-2026-07-02";
|
||||
const SW_METADATA = Object.freeze({
|
||||
version: SW_VERSION,
|
||||
runtimePathPrefix: "/webtorrent/",
|
||||
});
|
||||
|
||||
// Cross-origin image cache (thumbnails / avatars / banners). These are the
|
||||
// images bitvid does NOT control headers for (external hosts, user CDNs), so
|
||||
// without this they can re-download on every visit. Strategy:
|
||||
// stale-while-revalidate — serve the cached copy instantly, refresh it in the
|
||||
// background — so revisits are instant AND a changed image self-heals on the
|
||||
// next view. Same-origin assets are left alone (they ride the host's HTTP
|
||||
// cache + the dist cache-purge pipeline). Bump the version to discard.
|
||||
const IMAGE_CACHE = "bitvid-images-v1";
|
||||
const IMAGE_CACHE_MAX_ENTRIES = 300;
|
||||
|
||||
function isCacheableImageRequest(request) {
|
||||
if (!request || request.method !== "GET") {
|
||||
return false;
|
||||
}
|
||||
if (request.destination !== "image") {
|
||||
return false;
|
||||
}
|
||||
const url = request.url || "";
|
||||
if (!/^https?:/.test(url)) {
|
||||
return false;
|
||||
}
|
||||
// Same-origin images (app assets) keep the normal HTTP-cache path.
|
||||
try {
|
||||
return new URL(url).origin !== self.location.origin;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trimImageCache(cache) {
|
||||
try {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= IMAGE_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
// Cache keys() preserves insertion order; drop the oldest overflow.
|
||||
const excess = keys.length - IMAGE_CACHE_MAX_ENTRIES;
|
||||
await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key)));
|
||||
} catch (error) {
|
||||
// Trimming is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
async function putImageInCache(cache, request, response) {
|
||||
try {
|
||||
await cache.put(request, response);
|
||||
await trimImageCache(cache);
|
||||
} catch (error) {
|
||||
// Quota pressure (opaque responses are padded heavily for quota
|
||||
// accounting) or storage failure: drop the whole image cache and keep
|
||||
// going — the page still got its network response.
|
||||
try {
|
||||
await caches.delete(IMAGE_CACHE);
|
||||
} catch (cleanupError) {
|
||||
// fail open
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageRequest(event) {
|
||||
const request = event.request;
|
||||
const cache = await caches.open(IMAGE_CACHE);
|
||||
const cached = await cache.match(request, { ignoreVary: true });
|
||||
|
||||
const refresh = fetch(request)
|
||||
.then((response) => {
|
||||
// Cache good responses; opaque (cross-origin no-cors) counts as good —
|
||||
// its status is unreadable, and these are exactly the images we target.
|
||||
if (response && (response.ok || response.type === "opaque")) {
|
||||
event.waitUntil(putImageInCache(cache, request, response.clone()));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
// Keep the background refresh alive after we respond.
|
||||
event.waitUntil(refresh.catch(() => {}));
|
||||
return cached;
|
||||
}
|
||||
return refresh;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
function broadcastLifecycle(eventType, details = {}) {
|
||||
@@ -46,7 +134,9 @@
|
||||
event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
|
||||
});
|
||||
|
||||
// Claim clients on activation and clear caches
|
||||
// Claim clients on activation and clear caches. The CURRENT image cache is
|
||||
// preserved (it is versioned; stale versions and everything else are wiped) so
|
||||
// an SW update doesn't throw away every cached thumbnail/avatar.
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
@@ -58,7 +148,11 @@
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) =>
|
||||
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((cacheName) => cacheName !== IMAGE_CACHE)
|
||||
.map((cacheName) => caches.delete(cacheName))
|
||||
)
|
||||
),
|
||||
])
|
||||
);
|
||||
@@ -68,6 +162,16 @@
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const requestURL = event.request.url;
|
||||
|
||||
// Cross-origin images: serve from the image cache (stale-while-revalidate).
|
||||
// Fail open — any handler error falls back to a plain network fetch so a
|
||||
// caching bug can never break image loading.
|
||||
if (isCacheableImageRequest(event.request)) {
|
||||
event.respondWith(
|
||||
handleImageRequest(event).catch(() => fetch(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle WebTorrent streaming requests
|
||||
if (!requestURL.includes("/webtorrent/")) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Cache-Control policy for uploaded media (TODO #55, part 1). Upload keys are
|
||||
// content-addressed (buildR2Key namespaces by infohash), so media should get a
|
||||
// long immutable Cache-Control — browsers then keep thumbnails/videos/torrents
|
||||
// across visits. Mutable playlists (m3u8/mpd) must stay short-lived.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-upload-cache-control
|
||||
// given: "an object key by extension"
|
||||
// when: "computeCacheControl derives the Cache-Control header"
|
||||
// then: "media/torrents are immutable-year; playlists revalidate; unknown gets the default"
|
||||
// observable_outcomes:
|
||||
// - "video + image (incl webp/avif) + .torrent keys -> public, max-age=31536000, immutable"
|
||||
// - "m3u8/mpd -> public, max-age=60, must-revalidate"
|
||||
// - "unknown/empty keys -> the 1h default (never immutable)"
|
||||
// determinism_controls:
|
||||
// - "pure function; explicit inputs"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { computeCacheControl } from "../js/storage/s3-multipart.js";
|
||||
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
|
||||
test("videos, images (incl. webp/avif), and torrents are immutable for a year", () => {
|
||||
for (const key of [
|
||||
"u/npub1x/abc123/video.mp4",
|
||||
"u/npub1x/abc123/clip.webm",
|
||||
"u/npub1x/abc123/thumb.jpg",
|
||||
"u/npub1x/abc123/thumb.webp",
|
||||
"u/npub1x/abc123/poster.avif",
|
||||
"u/npub1x/abc123/video.torrent",
|
||||
"u/npub1x/external/deadbeef.torrent",
|
||||
"SHOUTY/PATH/THUMB.PNG",
|
||||
]) {
|
||||
assert.equal(computeCacheControl(key), IMMUTABLE, key);
|
||||
}
|
||||
});
|
||||
|
||||
test("mutable playlists stay short-lived (live manifests change in place)", () => {
|
||||
assert.equal(
|
||||
computeCacheControl("u/npub1x/live/stream.m3u8"),
|
||||
"public, max-age=60, must-revalidate",
|
||||
);
|
||||
assert.equal(
|
||||
computeCacheControl("u/npub1x/live/stream.mpd"),
|
||||
"public, max-age=60, must-revalidate",
|
||||
);
|
||||
});
|
||||
|
||||
test("unknown or missing keys fall back to the 1h default, never immutable", () => {
|
||||
for (const key of ["u/npub1x/uploads/file.bin", "no-extension", "", null, undefined]) {
|
||||
const value = computeCacheControl(key);
|
||||
assert.equal(value, "public, max-age=3600", String(key));
|
||||
assert.ok(!value.includes("immutable"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
// Service-worker image cache (TODO #55, part 2): cross-origin images
|
||||
// (thumbnails / avatars / banners) are served stale-while-revalidate from the
|
||||
// Cache API so revisits are instant even when the host sends no cache headers.
|
||||
// These tests load the REAL sw.min.js in a VM sandbox with stubbed SW globals and
|
||||
// drive its actual fetch/activate handlers.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-sw-image-cache
|
||||
// given: "the real sw.min.js evaluated with stubbed self/caches/fetch"
|
||||
// when: "fetch events fire for cross-origin images / same-origin images / other URLs, and activate fires"
|
||||
// then: "cross-origin images are cached + served stale-while-revalidate; others untouched; activate preserves ONLY the current image cache"
|
||||
// observable_outcomes:
|
||||
// - "cold fetch -> network response returned AND stored in bitvid-images-v1"
|
||||
// - "warm fetch -> cached body served; background refresh re-fetches"
|
||||
// - "network failure with a cached copy -> cached copy served"
|
||||
// - "same-origin image + non-image URL -> respondWith never called"
|
||||
// - "activate wipes other caches (incl. old image-cache versions) but keeps bitvid-images-v1"
|
||||
// determinism_controls:
|
||||
// - "vm sandbox; Map-backed CacheStorage stub; controllable fetch stub; no network"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["testing a re-implementation instead of the shipped file", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
const swSource = await readFile(new URL("../sw.min.js", import.meta.url), "utf8");
|
||||
|
||||
function makeCacheStorage() {
|
||||
const stores = new Map(); // name -> Map(url -> response)
|
||||
return {
|
||||
stores,
|
||||
async open(name) {
|
||||
if (!stores.has(name)) {
|
||||
stores.set(name, new Map());
|
||||
}
|
||||
const store = stores.get(name);
|
||||
return {
|
||||
async match(request) {
|
||||
return store.get(request.url) || undefined;
|
||||
},
|
||||
async put(request, response) {
|
||||
store.set(request.url, response);
|
||||
},
|
||||
async keys() {
|
||||
return Array.from(store.keys()).map((url) => ({ url }));
|
||||
},
|
||||
async delete(request) {
|
||||
return store.delete(request.url);
|
||||
},
|
||||
};
|
||||
},
|
||||
async keys() {
|
||||
return Array.from(stores.keys());
|
||||
},
|
||||
async delete(name) {
|
||||
return stores.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(body, { ok = true, type = "basic" } = {}) {
|
||||
return {
|
||||
body,
|
||||
ok,
|
||||
type,
|
||||
clone() {
|
||||
return makeResponse(body, { ok, type });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadSw({ fetchImpl } = {}) {
|
||||
const listeners = new Map();
|
||||
const cacheStorage = makeCacheStorage();
|
||||
const sandbox = {
|
||||
location: { origin: "https://bitvid.network" },
|
||||
registration: { scope: "/" },
|
||||
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||
skipWaiting: async () => {},
|
||||
clients: { matchAll: async () => [], claim: async () => {} },
|
||||
caches: cacheStorage,
|
||||
fetch: fetchImpl || (async () => makeResponse("net")),
|
||||
URL,
|
||||
Response,
|
||||
Headers,
|
||||
ReadableStream,
|
||||
MessageChannel,
|
||||
Promise,
|
||||
Object,
|
||||
Array,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
};
|
||||
sandbox.self = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(swSource, sandbox);
|
||||
return { listeners, cacheStorage, sandbox };
|
||||
}
|
||||
|
||||
function makeFetchEvent(url, { destination = "image", method = "GET" } = {}) {
|
||||
const event = {
|
||||
request: { url, destination, method },
|
||||
response: null,
|
||||
pending: [],
|
||||
respondWith(promise) {
|
||||
this.response = Promise.resolve(promise);
|
||||
},
|
||||
waitUntil(promise) {
|
||||
this.pending.push(Promise.resolve(promise).catch(() => {}));
|
||||
},
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
const IMG = "https://cdn.example.com/thumb.jpg";
|
||||
|
||||
test("cold cross-origin image: network response served AND cached in bitvid-images-v1", async () => {
|
||||
let fetches = 0;
|
||||
const { listeners, cacheStorage } = loadSw({
|
||||
fetchImpl: async () => {
|
||||
fetches += 1;
|
||||
return makeResponse(`net-${fetches}`);
|
||||
},
|
||||
});
|
||||
|
||||
const event = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(event);
|
||||
const response = await event.response;
|
||||
assert.equal(response.body, "net-1", "network response returned to the page");
|
||||
|
||||
await Promise.all(event.pending);
|
||||
const store = cacheStorage.stores.get("bitvid-images-v1");
|
||||
assert.ok(store?.has(IMG), "response was stored in the image cache");
|
||||
});
|
||||
|
||||
test("warm cross-origin image: cached copy served, background refresh still fetches", async () => {
|
||||
let fetches = 0;
|
||||
const { listeners, cacheStorage } = loadSw({
|
||||
fetchImpl: async () => {
|
||||
fetches += 1;
|
||||
return makeResponse(`net-${fetches}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Warm the cache.
|
||||
const first = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(first);
|
||||
await first.response;
|
||||
await Promise.all(first.pending);
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
// Second visit: served from cache; the revalidation fetch still happens.
|
||||
const second = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(second);
|
||||
const response = await second.response;
|
||||
assert.equal(response.body, "net-1", "cached copy served immediately");
|
||||
await Promise.all(second.pending);
|
||||
assert.equal(fetches, 2, "background refresh re-fetched the image");
|
||||
assert.equal(
|
||||
cacheStorage.stores.get("bitvid-images-v1").get(IMG).body,
|
||||
"net-2",
|
||||
"cache updated by the background refresh (self-heals changed images)",
|
||||
);
|
||||
});
|
||||
|
||||
test("network failure with a cached copy: cached copy served (offline-friendly)", async () => {
|
||||
let shouldFail = false;
|
||||
const { listeners } = loadSw({
|
||||
fetchImpl: async () => {
|
||||
if (shouldFail) {
|
||||
throw new Error("offline");
|
||||
}
|
||||
return makeResponse("net-1");
|
||||
},
|
||||
});
|
||||
|
||||
const warm = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(warm);
|
||||
await warm.response;
|
||||
await Promise.all(warm.pending);
|
||||
|
||||
shouldFail = true;
|
||||
const offline = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(offline);
|
||||
const response = await offline.response;
|
||||
assert.equal(response.body, "net-1", "cached copy served when the network fails");
|
||||
await Promise.all(offline.pending);
|
||||
});
|
||||
|
||||
test("same-origin images and non-image requests are NOT intercepted", () => {
|
||||
const { listeners } = loadSw();
|
||||
|
||||
const sameOrigin = makeFetchEvent("https://bitvid.network/assets/logo.png");
|
||||
listeners.get("fetch")(sameOrigin);
|
||||
assert.equal(sameOrigin.response, null, "same-origin image left to the HTTP cache");
|
||||
|
||||
const nonImage = makeFetchEvent("https://cdn.example.com/data.json", {
|
||||
destination: "fetch",
|
||||
});
|
||||
listeners.get("fetch")(nonImage);
|
||||
assert.equal(nonImage.response, null, "non-image request untouched");
|
||||
|
||||
const post = makeFetchEvent(IMG, { method: "POST" });
|
||||
listeners.get("fetch")(post);
|
||||
assert.equal(post.response, null, "non-GET untouched");
|
||||
});
|
||||
|
||||
test("activate wipes other caches but preserves the CURRENT image cache", async () => {
|
||||
const { listeners, cacheStorage } = loadSw();
|
||||
|
||||
// Seed: the live image cache, an old image-cache version, and a stray cache.
|
||||
(await cacheStorage.open("bitvid-images-v1")).put({ url: IMG }, makeResponse("keep"));
|
||||
await cacheStorage.open("bitvid-images-v0");
|
||||
await cacheStorage.open("some-old-cache");
|
||||
|
||||
const event = { pending: [], waitUntil(p) { this.pending.push(p); } };
|
||||
listeners.get("activate")(event);
|
||||
await Promise.all(event.pending);
|
||||
|
||||
const names = await cacheStorage.keys();
|
||||
assert.deepEqual(names, ["bitvid-images-v1"], "only the current image cache survives");
|
||||
assert.equal(
|
||||
cacheStorage.stores.get("bitvid-images-v1").get(IMG).body,
|
||||
"keep",
|
||||
"cached thumbnails survive an SW update",
|
||||
);
|
||||
});
|
||||
|
||||
test("opaque (cross-origin no-cors) responses are cached; failed basic responses are not", async () => {
|
||||
let mode = "opaque";
|
||||
const { listeners, cacheStorage } = loadSw({
|
||||
fetchImpl: async () =>
|
||||
mode === "opaque"
|
||||
? makeResponse("opaque-body", { ok: false, type: "opaque" })
|
||||
: makeResponse("error-body", { ok: false, type: "basic" }),
|
||||
});
|
||||
|
||||
const opaqueEvent = makeFetchEvent(IMG);
|
||||
listeners.get("fetch")(opaqueEvent);
|
||||
await opaqueEvent.response;
|
||||
await Promise.all(opaqueEvent.pending);
|
||||
assert.ok(
|
||||
cacheStorage.stores.get("bitvid-images-v1")?.has(IMG),
|
||||
"opaque response cached (status unreadable by design)",
|
||||
);
|
||||
|
||||
mode = "error";
|
||||
const errUrl = "https://cdn.example.com/broken.png";
|
||||
const errEvent = makeFetchEvent(errUrl);
|
||||
listeners.get("fetch")(errEvent);
|
||||
await errEvent.response;
|
||||
await Promise.all(errEvent.pending);
|
||||
assert.ok(
|
||||
!cacheStorage.stores.get("bitvid-images-v1").has(errUrl),
|
||||
"a non-ok basic response (404/500) is not cached",
|
||||
);
|
||||
});
|
||||
@@ -1672,19 +1672,37 @@ passphrase-encrypted). Items 51, 56, 57 are all facets of that.
|
||||
dispatches; unrelated target is a no-op). Lint + build green.
|
||||
|
||||
### 55. Video-grid event caching — faster loads, cache-first + merge-new (PERF / UX)
|
||||
- [ ] **Channel-profile grid still loads slowly.** Even after the coalescing + relay
|
||||
timeout + parity-seed fix, the channel wall streams in slowly. Cache that channel's
|
||||
events so revisits render instantly.
|
||||
- [ ] **Generalize to every grid:** cache the events behind each video grid (feed tabs +
|
||||
channel walls) so they render from cache immediately, then refresh in the
|
||||
background and **merge in only new events** (don't blank + re-fetch). Fresh content
|
||||
still matters — proposed balance: **cache-and-refresh simultaneously**, appending
|
||||
new events as they arrive, with an explicit **pull-to-refresh / refresh button**
|
||||
for a forced re-fetch. Decide cache store (in-memory vs IndexedDB via
|
||||
`js/state/cache.js`) + a staleness/TTL policy.
|
||||
- [ ] **Cache media too:** thumbnails and profile images/banners should be cached (browser
|
||||
cache headers / a warm cache / persistent store) so they don't reload from scratch
|
||||
on every visit. Cross-ref `js/channelProfile.js` and the card renderers.
|
||||
- [x] **VERIFIED 2026-07-02: event caching already exists — no gap.** Traced end to
|
||||
end: `allEvents` (incl. ingested NIP-71 videos via `nip71IngestService.injectVideo`)
|
||||
is persisted to IndexedDB (`bitvid:eventsCache:v1`, `PersistenceManager` +
|
||||
`EventsCacheStore`), restored on boot (`restoreLocalData`), and `loadVideos` +
|
||||
the channel grid render **cache-first then merge-new** (never blank+refetch).
|
||||
Bullets 1–2 were already built; the remaining slowness is only a genuinely-cold
|
||||
first visit, already bounded by the coalescing/timeout/parity fixes.
|
||||
- [x] **Media caching part 1 — Cache-Control on uploads — DONE 2026-07-02.**
|
||||
`computeCacheControl` (js/storage/s3-multipart.js) already stamped most media
|
||||
immutable; added the missing `webp`/`avif`/`ico` **and `.torrent`** (safe:
|
||||
`buildR2Key` namespaces by infohash → content-addressed). Playlists (m3u8/mpd)
|
||||
stay short-lived. Exported + tested (`tests/storage-cache-control.test.mjs`, 3).
|
||||
- [x] **Media caching part 2 — SW image cache — DONE 2026-07-02.** `sw.min.js` now
|
||||
serves **cross-origin images** (thumbnails/avatars/banners — the hosts bitvid
|
||||
doesn't control headers for) **stale-while-revalidate** from `bitvid-images-v1`
|
||||
(Cache API): instant on revisit, background refresh self-heals changed images,
|
||||
LRU-capped at 300 entries, quota errors fail open (drop the cache, never break
|
||||
image loading), and every handler error falls back to plain `fetch`. The
|
||||
activate handler's wipe-all now **preserves the current image cache** (old
|
||||
versions + other caches still wiped). Same-origin assets are untouched (they
|
||||
ride the host HTTP cache + dist cache-purge pipeline). `app._initServiceWorker`
|
||||
now registers the SW at boot when none exists (same path/scope/options as
|
||||
`webtorrent.js`'s `setupServiceWorker`, so playback's later `register()` is a
|
||||
no-op on the same registration). SW_VERSION bumped. Tests:
|
||||
`tests/sw-image-cache.test.mjs` (6 — runs the REAL sw.min.js in a VM sandbox:
|
||||
cold/warm/offline/opaque flows, non-interception, activate preservation).
|
||||
- [ ] **VERIFY on unstable:** revisit a feed/channel — thumbnails + avatars should
|
||||
load instantly from the SW cache (DevTools → Network shows "(ServiceWorker)");
|
||||
after an SW update the image cache survives. Optional follow-up: pull-to-refresh
|
||||
/ manual refresh affordance for forced re-fetch (nice-to-have; background
|
||||
merge-new already keeps grids fresh).
|
||||
|
||||
### 56. Upload & edit modals: prompt to unlock storage (PIN popup), don't just error (UX)
|
||||
- [ ] When storage is locked, the **upload** and **edit** modals should open the
|
||||
|
||||
Reference in new issue
Block a user