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 {