From 60b67321734d3d8d375c7fab3fd651efea559d2c Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Mon, 3 Aug 2026 00:18:13 +0200 Subject: [PATCH] fix(web): key the app-shell cache off a content-derived build id The Service Worker's cache name was a constant and `sw.js` carried no build-varying bytes, so a deploy that changed only app chunks left the worker byte-identical: no update check fired, `install` never re-ran, and the offline navigation fallback pinned the first deploy a user installed. `deleteStaleCaches` excluded the live cache, so nothing could rotate it. The web build now digests every emitted chunk into a build id, stamps it into the worker via `define`, and the shell cache is keyed off it. A new deploy therefore installs into a fresh cache and `activate` evicts the one it superseded. The id is derived from the output bytes, not a clock, so an unchanged build keeps its id. The digest covers file contents rather than the hashed file names alone: `index.html` ships unhashed, so a change confined to it would otherwise leave the shell pinned. The build fails closed if the emitted worker did not take the stamp, since an unstamped worker silently stops rotating. Closes #974 --- apps/web/vite.config.ts | 109 ++++++++++++++---------- packages/client/src/sw/precache.test.ts | 31 ++++++- packages/client/src/sw/precache.ts | 10 ++- 3 files changed, 102 insertions(+), 48 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7c620fbb9..28ed98469 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; import { build, type Plugin } from 'vite'; @@ -9,57 +12,75 @@ const SW_FILE = 'sw.js'; const MANIFEST_FILE = 'precache-manifest.json'; /** - * The app-shell manifest the Service Worker precaches on install: every emitted - * chunk, as same-origin absolute paths. + * The app-shell pair, sharing one build id: the manifest the Service Worker + * precaches on install — every emitted chunk, as same-origin absolute paths — + * and the worker itself, stamped with a digest of those same bytes so the shell + * cache rotates with the output. + * + * The worker is a second pass so it lands unhashed at the output root — its + * scope is bounded by its own URL path — and as a classic script, not the ES + * module the app's chunk graph would emit. */ -function precacheManifest(): Plugin { - return { - name: 'cipherbox:precache-manifest', - enforce: 'post', - generateBundle(_options, bundle) { - const shell = Object.keys(bundle) - .filter((fileName) => !fileName.endsWith('.map')) - .map((fileName) => `/${fileName}`) - .sort(); - this.emitFile({ - type: 'asset', - fileName: MANIFEST_FILE, - source: `${JSON.stringify(shell, null, 2)}\n`, - }); - }, - }; -} +function appShell(): Plugin[] { + let buildId: string | null = null; -/** - * A separate pass so the worker lands unhashed at the output root — its scope is - * bounded by its own URL path — and as a classic script, not the ES module the - * app's chunk graph would emit. - */ -function serviceWorkerBuild(): Plugin { - return { - name: 'cipherbox:service-worker', - apply: 'build', - async closeBundle() { - await build({ - configFile: false, - logLevel: 'warn', - build: { - outDir: OUT_DIR, - emptyOutDir: false, - rollupOptions: { - input: SW_ENTRY, - // `iife` keeps the classic-script contract: an `es` chunk reaching for - // a dynamic import emits `import.meta`, which a classic worker rejects. - output: { entryFileNames: SW_FILE, codeSplitting: false, format: 'iife' }, + return [ + { + name: 'cipherbox:precache-manifest', + enforce: 'post', + generateBundle(_options, bundle) { + const fileNames = Object.keys(bundle) + .filter((fileName) => !fileName.endsWith('.map')) + .sort(); + + const digest = createHash('sha256'); + for (const fileName of fileNames) { + const output = bundle[fileName]; + digest.update(fileName); + digest.update(output.type === 'chunk' ? output.code : output.source); + } + buildId = digest.digest('hex').slice(0, 16); + + const shell = fileNames.map((fileName) => `/${fileName}`); + this.emitFile({ + type: 'asset', + fileName: MANIFEST_FILE, + source: `${JSON.stringify(shell, null, 2)}\n`, + }); + }, + }, + { + name: 'cipherbox:service-worker', + apply: 'build', + async closeBundle() { + if (buildId === null) + throw new Error('the precache manifest emitted no app-shell build id'); + await build({ + configFile: false, + logLevel: 'warn', + define: { __APP_SHELL_BUILD_ID__: JSON.stringify(buildId) }, + build: { + outDir: OUT_DIR, + emptyOutDir: false, + rollupOptions: { + input: SW_ENTRY, + // `iife` keeps the classic-script contract: an `es` chunk reaching for + // a dynamic import emits `import.meta`, which a classic worker rejects. + output: { entryFileNames: SW_FILE, codeSplitting: false, format: 'iife' }, + }, }, - }, - }); + }); + // The worker's bytes must vary per deploy or the browser finds no update + // to install, and the shell it precached is never rotated. + const emitted = await readFile(join(OUT_DIR, SW_FILE), 'utf8'); + if (!emitted.includes(buildId)) throw new Error(`${SW_FILE} did not take the build stamp`); + }, }, - }; + ]; } export default defineConfig({ - plugins: [react(), precacheManifest(), serviceWorkerBuild()], + plugins: [react(), ...appShell()], // `@cipherbox/client`'s engine worker dynamically imports the wasm-bindgen ES // module, which a classic worker cannot do (blueprint/web-client.md). worker: { format: 'es' }, diff --git a/packages/client/src/sw/precache.test.ts b/packages/client/src/sw/precache.test.ts index 03b664711..73f6972cc 100644 --- a/packages/client/src/sw/precache.test.ts +++ b/packages/client/src/sw/precache.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { APP_SHELL_CACHE, @@ -119,7 +119,7 @@ describe('precacheAppShell', () => { }); describe('deleteStaleCaches', () => { - it('drops cipherbox caches from earlier deploys and keeps the shell', async () => { + it('drops shell caches from earlier deploys and keeps the live one', async () => { const caches = new FakeCacheStorage(); caches.cache(APP_SHELL_CACHE); caches.cache('cipherbox-app-shell-v0'); @@ -131,6 +131,33 @@ describe('deleteStaleCaches', () => { }); }); +describe('the build stamp', () => { + /** Loads a fresh module instance under the stamp the web build defines. */ + const loadStamped = async (buildId: string): Promise => { + vi.resetModules(); + vi.stubGlobal('__APP_SHELL_BUILD_ID__', buildId); + return import('./precache.js'); + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rotates the shell cache so a redeploy installs fresh and evicts its predecessor', async () => { + const caches = new FakeCacheStorage(); + const first = await loadStamped('build-1'); + const second = await loadStamped('build-2'); + expect(second.APP_SHELL_CACHE).not.toBe(first.APP_SHELL_CACHE); + + await first.precacheAppShell(caches, manifestFetch('["/assets/old.js"]'), ORIGIN); + await second.precacheAppShell(caches, manifestFetch('["/assets/new.js"]'), ORIGIN); + await second.deleteStaleCaches(caches); + + expect([...caches.opened.keys()]).toEqual([second.APP_SHELL_CACHE]); + expect([...(await second.readPrecachedUrls(caches))]).toEqual([`${ORIGIN}/assets/new.js`]); + }); +}); + describe('appShellClaims', () => { it('claims navigations and precached same-origin GETs only', () => { const precached = new Set([`${ORIGIN}/assets/app.js`]); diff --git a/packages/client/src/sw/precache.ts b/packages/client/src/sw/precache.ts index f00ec2368..016825956 100644 --- a/packages/client/src/sw/precache.ts +++ b/packages/client/src/sw/precache.ts @@ -5,13 +5,19 @@ import { STREAM_PATH_PREFIX } from '../media/protocol.js'; -export const APP_SHELL_CACHE = 'cipherbox-app-shell'; +/** Stamped by the web build from the build output's digest; absent in dev. */ +declare const __APP_SHELL_BUILD_ID__: string | undefined; + +const CACHE_PREFIX = 'cipherbox-app-shell'; +const BUILD_ID = typeof __APP_SHELL_BUILD_ID__ === 'string' ? __APP_SHELL_BUILD_ID__ : 'dev'; + +/** Keyed by build id so a deploy installs fresh and `activate` evicts the shell it superseded. */ +export const APP_SHELL_CACHE = `${CACHE_PREFIX}-${BUILD_ID}`; const PRECACHE_MANIFEST_URL = '/precache-manifest.json'; /** The document a navigation falls back to when the network is unreachable. */ const APP_SHELL_DOCUMENT = '/index.html'; -const CACHE_PREFIX = 'cipherbox-'; /** The subset of `Cache` the shell drives (injectable). */ export interface CacheLike {