From c0a2c8784ec189ccec86d1339d9dc01c82374978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 22:12:14 +0200 Subject: [PATCH] feat(pwa): re-anchor SW CACHE_VERSION on every build via prebuild step --- .gitignore | 5 ++ package.json | 1 + public/sw.js | 17 ++++- scripts/__tests__/generate-sw-version.test.ts | 72 +++++++++++++++++++ scripts/generate-sw-version.mjs | 37 ++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/generate-sw-version.test.ts create mode 100644 scripts/generate-sw-version.mjs diff --git a/.gitignore b/.gitignore index f32cec364..77f714f60 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,11 @@ next-env.d.ts # prisma generated /src/generated/prisma +# v1.4.43 QoL (L3) — generated at build time by +# scripts/generate-sw-version.mjs; loaded by public/sw.js to key +# the cache-version baseline. +/public/sw-version.js + # v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time # via `scripts/fetch-geolite2.sh`. Too large for git; license key # required. The folder + a `.gitkeep` are tracked so the Dockerfile diff --git a/package.json b/package.json index 7d61719eb..659b3d7a8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "packageManager": "pnpm@10.31.0", "scripts": { "dev": "next dev", + "prebuild": "node scripts/generate-sw-version.mjs", "build": "next build", "analyze": "ANALYZE=1 next build", "bundle-report": "node scripts/print-bundle-report.mjs", diff --git a/public/sw.js b/public/sw.js index e8331fdc3..bbd0ae200 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,7 +13,22 @@ // polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`, // and triggers an SW-unregister + cache-wipe + hard reload when the // server moves ahead of the running shell. -const CACHE_VERSION = "v1.4.38.4"; +// +// v1.4.43 QoL (L3) — read the active version from `self.__APP_VERSION__`, +// which the build step writes to `/public/sw-version.js` (loaded +// synchronously below via `importScripts`). Pre-fix the literal was +// hand-bumped per release and quietly drifted four releases stale, so +// every deploy between v1.4.38.4 → v1.4.42 served the previous shell's +// cache instead of evicting it on activate. The fallback literal stays +// in case the import is missing (legacy SW versions, dev mode without +// the build step), but the source of truth is now the generated file. +try { + importScripts("/sw-version.js"); +} catch { + // Fall through to the literal fallback below. +} +const CACHE_VERSION = + (typeof self !== "undefined" && self.__APP_VERSION__) || "v1.4.43"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; diff --git a/scripts/__tests__/generate-sw-version.test.ts b/scripts/__tests__/generate-sw-version.test.ts new file mode 100644 index 000000000..f47c8e503 --- /dev/null +++ b/scripts/__tests__/generate-sw-version.test.ts @@ -0,0 +1,72 @@ +/** + * v1.4.43 QoL (L3) — verify the SW build step + the SW source contract. + * + * Pre-fix `public/sw.js` hard-coded `const CACHE_VERSION = "v1.4.38.4"` + * which drifted four releases stale. The current shape: + * - `scripts/generate-sw-version.mjs` writes `self.__APP_VERSION__` + * into `public/sw-version.js` from `package.json`. + * - `public/sw.js` `importScripts('/sw-version.js')` and reads + * `self.__APP_VERSION__` (falling back to a literal). + */ +import { execFileSync } from "node:child_process"; +import { + existsSync, + readFileSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { describe, it, expect, afterAll } from "vitest"; + +const ROOT = join(__dirname, "../.."); +const SW_PATH = join(ROOT, "public/sw.js"); +const GENERATED_PATH = join(ROOT, "public/sw-version.js"); +const SCRIPT_PATH = join(ROOT, "scripts/generate-sw-version.mjs"); +const PKG_PATH = join(ROOT, "package.json"); + +describe("public/sw.js CACHE_VERSION wiring", () => { + it("does not carry a hard-coded pre-v1.4.43 literal", () => { + const sw = readFileSync(SW_PATH, "utf8"); + // The exact pre-fix literal — pin out a regression where someone + // re-hand-codes the version instead of going through the script. + expect(sw).not.toContain('const CACHE_VERSION = "v1.4.38.4"'); + // The fix routes via `self.__APP_VERSION__`. + expect(sw).toContain("self.__APP_VERSION__"); + }); + + it("loads the generated version file via importScripts", () => { + const sw = readFileSync(SW_PATH, "utf8"); + expect(sw).toContain("importScripts"); + expect(sw).toContain("/sw-version.js"); + }); + + it("falls back to a literal when the import fails", () => { + const sw = readFileSync(SW_PATH, "utf8"); + // The fallback OR-expression keeps the SW functional in dev mode + // where the build script has not run. Pin the shape so the fallback + // can never silently vanish. + expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/); + }); +}); + +describe("scripts/generate-sw-version.mjs", () => { + afterAll(() => { + // Don't leave the generated artefact behind for the next test run. + if (existsSync(GENERATED_PATH)) rmSync(GENERATED_PATH); + }); + + it("writes self.__APP_VERSION__ matching package.json", () => { + execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" }); + expect(existsSync(GENERATED_PATH)).toBe(true); + const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8")); + const generated = readFileSync(GENERATED_PATH, "utf8"); + expect(generated).toContain(`self.__APP_VERSION__ = "v${pkg.version}"`); + }); + + it("re-running the script is idempotent (overwrites with same content)", () => { + execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" }); + const a = readFileSync(GENERATED_PATH, "utf8"); + execFileSync("node", [SCRIPT_PATH], { stdio: "pipe" }); + const b = readFileSync(GENERATED_PATH, "utf8"); + expect(b).toBe(a); + }); +}); diff --git a/scripts/generate-sw-version.mjs b/scripts/generate-sw-version.mjs new file mode 100644 index 000000000..5157f051b --- /dev/null +++ b/scripts/generate-sw-version.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * v1.4.43 QoL (L3) — write the active package.json version into + * `public/sw-version.js`. The Service Worker (`public/sw.js`) loads + * this file via `importScripts('/sw-version.js')` and reads + * `self.__APP_VERSION__` to key its `CACHE_VERSION`. + * + * Pre-fix the SW carried a hand-bumped literal that drifted four + * releases stale; the `activate`-step's old-cache eviction became + * a no-op for every deploy in v1.4.38.4 → v1.4.42. Running this + * script as a `prebuild` step re-anchors the version on every build, + * so the eviction now fires reliably. + * + * Idempotent: writes the file unconditionally. Safe to commit the + * generated file or `.gitignore` it — either way `pnpm build` reruns + * the generator. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); +const pkgPath = join(repoRoot, "package.json"); +const outPath = join(repoRoot, "public", "sw-version.js"); + +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +const version = `v${pkg.version}`; + +const banner = + "// AUTO-GENERATED by scripts/generate-sw-version.mjs — do not edit.\n"; +const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`; + +writeFileSync(outPath, banner + body, "utf8"); + +// One-line log so the build output shows the version we baked in. +process.stdout.write(`sw-version.js → ${version}\n`);