commit beb2ccdf32fd995a88e27675c47cf0a12681a5b4
parent eac2db7b8625628fbba264739ea92ec2ec08cf48
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 22:17:22 -0400
export: both service workers serve /icons/ network-first; the SHELL comment names the one-visit offline gap (brand S1 review)
The icons are lit with the site's accent, which a redeploy can change at the
same URL, so a cache-first icon outlived every accent change until the next
SHELL rename. /icons/ is now networkFirst into shell-v2: fresh online, cached
offline. /_next/static/ (content-hashed) stays cache-first; SHELL stays
shell-v2 and VERSION v1. The SHELL comment now says the rename also drops the
cached HTML and chunks, so the installed app opens offline again only after
one more online visit.
No test covered the strategy (contract.test.ts pins the URL families only):
lib/archive/serviceWorkerRouting.test.ts runs each worker in node:vm with a
fake self/caches/fetch and drives its fetch handler — /icons/ online replaces
a stale cached copy, offline serves it; /_next/static/ is served from cache
without a fetch. Reverting site-sw's routing turns its icon test red.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 148 insertions(+), 8 deletions(-)
diff --git a/common/lib/archive/serviceWorkerRouting.test.ts b/common/lib/archive/serviceWorkerRouting.test.ts
@@ -0,0 +1,117 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import vm from "node:vm";
+
+// The service workers' CACHING STRATEGY, run for real: each worker file is
+// evaluated in a node:vm context with a fake `self` / `caches` / `fetch`, and
+// its fetch handler is driven with synthetic events. contract.test.ts pins the
+// URL families these workers match; this pins what they DO with a match.
+//
+// The case that matters: /icons/* keeps its URL across a redeploy that changes
+// what it draws (a site's icons are lit with its accent), so it must be
+// network-first — a cache-first icon would outlive every accent change.
+// /_next/static/* is content-hashed and stays cache-first.
+
+const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const ORIGIN = "https://archive.example";
+
+type FakeResponse = { body: string; ok: boolean; clone(): FakeResponse };
+
+function res(body: string): FakeResponse {
+ return { body, ok: true, clone: () => res(body) };
+}
+
+// One worker, loaded fresh: returns a `request(path, {online})` that dispatches a
+// GET fetch event and resolves to the served body, plus what the network saw
+// and a way to seed the caches.
+function loadWorker(file: string) {
+ const handlers: Record<string, (e: unknown) => void> = {};
+ const stores = new Map<string, Map<string, FakeResponse>>();
+ const store = (name: string) => {
+ if (!stores.has(name)) stores.set(name, new Map());
+ return stores.get(name)!;
+ };
+ let online = true;
+ let network = "";
+ const fetched: string[] = [];
+ const keyOf = (r: string | { url: string }) =>
+ new URL(typeof r === "string" ? r : r.url, ORIGIN).href;
+ const context = {
+ self: {
+ addEventListener: (type: string, fn: (e: unknown) => void) => {
+ handlers[type] = fn;
+ },
+ location: { origin: ORIGIN },
+ skipWaiting: () => {},
+ clients: { claim: async () => {} },
+ },
+ caches: {
+ open: async (name: string) => ({
+ match: async (r: string | { url: string }) => store(name).get(keyOf(r)),
+ put: async (r: string | { url: string }, v: FakeResponse) => {
+ store(name).set(keyOf(r), v);
+ },
+ }),
+ keys: async () => [...stores.keys()],
+ delete: async (name: string) => stores.delete(name),
+ },
+ fetch: async (r: { url: string }) => {
+ fetched.push(new URL(r.url).pathname);
+ if (!online) throw new TypeError("Failed to fetch");
+ return res(network);
+ },
+ Response: { error: () => ({ body: "<error>", ok: false }) },
+ URL,
+ console,
+ };
+ vm.runInNewContext(readFileSync(path.join(REPO, file), "utf8"), context, {
+ filename: file,
+ });
+ assert.equal(typeof handlers.fetch, "function", `${file} registers no fetch handler`);
+
+ return {
+ fetched,
+ seed: (cacheName: string, p: string, body: string) =>
+ store(cacheName).set(new URL(p, ORIGIN).href, res(body)),
+ cached: (cacheName: string, p: string) => store(cacheName).get(new URL(p, ORIGIN).href)?.body,
+ request: async (p: string, opts: { online: boolean; network?: string }) => {
+ online = opts.online;
+ network = opts.network ?? "";
+ let served: Promise<FakeResponse> | undefined;
+ handlers.fetch({
+ request: { method: "GET", url: new URL(p, ORIGIN).href, mode: "no-cors", destination: "image" },
+ respondWith: (p2: Promise<FakeResponse>) => {
+ served = p2;
+ },
+ });
+ assert.ok(served, `${p}: the worker did not answer`);
+ return (await served).body;
+ },
+ };
+}
+
+for (const file of ["export/service-worker/site-sw.js", "export/service-worker/sw-hub.js"]) {
+ test(`${file}: /icons/ is network-first in shell-v2 — a redeployed accent reaches readers`, async () => {
+ const sw = loadWorker(file);
+ sw.seed("shell-v2", "/icons/icon.svg", "old mark");
+ // Online: the fresh icon wins over the cached one, and replaces it.
+ assert.equal(await sw.request("/icons/icon.svg", { online: true, network: "new mark" }), "new mark");
+ assert.deepEqual(sw.fetched, ["/icons/icon.svg"]);
+ assert.equal(sw.cached("shell-v2", "/icons/icon.svg"), "new mark");
+ // Offline: the cached icon is still served.
+ assert.equal(await sw.request("/icons/icon.svg", { online: false }), "new mark");
+ });
+
+ test(`${file}: /_next/static/ stays cache-first`, async () => {
+ const sw = loadWorker(file);
+ sw.seed("shell-v2", "/_next/static/chunks/app.js", "cached chunk");
+ assert.equal(
+ await sw.request("/_next/static/chunks/app.js", { online: true, network: "network chunk" }),
+ "cached chunk",
+ );
+ assert.deepEqual(sw.fetched, []);
+ });
+}
diff --git a/export/service-worker/site-sw.js b/export/service-worker/site-sw.js
@@ -7,7 +7,8 @@
*
* Caches:
* SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML
- * navigations (network-first, cache fallback) + icons/manifest.
+ * 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,
@@ -27,8 +28,12 @@
// 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 (cache-first under /icons/, so an installed app
-// would otherwise keep the old mark forever) while PAGES and META survive.
+// 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}`;
@@ -91,11 +96,18 @@ self.addEventListener("fetch", (event) => {
return;
}
- // App shell.
- if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) {
+ // 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;
diff --git a/export/service-worker/sw-hub.js b/export/service-worker/sw-hub.js
@@ -19,8 +19,12 @@
// 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 (cache-first under /icons/, so an installed app
-// would otherwise keep the old mark forever) while PAGES and META survive.
+// 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}`;
@@ -83,10 +87,17 @@ self.addEventListener("fetch", (event) => {
// App shell is same-origin only (the hub's own bundle).
if (url.origin !== self.location.origin) return;
- if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) {
+ // /_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;