Files
bitvid/sw.min.js
T
thePR0M3TH3ANandClaude Opus 4.8 c20938b4aa [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]>
2026-07-02 14:48:18 -04:00

304 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
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 = {}) {
return clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windowClients) => {
windowClients.forEach((client) => {
client.postMessage({
type: "BITVID_SW_LIFECYCLE",
eventType,
metadata: SW_METADATA,
...details,
});
});
});
}
// Handle messages from clients
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
if (event.data && event.data.type === "CLEAR_CACHES") {
caches
.keys()
.then((cacheNames) =>
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
);
}
if (event.data && event.data.type === "ENSURE_CLIENTS_CLAIM") {
event.waitUntil(clients.claim());
}
});
// Immediately install and skip waiting
self.addEventListener("install", (event) => {
event.waitUntil(Promise.all([self.skipWaiting(), broadcastLifecycle("install")]));
});
// 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([
clients.claim(),
self.skipWaiting(),
broadcastLifecycle("activate", {
scope: self.registration?.scope || null,
}),
caches
.keys()
.then((cacheNames) =>
Promise.all(
cacheNames
.filter((cacheName) => cacheName !== IMAGE_CACHE)
.map((cacheName) => caches.delete(cacheName))
)
),
])
);
});
// Handle fetch events
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;
}
// Create a promise to handle the request
const responsePromise = (async () => {
// 1) Keepalive check
if (requestURL.includes("/webtorrent/keepalive/")) {
return new Response();
}
// 2) Cancel check
if (requestURL.includes("/webtorrent/cancel/")) {
return new Response(
new ReadableStream({
cancel() {
cancelled = true;
},
})
);
}
// 3) Streaming requests
// We define an async function and immediately invoke it with `event`
return (async function ({ request }) {
const { url, method, headers, destination } = request;
// 3a) Find open window clients
const windowClients = await clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// 3b) We send a message to each client with a MessageChannel
const [clientResponse, port] = await new Promise((resolve) => {
for (const client of windowClients) {
const channel = new MessageChannel();
channel.port1.onmessage = ({ data }) => {
resolve([data, channel.port1]);
};
client.postMessage(
{
url,
method,
headers: Object.fromEntries(headers.entries()),
scope: self.registration.scope,
destination,
type: "webtorrent",
},
[channel.port2]
);
}
});
// 3c) Setup a small helper to close the port
let timeoutId = null;
const closeChannel = () => {
port.postMessage(false);
clearTimeout(timeoutId);
port.onmessage = null;
};
// 3d) Build a Headers object that prevents caching
const responseHeaders = new Headers(clientResponse.headers);
responseHeaders.set(
"Cache-Control",
"no-cache, no-store, must-revalidate, max-age=0"
);
responseHeaders.set("Pragma", "no-cache");
responseHeaders.set("Expires", "0");
responseHeaders.set("X-Bitvid-SW-Version", SW_VERSION);
// 3e) If the response is not a streaming request, return it directly
if (clientResponse.body !== "STREAM") {
closeChannel();
return new Response(clientResponse.body, {
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
});
}
// 3f) Otherwise, create a streaming response
return new Response(
new ReadableStream({
pull(controller) {
return new Promise((resolvePull) => {
port.onmessage = ({ data }) => {
if (data) {
controller.enqueue(data);
} else {
closeChannel();
controller.close();
}
resolvePull();
};
// If not cancelled, auto‐close after 5s of no data
// *** Increase the timeout to avoid frequent CPU wake-ups ***
if (!cancelled && destination !== "document") {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
closeChannel();
resolvePull();
}, 30000); // 30 seconds
}
// Request next chunk from client
port.postMessage(true);
});
},
cancel() {
closeChannel();
},
}),
{
status: clientResponse.status,
statusText: clientResponse.statusText,
headers: responseHeaders,
}
);
})(event);
})();
// respondWith the promise if it exists
event.respondWith(responsePromise);
});
})();