diff --git a/Dockerfile b/Dockerfile index ecc726d6a..db86834e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version +# Every shipped locale's message catalog is present and parses, checked +# against the locale list in the source; the list is written out so the runner +# stage can check the catalogs the image actually carries. +RUN node scripts/verify-message-catalogs.mjs --dir messages \ + --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner @@ -111,6 +116,12 @@ COPY --from=builder /app/public ./public # The server reads its message catalogs from here on first use, one locale at # a time (src/lib/i18n/server-catalogs.ts); they are not bundled. COPY --from=builder /app/messages ./messages +# A missing or broken catalog is only noticed when a page asks for that +# locale, so check the copies in this stage against the builder's list. +COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/ +RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \ + --locales /tmp/catalog-check/.message-locales && \ + rm -rf /tmp/catalog-check COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Next's standalone output must carry the externalized worker dependencies. diff --git a/scripts/verify-message-catalogs.mjs b/scripts/verify-message-catalogs.mjs new file mode 100644 index 000000000..0e8dd79b5 --- /dev/null +++ b/scripts/verify-message-catalogs.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Checks that every shipped locale has a message catalog that parses. + * + * The server reads `messages/.json` from disk at run time + * (`src/lib/i18n/server-catalogs.ts`); nothing is bundled, so an image that + * lost the directory, or one file of it, builds and starts clean and only + * shows the gap when a page asks for that locale. The Dockerfile runs this + * twice: in the builder against the locale list in `src/lib/i18n/config.ts`, + * writing that list out, and in the runner stage against the catalogs the + * image actually carries. + * + * node scripts/verify-message-catalogs.mjs --dir messages \ + * --config src/lib/i18n/config.ts [--write .message-locales] + * node verify-message-catalogs.mjs --dir /app/messages \ + * --locales .message-locales + * + * Exits 1 with every problem listed when a catalog is missing, does not parse + * or is not a JSON object, or when the locale list cannot be read. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? null : (process.argv[index + 1] ?? null); +} + +function localesFromConfig(path) { + const source = readFileSync(path, "utf8"); + const block = /export const locales = \[([^\]]*)\]/.exec(source); + if (!block) throw new Error(`no \`export const locales = [...]\` in ${path}`); + const list = [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); + if (list.length === 0) throw new Error(`the locale list in ${path} is empty`); + return list; +} + +function localesFromFile(path) { + const list = readFileSync(path, "utf8").split("\n").filter(Boolean); + if (list.length === 0) throw new Error(`the locale list in ${path} is empty`); + return list; +} + +const dir = arg("dir"); +const config = arg("config"); +const listFile = arg("locales"); +if (!dir || (!config && !listFile)) { + console.error( + "usage: verify-message-catalogs.mjs --dir (--config | --locales ) [--write ]", + ); + process.exit(2); +} + +let locales; +try { + locales = config ? localesFromConfig(config) : localesFromFile(listFile); +} catch (error) { + console.error(`ERROR: ${error.message}`); + process.exit(1); +} + +const problems = []; +for (const locale of locales) { + const path = join(dir, `${locale}.json`); + let text; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + problems.push( + `${path}: ${error.code === "ENOENT" ? "missing" : error.message}`, + ); + continue; + } + try { + const parsed = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + problems.push(`${path}: not a JSON object`); + } + } catch (error) { + problems.push(`${path}: does not parse (${error.message})`); + } +} + +if (problems.length > 0) { + console.error( + "ERROR: message catalogs the server reads at run time are broken:", + ); + for (const problem of problems) console.error(` ${problem}`); + process.exit(1); +} + +const write = arg("write"); +if (write) writeFileSync(write, `${locales.join("\n")}\n`); +console.log(`message catalogs ok: ${locales.join(", ")} in ${dir}`); diff --git a/src/__tests__/verify-message-catalogs-script.test.ts b/src/__tests__/verify-message-catalogs-script.test.ts new file mode 100644 index 000000000..06628fb27 --- /dev/null +++ b/src/__tests__/verify-message-catalogs-script.test.ts @@ -0,0 +1,63 @@ +/** + * `scripts/verify-message-catalogs.mjs`, which the image build runs in the + * builder and again in the runner stage, passes the shipped catalogs and fails + * on a missing or corrupt one. A check that cannot fail would let an image + * without its catalogs through, so both directions are pinned. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const SCRIPT = join(process.cwd(), "scripts", "verify-message-catalogs.mjs"); +const CONFIG = join(process.cwd(), "src", "lib", "i18n", "config.ts"); + +function run(args: string[]) { + return spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" }); +} + +let scratch: string | null = null; +afterEach(() => { + if (scratch) rmSync(scratch, { recursive: true, force: true }); + scratch = null; +}); + +describe("verify-message-catalogs", () => { + it("passes the catalogs in the tree and writes the locale list", () => { + scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-")); + const list = join(scratch, "locales"); + const res = run([ + "--dir", + join(process.cwd(), "messages"), + "--config", + CONFIG, + "--write", + list, + ]); + expect(res.status).toBe(0); + expect(res.stdout).toMatch(/message catalogs ok: .*\ben\b/); + + // The runner-stage form, against the list the builder wrote. + expect( + run(["--dir", join(process.cwd(), "messages"), "--locales", list]).status, + ).toBe(0); + }); + + it("fails on a missing catalog and on one that does not parse", () => { + scratch = mkdtempSync(join(tmpdir(), "hl-catalog-check-")); + const dir = join(scratch, "messages"); + mkdirSync(dir); + writeFileSync(join(dir, "en.json"), "{}"); + writeFileSync(join(dir, "de.json"), '{"common": '); + const list = join(scratch, "locales"); + writeFileSync(list, "en\nde\nfr\n"); + + const res = run(["--dir", dir, "--locales", list]); + expect(res.status).toBe(1); + expect(res.stderr).toMatch(/de\.json: does not parse/); + expect(res.stderr).toMatch(/fr\.json: missing/); + expect(res.stderr).not.toMatch(/en\.json/); + }); +}); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 35eddfef6..d75f9f8e5 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -13,6 +13,14 @@ export async function register() { ], ); startEventLoopLagMonitor(); + + // Every locale falls back to the English message catalog, so a process + // without a readable `messages/en.json` cannot serve a page or a job. + // Refuse to start with the file named, rather than fail every request. + const { assertDefaultCatalogReadable } = + await import("@/lib/i18n/server-catalogs"); + assertDefaultCatalogReadable(); + beginBootStep("readiness_summary"); // First-boot readiness summary: a human-readable block on the core secrets diff --git a/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts new file mode 100644 index 000000000..cd709fccf --- /dev/null +++ b/src/lib/i18n/__tests__/server-catalogs-fallback.test.ts @@ -0,0 +1,91 @@ +/** + * A message catalog that cannot be read costs its own locale, not every + * request. + * + * The catalogs are read from `messages/` at run time. A missing or corrupt + * file used to throw from `serverCatalog`, and because the matchers iterate + * every locale (`localisedValues`), one bad non-English file made every + * request that touched them a 500. Now that locale is served the English + * catalog and the failure is logged once; the English catalog itself, which + * everything falls back to, stops the process at boot with the file named. + */ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() })); + +import { emitSignal } from "@/lib/logging/signal"; + +import { localisedValues } from "../shared-resolve"; +import { + assertDefaultCatalogReadable, + serverCatalog, +} from "../server-catalogs"; + +const CACHE = Symbol.for("healthlog.i18n.serverCatalogs"); +const EN = { common: { save: "Save" } }; + +function clearCatalogCache() { + (globalThis as { [CACHE]?: Map })[CACHE]?.clear(); +} + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hl-catalogs-")); + mkdirSync(join(root, "messages")); + writeFileSync(join(root, "messages", "en.json"), JSON.stringify(EN)); + writeFileSync(join(root, "messages", "de.json"), '{"common": {"save": '); + vi.spyOn(process, "cwd").mockReturnValue(root); + vi.mocked(emitSignal).mockClear(); + clearCatalogCache(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + clearCatalogCache(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("a catalog that cannot be read", () => { + it("serves a corrupt locale the English catalog and logs it once", () => { + expect(serverCatalog("de")).toEqual(EN); + expect(serverCatalog("de")).toEqual(EN); + expect(emitSignal).toHaveBeenCalledTimes(1); + expect(emitSignal).toHaveBeenCalledWith( + expect.objectContaining({ + action: "i18n.catalog.fallback", + level: "warn", + meta: { locale: "de", reason: "corrupt", fallback: "en" }, + }), + ); + }); + + it("serves a missing locale the English catalog", () => { + expect(serverCatalog("fr")).toEqual(EN); + expect(emitSignal).toHaveBeenCalledWith( + expect.objectContaining({ + meta: { locale: "fr", reason: "missing", fallback: "en" }, + }), + ); + }); + + it("keeps a matcher that reads every locale answering", () => { + expect(localisedValues("common.save")).toEqual(["Save"]); + }); + + it("refuses to boot without the English catalog, naming the file", () => { + rmSync(join(root, "messages", "en.json")); + expect(() => assertDefaultCatalogReadable()).toThrow( + /en message catalog at .*messages[/\\]en\.json is missing/, + ); + }); + + it("refuses to boot on an English catalog that does not parse", () => { + writeFileSync(join(root, "messages", "en.json"), "[1, 2"); + expect(() => assertDefaultCatalogReadable()).toThrow(/is corrupt/); + }); +}); diff --git a/src/lib/i18n/server-catalogs.ts b/src/lib/i18n/server-catalogs.ts index cd9f1b555..138cd9f29 100644 --- a/src/lib/i18n/server-catalogs.ts +++ b/src/lib/i18n/server-catalogs.ts @@ -18,12 +18,23 @@ * milliseconds. * * The directory is `messages/` under the working directory: the checkout in - * development and tests, `/app` in the image, which copies it there. + * development and tests, `/app` in the image, which copies it there (and the + * image build checks every locale's file is there and parses). + * + * A catalog that cannot be read is not every request's problem. A missing or + * corrupt non-English catalog is logged once per process and that locale is + * served the English one; before, the read threw, and since the matchers + * iterate every locale, one bad file turned every request into a 500. The + * English catalog is the fallback itself, so a process without it cannot serve + * at all: the boot check (`assertDefaultCatalogReadable`) refuses to start one + * with a message that names the file. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { locales, type Locale } from "./config"; +import { emitSignal } from "@/lib/logging/signal"; + +import { defaultLocale, locales, type Locale } from "./config"; export type ServerCatalog = Record; @@ -51,11 +62,87 @@ export function serverCatalog(locale: Locale): ServerCatalog { if (!(locales as readonly string[]).includes(locale)) { throw new Error(`unknown locale: ${String(locale)}`); } - const parsed = JSON.parse( - readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"), - ) as ServerCatalog; - loaded.set(locale, parsed); - return parsed; + const read = readCatalog(locale); + if (read.ok) { + loaded.set(locale, read.catalog); + return read.catalog; + } + if (locale === defaultLocale) { + throw new Error(defaultCatalogMessage(read)); + } + // Logged once: the fallback is cached under the locale, so the next read + // of it is a cache hit. + emitSignal({ + action: "i18n.catalog.fallback", + level: "warn", + meta: { locale, reason: read.reason, fallback: defaultLocale }, + error: read.error, + }); + const fallback = serverCatalog(defaultLocale); + loaded.set(locale, fallback); + return fallback; +} + +type CatalogRead = + | { ok: true; catalog: ServerCatalog } + | { + ok: false; + path: string; + reason: "missing" | "unreadable" | "corrupt"; + error: unknown; + }; + +function readCatalog(locale: Locale): CatalogRead { + const path = join(catalogDirectory(), `${locale}.json`); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (error) { + const missing = (error as NodeJS.ErrnoException)?.code === "ENOENT"; + return { + ok: false, + path, + reason: missing ? "missing" : "unreadable", + error, + }; + } + try { + const parsed: unknown = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return { + ok: false, + path, + reason: "corrupt", + error: new Error("the catalog is not a JSON object"), + }; + } + return { ok: true, catalog: parsed as ServerCatalog }; + } catch (error) { + return { ok: false, path, reason: "corrupt", error }; + } +} + +function defaultCatalogMessage(read: Extract) { + const detail = + read.error instanceof Error ? read.error.message : String(read.error); + return ( + `The ${defaultLocale} message catalog at ${read.path} is ${read.reason} ` + + `(${detail}). Every other locale falls back to it, so the server cannot ` + + `serve without it; the image must ship messages/*.json next to server.js.` + ); +} + +/** + * Boot check: read the English catalog now and throw a message naming the + * file when it is missing or does not parse, so a broken image stops at boot + * instead of failing every request. + */ +export function assertDefaultCatalogReadable(): void { + serverCatalog(defaultLocale); } /** Locales whose catalog this process has read so far (for tests and logs). */