[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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 14:48:18 -04:00
1 parent 9e5cb9f57b
commit c20938b4aa
6 files changed
+497 -19

No files matched your search

+25 -1
View File
@@ -861,8 +861,32 @@ 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) => {
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,
);
});
}
}
+7 -2
View File
@@ -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
)
) {
Vendored
+107 -3
View File
@@ -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;
+63
View File
@@ -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"));
}
});
+264
View File
@@ -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",
);
});
+31 -13
View File
@@ -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