(() => { "use strict"; const SW_VERSION = "bitvid-sw-2026-07-02.1"; 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; // Trim is O(entries) (a full keys() enumeration), so amortize it instead of // running it after every put — a cold grid load puts dozens of thumbnails in a // burst, and per-put trims made that burst contend with the cache.match calls // of later images (the "grid loads slowly" regression). const IMAGE_CACHE_TRIM_EVERY = 20; let imageCachePromise = null; let imagePutsSinceTrim = 0; function openImageCache() { if (!imageCachePromise) { imageCachePromise = caches.open(IMAGE_CACHE); imageCachePromise.catch(() => { imageCachePromise = null; }); } return imageCachePromise; } 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(request, response) { try { const cache = await openImageCache(); await cache.put(request, response); imagePutsSinceTrim += 1; if (imagePutsSinceTrim >= IMAGE_CACHE_TRIM_EVERY) { imagePutsSinceTrim = 0; 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. imageCachePromise = null; try { await caches.delete(IMAGE_CACHE); } catch (cleanupError) { // fail open } } } async function handleImageRequest(event) { const request = event.request; // Start the network fetch IMMEDIATELY — never delay a download behind cache // I/O. On a cold cache this makes the SW path as fast as no SW at all; on a // warm cache the cached copy below still wins the race for first paint. const network = 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(request, response.clone())); } return response; }); // Mark handled so a network failure can't surface as an unhandled rejection // while we're still consulting the cache; the original promise stays // observable for the no-cache path below. network.catch(() => {}); let cached = null; try { const cache = await openImageCache(); cached = await cache.match(request, { ignoreVary: true }); } catch (error) { // Cache unavailable — plain network behavior. } if (cached) { // Serve the cached copy instantly; the refresh continues in the background. event.waitUntil(network.catch(() => {})); return cached; } return network; } 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); }); })();