Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 65 additions & 44 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { build, type Plugin } from 'vite';
Expand All @@ -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' },
Expand Down
31 changes: 29 additions & 2 deletions packages/client/src/sw/precache.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
APP_SHELL_CACHE,
Expand Down Expand Up @@ -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');
Expand All @@ -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<typeof import('./precache.js')> => {
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`]);
Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/sw/precache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down