diff --git a/js/app.js b/js/app.js index 16c5dd24..99f028d9 100644 --- a/js/app.js +++ b/js/app.js @@ -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, + ); + }); } } diff --git a/js/storage/s3-multipart.js b/js/storage/s3-multipart.js index cb51968f..fc23765f 100644 --- a/js/storage/s3-multipart.js +++ b/js/storage/s3-multipart.js @@ -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 ) ) { diff --git a/sw.min.js b/sw.min.js index d415e4c2..caafee44 100644 --- a/sw.min.js +++ b/sw.min.js @@ -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; diff --git a/tests/storage-cache-control.test.mjs b/tests/storage-cache-control.test.mjs new file mode 100644 index 00000000..35592c77 --- /dev/null +++ b/tests/storage-cache-control.test.mjs @@ -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")); + } +}); diff --git a/tests/sw-image-cache.test.mjs b/tests/sw-image-cache.test.mjs new file mode 100644 index 00000000..753af501 --- /dev/null +++ b/tests/sw-image-cache.test.mjs @@ -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", + ); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 252ece55..05400b22 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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