mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
265 lines
8.6 KiB
JavaScript
265 lines
8.6 KiB
JavaScript
// 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",
|
|
);
|
|
});
|