/* Service worker for the Archilyzer HUB (INSTANCE_MODE=hub). * * Same offline model as the site SW (browse-cache + opt-in per-channel * download), but CROSS-ORIGIN: the hub federates content from many origins, so * this worker caches every published archive document from ANY origin — not * just its own. The only origins it ever reaches are ones the user added, whose * JSON is CORS-open (Access-Control-Allow-Origin: *); a cross-origin fetch is * therefore a readable `cors` response that can be cached (never `no-cors` — * opaque responses are unreadable and quota-toxic). * * Differences from the site SW: * - no same-origin early-return: shard requests to any origin are cached; * - staleness (generatedAt) META and channel eviction/status are scoped per * (origin, slug), keyed off the FULL request URL, so same-named channels on * different sites don't collide; * - the page's message API carries `origin` + absolute shard URLs. */ // VERSION names the DATA caches, and activate deletes every cache it does not // keep — so bumping it throws away every reader's offline channel downloads. // Never bump it for a shell change. SHELL is named on its own: "shell-v2" // retired the pre-brand icons (the old worker served /icons/ cache-first, so an // installed app would have kept the old mark forever) while PAGES and META // survive. The rename has one cost: activate also deletes shell-v1's cached // HTML and /_next/static chunks, so right after the update the installed app // does not open OFFLINE until the reader has made one more online visit, which // refills shell-v2. Their channel downloads are not touched. const VERSION = "v1"; const SHELL = "shell-v2"; const PAGES = `pages-${VERSION}`; const META = `meta-${VERSION}`; // THE THREE URL FAMILIES OF THE PUBLISHED CONTRACT, matched on url.pathname so // they are origin-independent. A service worker cannot import, so these lists // are hand-written — and `common/lib/archive/contract.test.ts` reads THIS FILE // (and site-sw.js) and fails if they drift from CONTRACT.layers, // PER_CHANNEL_TREES and ROOT_FILES. `digests` was missing here entirely: the // hub federated AI digests it could never cache. // Per-channel trees: ///(manifest.json|page-NNNN.json). const SHARD_RE = /^\/(transcripts|subs|posts|digests)\/([^/]+)\/(.+)$/; // Flat trees: one manifest and its pages at the tree root, no channel level. const FLAT_RE = /^\/(summaries|stats)\/(.+)$/; // The root documents a reader fetches by name. const ROOT_RE = /^\/(corpus\.json|site\.json|search-aliases\.json|duplicates\.json|tags\.json)$/; self.addEventListener("install", () => { self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { const keep = new Set([SHELL, PAGES, META]); const names = await caches.keys(); await Promise.all(names.map((n) => (keep.has(n) ? null : caches.delete(n)))); await self.clients.claim(); })(), ); }); self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; const url = new URL(req.url); // Shard requests are cached for ANY origin (this is the federation change). const shard = SHARD_RE.exec(url.pathname); if (shard) { const [, , slug, rest] = shard; if (rest === "manifest.json") { event.respondWith(handleManifest(req, url.origin, slug)); } else { event.respondWith(cacheFirst(req, PAGES)); } return; } // Site-wide archive documents, also from ANY origin — a member site's // summaries index and its /duplicates.json are as federated as its shards. // Network-first: no per-channel generatedAt to evict them against, so fresh // online and present offline rather than stale-forever. if (FLAT_RE.test(url.pathname) || ROOT_RE.test(url.pathname)) { event.respondWith(networkFirst(req, PAGES)); return; } // App shell is same-origin only (the hub's own bundle). if (url.origin !== self.location.origin) return; // /_next/static/* is content-hashed: cache-first. /icons/* keeps its URL // across deploys that change what it draws (the site SW's reason; the hub's // parent mark changes less, but it is the same rule): network-first. if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(req, SHELL)); return; } if (url.pathname.startsWith("/icons/")) { event.respondWith(networkFirst(req, SHELL)); return; } if (req.mode === "navigate" || req.destination === "document") { event.respondWith(networkFirstDoc(req)); return; } }); // Cache-first: serve from cache, else fetch (cors) and store. async function cacheFirst(req, cacheName) { const cache = await caches.open(cacheName); const hit = await cache.match(req); if (hit) return hit; try { const res = await fetch(req); if (res.ok) cache.put(req, res.clone()); return res; } catch (err) { return hit || Response.error(); } } // Network-first: serve the fresh copy and remember it; fall back to whatever is // cached when the network is gone. async function networkFirst(req, cacheName) { const cache = await caches.open(cacheName); try { const res = await fetch(req); if (res.ok) cache.put(req, res.clone()); return res; } catch (err) { const hit = await cache.match(req); return hit || Response.error(); } } async function networkFirstDoc(req) { const cache = await caches.open(SHELL); try { const res = await fetch(req); if (res.ok) cache.put(req, res.clone()); return res; } catch (err) { const hit = await cache.match(req); if (hit) return hit; const root = await cache.match("/"); if (root) return root; return Response.error(); } } // Channel manifest: network-first. On a fresh manifest, compare generatedAt to // the stored value (keyed per origin) and evict that (origin, slug)'s page // shards if it changed. Offline → serve the cached manifest. async function handleManifest(req, origin, slug) { const pages = await caches.open(PAGES); try { const res = await fetch(req); if (res.ok) { const clone = res.clone(); try { const body = await clone.json(); const gen = String(body?.generatedAt ?? ""); const prev = await readMeta(origin, slug); if (prev !== null && prev !== gen) { await evictChannelPages(origin, slug); } await writeMeta(origin, slug, gen); } catch { // Unparseable manifest — still serve it. } pages.put(req, res.clone()); } return res; } catch (err) { const hit = await pages.match(req); return hit || Response.error(); } } // True when a cached entry belongs to (origin, slug) and is a page shard (not a // manifest). origin === "" means the hub's own same-origin content. function belongsToChannel(entryUrl, origin, slug) { const u = new URL(entryUrl); const wantOrigin = origin || self.location.origin; if (u.origin !== wantOrigin) return false; // The PER-CHANNEL trees, and only those: /summaries// never existed // (summaries is flat) and /posts, /digests were missing. const prefixes = [ `/transcripts/${slug}/`, `/subs/${slug}/`, `/posts/${slug}/`, `/digests/${slug}/`, ]; return prefixes.some( (pre) => u.pathname.startsWith(pre) && !u.pathname.endsWith("manifest.json"), ); } async function evictChannelPages(origin, slug) { const pages = await caches.open(PAGES); const keys = await pages.keys(); await Promise.all( keys.map((k) => (belongsToChannel(k.url, origin, slug) ? pages.delete(k) : null)), ); } // One member origin's site-wide documents — the flat trees and the root files, // exactly what the page downloads ONCE per origin. Scoped by origin like // everything else here, so evicting one member never touches another's. The // per-channel sweep above only knows /// prefixes, so without this // these bytes would survive every removal. async function evictSiteData(origin) { const pages = await caches.open(PAGES); const wantOrigin = origin || self.location.origin; const keys = await pages.keys(); await Promise.all( keys.map((k) => { const u = new URL(k.url); if (u.origin !== wantOrigin) return null; return FLAT_RE.test(u.pathname) || ROOT_RE.test(u.pathname) ? pages.delete(k) : null; }), ); } // Per-(origin, channel) generatedAt stored as a synthetic Response in META. function metaKey(origin, slug) { return `/__gen__/${encodeURIComponent(origin || "")}/${slug}`; } async function readMeta(origin, slug) { const meta = await caches.open(META); const hit = await meta.match(metaKey(origin, slug)); return hit ? await hit.text() : null; } async function writeMeta(origin, slug, gen) { const meta = await caches.open(META); await meta.put(metaKey(origin, slug), new Response(gen)); } // ---- Message API (the page drives bulk download / eviction / status) ---- self.addEventListener("message", (event) => { const data = event.data || {}; const port = event.ports && event.ports[0]; const origin = data.origin || ""; if (data.type === "CACHE_URLS") { event.waitUntil(cacheUrls(data.slug, data.urls || [], port)); } else if (data.type === "EVICT_CHANNEL") { event.waitUntil( evictChannelPages(origin, data.slug).then( () => port && port.postMessage({ ok: true }), ), ); } else if (data.type === "EVICT_SITE") { event.waitUntil( evictSiteData(origin).then(() => port && port.postMessage({ ok: true })), ); } else if (data.type === "CHANNEL_STATUS") { event.waitUntil(channelStatus(origin, data.slug, port)); } }); // Fetch + cache a list of (absolute or root-relative) shard URLs. async function cacheUrls(slug, urls, port) { const cache = await caches.open(PAGES); let done = 0; for (const u of urls) { try { const res = await fetch(u, { cache: "reload" }); if (res.ok) await cache.put(u, res.clone()); } catch { // Skip failures; the channel just won't be fully offline. } done++; if (port) port.postMessage({ type: "progress", slug, done, total: urls.length }); } if (port) port.postMessage({ type: "done", slug, done, total: urls.length }); } // Report how many of a channel's transcript page shards are cached, scoped to // (origin, slug). async function channelStatus(origin, slug, port) { const cache = await caches.open(PAGES); const keys = await cache.keys(); const wantOrigin = origin || self.location.origin; const prefix = `/transcripts/${slug}/`; let cached = 0; for (const k of keys) { const u = new URL(k.url); if ( u.origin === wantOrigin && u.pathname.startsWith(prefix) && u.pathname.endsWith(".json") && !u.pathname.endsWith("manifest.json") ) { cached++; } } if (port) port.postMessage({ type: "status", slug, cachedPages: cached }); }