fix(i18n): serve English for an unreadable catalog, refuse to boot without it

The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.

The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:43:03 +02:00
1 parent ac31130bc0
commit 7fda400a8c
6 files changed
+365 -7

No files matched your search

+11
View File
@@ -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.
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Checks that every shipped locale has a message catalog that parses.
*
* The server reads `messages/<locale>.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 <messages> (--config <config.ts> | --locales <file>) [--write <file>]",
);
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}`);
@@ -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/);
});
});
+8
View File
@@ -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
@@ -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<string, unknown> })[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/);
});
});
+94 -7
View File
@@ -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<string, unknown>;
@@ -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<CatalogRead, { ok: false }>) {
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). */