diff --git a/_config.ts b/_config.ts index 3a812fc9..ab25571d 100644 --- a/_config.ts +++ b/_config.ts @@ -368,25 +368,21 @@ async function buildFileTree( } async function writeFileTree() { - const RAW = 0x55; - - // Stamp the built SW with a unique build ID so the browser always detects a - // SW update on each build, not just when the SW source itself changes. - // Do this before buildFileTree (and before brotli's stale-sidecar cleanup) - // so the stamped version is the one captured in the tree CID. Also delete - // any brotli'd copy of the *un*stamped SW so Caddy's precompressed-br - // serving never delivers it to the browser's update checker. const swDist = "./dist/service-worker.js"; + + // Remove stale brotli'd SW so Caddy never serves the old compressed version + // while the plain file has been updated. try { Deno.removeSync(`${swDist}.br`); } catch { /* already gone */ } - const swSource = Deno.readTextFileSync(swDist) - .replace(/\n\/\/ @build \S+\n$/, ""); - const swStamped = `${swSource}\n// @build ${crypto.randomUUID()}\n`; - Deno.writeTextFileSync(swDist, swStamped); const tree = await buildFileTree("dist/"); + // The SW is not included in its own embedded tree — the browser manages the + // SW lifecycle independently and never fetches it through the SW's handler. + delete tree["service-worker.js"]; + delete tree["service-worker.js.br"]; + // Remove any .br sidecar that is older than its plain counterpart — this // catches cases where brotli compression did not re-run after an asset // changed (e.g. incremental afterUpdate builds), so the server falls back @@ -405,23 +401,24 @@ async function writeFileTree() { } catch { /* .br sidecar doesn't exist — nothing to do */ } } - tree["service-worker.js"] = await createCID( - RAW, - new TextEncoder().encode(swStamped), - ); - - // Exclude file-tree.json from its own contents (self-referential). - delete tree["file-tree.json"]; - delete tree["file-tree.json.br"]; - const sorted = Object.fromEntries( Object.keys(tree).sort().map((k) => [k, tree[k]]), ); - Deno.writeTextFileSync( - "./dist/file-tree.json", - JSON.stringify(sorted, null, 2), - ); + // Inject the file tree into the compiled SW and stamp with a build ID so + // the browser always detects an update on each build. + // + // The placeholder "__FILE_TREE__" may survive as-is or may be folded by + // esbuild into `JSON.parse("__FILE_TREE__")` → `"__FILE_TREE__"`. Both + // patterns are replaced with the actual object literal so that `FILE_TREE` + // is always an object in the running SW, never a string. + const treeJson = JSON.stringify(sorted); + const swSource = Deno.readTextFileSync(swDist) + .replace(/\n\/\/ @build \S+\n$/, "") + .replace('JSON.parse("__FILE_TREE__")', () => treeJson) + .replace('"__FILE_TREE__"', () => treeJson); + const swStamped = `${swSource}\n// @build ${crypto.randomUUID()}\n`; + Deno.writeTextFileSync(swDist, swStamped); } site.addEventListener("afterBuild", writeFileTree); diff --git a/specs/service-worker/SPEC.md b/specs/service-worker/SPEC.md index eda916db..5d1f8c43 100644 --- a/specs/service-worker/SPEC.md +++ b/specs/service-worker/SPEC.md @@ -4,7 +4,7 @@ - It must be able to serve every resource offline that has been requested before. - Every file must be cached by CID. -- The `dist` contains a file tree in the form of a JSON file which maps the path to the correct CID for the latest version. +- The file tree (a map of path → CID for file) is embedded directly in the service worker at build time. Because every build produces a new service worker, the embedded tree is always current. - Every new build means a new service worker. - All pages must use the same code. - When a new service worker is available, install and activate it immediately, while still making sure all pages are on the same service worker. This is done through sending a `{ type: "sw-activated" }` message to the client, on which they must reload the page. diff --git a/src/service-worker.js b/src/service-worker.js index 5cc3a7b6..ab0f6870 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -2,9 +2,8 @@ import { create as createCid } from "./common/cid.js"; -const fileTreePromise = fetch("./file-tree.json", { cache: "no-cache" }) - .then((r) => /** @type {Promise>} */ (r.json())) - .catch(() => null); +/** @type {Record} */ +const FILE_TREE = JSON.parse("__FILE_TREE__"); /** Media content types to ignore */ const MEDIA_CONTENT_TYPE = /^(audio|video)\//; @@ -145,17 +144,15 @@ async function openCaches() { } /** - * Looks up a pathname in the pre-built file tree and returns its CID, or - * `undefined` if the entry is absent or the tree failed to load. + * Looks up a pathname in the embedded file tree and returns its CID, or + * `undefined` if the entry is absent. * * @param {string} pathname - e.g. "/components/foo.js" - * @returns {Promise} + * @returns {string | undefined} */ -async function cidFromTree(pathname) { - const tree = await fileTreePromise; - if (!tree) return undefined; +function cidFromTree(pathname) { const key = pathname.replace(/^\//, ""); - return tree[key]; + return FILE_TREE[key]; } /** @@ -227,7 +224,7 @@ async function lookup(request) { async function handleFetch(request) { if (navigator.onLine) { const { pathname } = new URL(request.url); - const cid = await cidFromTree(pathname); + const cid = cidFromTree(pathname); if (cid !== undefined) { const { content } = await openCaches(); const cached = await content.match(cidUrl(cid));