diff --git a/next.config.ts b/next.config.ts index 2edfcfba3..672f10a91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,31 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { version as PKG_VERSION } from "./package.json"; +/** + * The message catalogs' content hash, the cache key of the boot script + * (`/i18n/.js?v=…`, `src/app/layout.tsx`). The service worker serves + * that URL cache-first, so a key that does not move with the catalog keeps + * an old catalog alive: keyed by the package version, every build of one + * release (a beta, a redeploy) shared one URL, and a key added since the + * first of them read as its raw name. + */ +function catalogVersion(): string { + const dir = join(process.cwd(), "messages"); + const hash = createHash("sha256"); + for (const file of readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort()) { + hash.update(file).update(readFileSync(join(dir, file))); + } + return hash.digest("hex").slice(0, 16); +} + const nextConfig: NextConfig = { output: "standalone", // `next build` type-checks the whole tsconfig program since 16.3, which @@ -20,6 +43,7 @@ const nextConfig: NextConfig = { // had to discover "pull-to-refresh" themselves after every release. env: { NEXT_PUBLIC_APP_VERSION: PKG_VERSION, + NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(), }, // v1.4.33 IW2 — strip `console.*` calls from the production bundle // (keep `console.error` + `console.warn` so the GlitchTip reporter diff --git a/public/sw.js b/public/sw.js index ce689494f..603d4a6af 100644 --- a/public/sw.js +++ b/public/sw.js @@ -230,8 +230,9 @@ self.addEventListener("fetch", (event) => { return; } - // Locale-catalog boot script — cache-first. The URL is versioned - // (`/i18n/?v=`) and served immutable, so cache-first is + // Locale-catalog boot script — cache-first. The URL is versioned by the + // catalogs' content hash (`/i18n/?v=`, `next.config.ts`), + // so a changed catalog is a new URL and cache-first is // correct and keeps the catalog available on an offline relaunch (the // cached shell HTML references the same versioned URL it was rendered // with). Carries no user data — same public strings as the repository's diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 79f28af68..027cb5262 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { // --- Baked at build time by the Dockerfile's ARG/ENV pairs. --- NEXT_PUBLIC_APP_VERSION: "Dockerfile build arg; a runtime value cannot change what is compiled in", + NEXT_PUBLIC_I18N_CATALOG_VERSION: + "computed by next.config.ts from the message catalogs at build time", NEXT_PUBLIC_APP_BUILD_SHA: "Dockerfile build arg, stamped from the release commit", NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build", diff --git a/src/__tests__/i18n-catalog-cache-key.test.ts b/src/__tests__/i18n-catalog-cache-key.test.ts new file mode 100644 index 000000000..6888e79f6 --- /dev/null +++ b/src/__tests__/i18n-catalog-cache-key.test.ts @@ -0,0 +1,31 @@ +/** + * The locale catalog's boot script is cached by its URL: the service worker + * serves `/i18n/.js?v=…` cache-first. Keyed by the package version, + * every build of one release shared one URL, so a beta updated within a + * release kept its first catalog and every key added since read as its raw + * name ("timeline.zoom.range"). The key is the catalogs' content hash now. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +describe("the locale catalog's cache key", () => { + it("moves with the catalogs, not with the release", () => { + const layout = read("src/app/layout.tsx"); + const src = + /src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout); + expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION"); + expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION"); + }); + + it("is computed from the message catalogs at build time", () => { + const config = read("next.config.ts"); + expect(config).toMatch( + /NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/, + ); + expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 57e112094..e935efd77 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -131,7 +131,9 @@ export default async function RootLayout({ id="healthlog-i18n-boot" strategy="beforeInteractive" nonce={nonce} - src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`} + // Keyed by the catalogs' content (`next.config.ts`), not the + // release: the service worker serves this URL cache-first. + src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_I18N_CATALOG_VERSION || "dev"}`} />