mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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