/* Service worker for the export (Archive) search sites. * * Offline model (see plan): browse-cache + opt-in per-channel download. Nothing * huge is precached on install — the transcript corpus is hundreds of MB. We * runtime-cache the app shell + whatever the user actually opens, and expose a * message API so the page can bulk-download a channel's shards for offline use. * * Caches: * SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML * navigations (network-first, cache fallback) + /icons/* (network- * first: they follow the site's accent, which a redeploy can change). * PAGES — every published JSON document: the per-channel shard trees * (manifest.json + page-NNNN.json), the site-wide flat trees * (summaries, stats) and the root documents (corpus.json, site.json, * search-aliases.json, duplicates.json, tags.json). Per-channel * pages are cache-first; per-channel manifests are network-first so a rebuild is * seen, with invalidation keyed off manifest.generatedAt — when a * channel's manifest generatedAt changes, that channel's cached page * shards are evicted (the shards have stable, non-content-hashed URLs, * so this is the only reliable staleness signal). The site-wide * documents have no per-channel generatedAt to evict against, so they * are network-first with a cache fallback: fresh online, present * offline, never stale-forever. * META — tiny synthetic Responses storing each channel's last-seen generatedAt * (avoids IndexedDB inside the SW). */ // 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. A service worker cannot // import, so these lists are hand-written — and `common/lib/archive/contract. // test.ts` reads THIS FILE and fails if they drift from CONTRACT.layers, // PER_CHANNEL_TREES and ROOT_FILES. Add a layer to the contract and this file // is what the test sends you to. // 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", () => { // Activate immediately — no precache list (corpus is too large to bundle). self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { // Drop caches from older SW versions. 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); if (url.origin !== self.location.origin) return; const shard = SHARD_RE.exec(url.pathname); if (shard) { const [, , slug, rest] = shard; if (rest === "manifest.json") { event.respondWith(handleManifest(req, slug)); } else { event.respondWith(cacheFirst(req, PAGES)); } return; } // Site-wide archive documents. Not cache-first: there is no per-channel // generatedAt to evict them against, so a cache-first copy would be stale // until the SW version changed. Network-first keeps them fresh online and // present offline — which is what made the duplicates page work with no // connection. if (FLAT_RE.test(url.pathname) || ROOT_RE.test(url.pathname)) { event.respondWith(networkFirst(req, PAGES)); return; } // App shell. /_next/static/* is content-hashed, so a URL never changes what // it names: cache-first. /icons/* is NOT: the icons are lit with the site's // accent, a site.json setting that can change between deploys at the same // URL, so they are network-first — fresh online, still there offline. 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 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) { // Offline miss — nothing cached. 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(); } } // Navigations: network-first (fresh HTML after deploys), fall back to cache, then // to any cached document so the installed app still opens offline. 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 and evict that channel's page shards if it changed. Offline → // serve the cached manifest. async function handleManifest(req, 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(slug); if (prev !== null && prev !== gen) { await evictChannelPages(slug); } await writeMeta(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(); } } async function evictChannelPages(slug) { const pages = await caches.open(PAGES); const keys = await pages.keys(); // The PER-CHANNEL trees, and only those: /summaries// never existed // (summaries is flat) and /posts, /digests were missing, so a rebuilt channel // kept serving its old social posts and AI digests from cache. const prefixes = [ `/transcripts/${slug}/`, `/subs/${slug}/`, `/posts/${slug}/`, `/digests/${slug}/`, ]; await Promise.all( keys.map((k) => { const p = new URL(k.url).pathname; const inChannel = prefixes.some( (pre) => p.startsWith(pre) && !p.endsWith("manifest.json"), ); return inChannel ? pages.delete(k) : null; }), ); } // The site-wide documents: the flat trees and the root files, i.e. exactly what // the page downloads ONCE per origin. Evicted when its last pinned channel goes, // because nothing else ever removes them — the per-channel sweep above only // knows about /// prefixes. The same two regexes drive the fetch // handler, so this can never fall out of step with what was cached. async function evictSiteData() { const pages = await caches.open(PAGES); const keys = await pages.keys(); await Promise.all( keys.map((k) => { const p = new URL(k.url).pathname; return FLAT_RE.test(p) || ROOT_RE.test(p) ? pages.delete(k) : null; }), ); } // Per-channel generatedAt stored as a synthetic Response in the META cache. async function readMeta(slug) { const meta = await caches.open(META); const hit = await meta.match(`/__gen__/${slug}`); return hit ? await hit.text() : null; } async function writeMeta(slug, gen) { const meta = await caches.open(META); await meta.put(`/__gen__/${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]; if (data.type === "CACHE_URLS") { event.waitUntil(cacheUrls(data.slug, data.urls || [], port)); } else if (data.type === "EVICT_CHANNEL") { event.waitUntil( evictChannelPages(data.slug).then(() => port && port.postMessage({ ok: true })), ); } else if (data.type === "EVICT_SITE") { event.waitUntil( evictSiteData().then(() => port && port.postMessage({ ok: true })), ); } else if (data.type === "CHANNEL_STATUS") { event.waitUntil(channelStatus(data.slug, port)); } }); // Fetch + cache a list of shard URLs, reporting progress over the message port. 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 page shards are currently cached. async function channelStatus(slug, port) { const cache = await caches.open(PAGES); const keys = await cache.keys(); const prefix = `/transcripts/${slug}/`; let cached = 0; for (const k of keys) { const p = new URL(k.url).pathname; if (p.startsWith(prefix) && p.endsWith(".json") && !p.endsWith("manifest.json")) { cached++; } } if (port) port.postMessage({ type: "status", slug, cachedPages: cached }); }