diff --git a/ui/public/sw.js b/ui/public/sw.js index e5997304dc..9a9d1a7ee6 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -1,4 +1,11 @@ -const CACHE_NAME = "paperclip-v2"; +// The build id is stamped into this file at production build time (see +// stampServiceWorkerBuildId in vite.config.ts), so a deploy that changes only +// the app bundle still changes sw.js byte-for-byte. That is what makes the +// browser install a new worker, which — via skipWaiting + controllerchange — +// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in +// dev, where HMR (not the worker) drives refreshes. +const BUILD_ID = "__PAPERCLIP_BUILD_ID__"; +const CACHE_NAME = `paperclip-${BUILD_ID}`; self.addEventListener("install", () => { self.skipWaiting(); diff --git a/ui/src/lib/vite-sw-build-id.test.ts b/ui/src/lib/vite-sw-build-id.test.ts new file mode 100644 index 0000000000..5b978a9f84 --- /dev/null +++ b/ui/src/lib/vite-sw-build-id.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + SERVICE_WORKER_BUILD_ID_PLACEHOLDER, + deriveBuildIdFromEntryFileName, + stampServiceWorkerBuildId, +} from "./vite-sw-build-id"; + +const swSource = () => + `const BUILD_ID = "${SERVICE_WORKER_BUILD_ID_PLACEHOLDER}";\n` + + "const CACHE_NAME = `paperclip-${BUILD_ID}`;\n"; + +describe("stampServiceWorkerBuildId", () => { + it("replaces the placeholder with the build id and leaves no placeholder", () => { + const out = stampServiceWorkerBuildId(swSource(), "index-abc123"); + expect(out).toContain("index-abc123"); + expect(out).not.toContain(SERVICE_WORKER_BUILD_ID_PLACEHOLDER); + }); + + it("produces different worker bytes for different build ids", () => { + // This is the whole point: a new bundle -> a new sw.js -> a new worker -> + // parked tabs reload. Identical build ids must stay byte-identical so the + // worker does not churn when the app did not change. + const a = stampServiceWorkerBuildId(swSource(), "index-aaaaaa"); + const b = stampServiceWorkerBuildId(swSource(), "index-bbbbbb"); + const again = stampServiceWorkerBuildId(swSource(), "index-aaaaaa"); + expect(a).not.toEqual(b); + expect(a).toEqual(again); + }); + + it("throws when the placeholder is missing so a drifted worker fails the build", () => { + expect(() => stampServiceWorkerBuildId("const CACHE_NAME = 'paperclip';", "x")).toThrow( + /placeholder/, + ); + }); + + it("throws on an empty build id rather than shipping a nameless cache", () => { + expect(() => stampServiceWorkerBuildId(swSource(), "")).toThrow(); + }); +}); + +describe("deriveBuildIdFromEntryFileName", () => { + it("uses the content-hashed entry file name", () => { + expect(deriveBuildIdFromEntryFileName("assets/index-BHbrFFmp.js")).toBe("index-BHbrFFmp"); + }); + + it("sanitizes characters that are unsafe in a cache name", () => { + expect(deriveBuildIdFromEntryFileName("assets/index @weird!.js")).toBe("index--weird-"); + }); +}); + +describe("public/sw.js contract", () => { + it("still contains the placeholder the plugin rewrites", () => { + const swPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../public/sw.js", + ); + const source = fs.readFileSync(swPath, "utf8"); + expect(source).toContain(SERVICE_WORKER_BUILD_ID_PLACEHOLDER); + }); +}); diff --git a/ui/src/lib/vite-sw-build-id.ts b/ui/src/lib/vite-sw-build-id.ts new file mode 100644 index 0000000000..d9ea0a13d8 --- /dev/null +++ b/ui/src/lib/vite-sw-build-id.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Plugin } from "vite"; + +/** + * Stamp the service worker with a per-build id so bundle-only deploys still + * refresh parked tabs. + * + * `sw.js` is a static public asset copied verbatim into the build, and its + * update machinery (`service-worker-updates.ts`) only reloads a parked tab when + * a *new* worker takes control — which happens only when `sw.js` changes + * byte-for-byte. Without this, a deploy that ships a new app bundle but the same + * `sw.js` installs no new worker, so an open tab keeps running the old bundle + * until someone reloads by hand. Rewriting the placeholder with a value derived + * from the bundle makes `sw.js` change exactly when the app does. + */ + +export const SERVICE_WORKER_BUILD_ID_PLACEHOLDER = "__PAPERCLIP_BUILD_ID__"; + +/** + * Replace the build-id placeholder in a service-worker source string. + * + * Throws when the placeholder is absent: that means the worker drifted away + * from the contract (renamed or removed placeholder) and would ship a service + * worker that never rotates — the exact bug this plugin exists to prevent — so + * a loud build failure beats a silent no-op. + */ +export function stampServiceWorkerBuildId(source: string, buildId: string): string { + if (!source.includes(SERVICE_WORKER_BUILD_ID_PLACEHOLDER)) { + throw new Error( + `service worker is missing the ${SERVICE_WORKER_BUILD_ID_PLACEHOLDER} placeholder; ` + + "the build cannot stamp a build id and parked tabs would not refresh after a deploy", + ); + } + if (!buildId) { + throw new Error("refusing to stamp the service worker with an empty build id"); + } + return source.split(SERVICE_WORKER_BUILD_ID_PLACEHOLDER).join(buildId); +} + +/** + * Derive a build id from the emitted bundle. The entry chunk's file name + * carries a content hash that changes whenever the app code changes and stays + * stable when it does not, so the worker rotates precisely with the app. + */ +export function deriveBuildIdFromEntryFileName(entryFileName: string): string { + const base = path.basename(entryFileName).replace(/\.js$/, ""); + // Keep only characters that are safe inside a Cache Storage name. + const sanitized = base.replace(/[^A-Za-z0-9_-]/g, "-"); + return sanitized || "build"; +} + +export function serviceWorkerBuildIdPlugin( + options: { serviceWorkerFileName?: string } = {}, +): Plugin { + const serviceWorkerFileName = options.serviceWorkerFileName ?? "sw.js"; + let buildId: string | null = null; + let outDir = "dist"; + + return { + name: "paperclip-sw-build-id", + apply: "build", + configResolved(config) { + outDir = config.build.outDir; + }, + generateBundle(_options, bundle) { + const entry = Object.values(bundle).find( + (chunk) => chunk.type === "chunk" && chunk.isEntry, + ); + if (entry) { + buildId = deriveBuildIdFromEntryFileName(entry.fileName); + } + }, + closeBundle() { + const swPath = path.resolve(outDir, serviceWorkerFileName); + const source = fs.readFileSync(swPath, "utf8"); + const stamped = stampServiceWorkerBuildId(source, buildId ?? "build"); + fs.writeFileSync(swPath, stamped); + }, + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d235797fec..3ac9f91485 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,11 +4,12 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { createUiDevWatchOptions } from "./src/lib/vite-watch"; import { createApiProxy } from "./src/lib/vite-api-proxy"; +import { serviceWorkerBuildIdPlugin } from "./src/lib/vite-sw-build-id"; const apiProxy = createApiProxy(); export default defineConfig(({ mode }) => ({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), serviceWorkerBuildIdPlugin()], build: { minify: "esbuild", },