mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
ac31130bc0
commit
7fda400a8c
6 files changed
+365
-7
No files matched your search
+11
@@ -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.
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user