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]>
304 lines
9.4 KiB
JavaScript
304 lines
9.4 KiB
JavaScript
(() => {
|
||
"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);
|
||
});
|
||
})();
|