perf(i18n): read server catalogs from disk, one locale at a time

The eight catalogs were static JSON imports, so every server bundle
carried the whole eight-locale chunk as a source string and parsed all of
them: 32 parsed copies and two 9.7 MB chunk sources in an idle process.
The server now reads messages/<locale>.json the first time a translator,
the SSR cache or a matcher asks for that locale, and keeps one parsed copy
per process on globalThis. allMessages keeps its shape; its entries load on
first read. The image copies messages/ next to the server.

foldLabel moves to a leaf module, so the client medication map no longer
imports the catalog index.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 06:41:16 +02:00
1 parent 7e932c4740
commit 2ccac728f4
8 files changed
+215 -65

No files matched your search

+3
View File
@@ -105,6 +105,9 @@ RUN addgroup --system --gid 1001 nodejs && \
# Copy built assets
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
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,50 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { getServerTranslator } from "../server-translator";
import {
lazyServerCatalogs,
loadedServerLocales,
serverCatalog,
} from "../server-catalogs";
describe("server catalogs", () => {
it("reads a locale from messages/ on first use and keeps one copy", () => {
const de = serverCatalog("de");
expect(de).toEqual(
JSON.parse(
readFileSync(join(process.cwd(), "messages", "de.json"), "utf8"),
),
);
expect(serverCatalog("de")).toBe(de);
expect(loadedServerLocales()).toContain("de");
});
it("loads only the locale an entry of the lazy record is read for", () => {
const record = lazyServerCatalogs();
const before = new Set(loadedServerLocales());
void record.it;
const added = loadedServerLocales().filter((l) => !before.has(l));
expect(added.every((l) => l === "it")).toBe(true);
expect(record.it).toBe(serverCatalog("it"));
});
it("lets an assignment replace an entry", () => {
const record = lazyServerCatalogs();
record.ko = { common: { save: "x" } };
expect(record.ko).toEqual({ common: { save: "x" } });
expect(serverCatalog("ko")).not.toEqual({ common: { save: "x" } });
});
it("refuses a locale that is not shipped", () => {
expect(() => serverCatalog("xx" as "de")).toThrow(/unknown locale/);
});
it("serves the server translator from the same catalogs", () => {
const t = getServerTranslator("fr");
const fr = serverCatalog("fr") as { common: { save: string } };
expect(t.t("common.save")).toBe(fr.common.save);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* The match form of a label, without the catalogs.
*
* `localised-label-index.ts` builds its indexes from every shipped catalog
* and is server-only; this fold is the part client code needs too (the
* medication target map folds a free-text name the same way), so it lives
* in a leaf that imports nothing.
*/
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
+11 -5
View File
@@ -7,8 +7,8 @@
* for every user, in every language). The t() chain (active locale →
* EN → raw key) now resolves its bundles through three paths:
*
* 1. SSR pass: this module seeds its cache with the full server-side
* catalog map (`shared-resolve`) inside a `typeof window ===
* 1. SSR pass: this module seeds its cache with the lazy server-side
* catalog map (`server-catalogs`) inside a `typeof window ===
* "undefined"` branch. Turbopack compiles the branch away for the
* browser bundle (verified — zero catalog bytes reach any client
* chunk), while the server render of every client component
@@ -33,6 +33,10 @@
* seed bundles directly (`primeMessages`).
*/
import { type Locale } from "./config";
import type { lazyServerCatalogs } from "./server-catalogs";
/** The SSR branch's view of `./server-catalogs` (types only; see below). */
type ServerCatalogsModule = { lazyServerCatalogs: typeof lazyServerCatalogs };
export type MessageBundle = Record<string, unknown>;
@@ -63,10 +67,12 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
// where a bare require of a TS module throws) — there the cache starts
// empty and `vitest.setup.ts` primes every bundle up front.
try {
const { allMessages } =
const { lazyServerCatalogs } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./shared-resolve") as typeof import("./shared-resolve");
return { ...allMessages };
require("./server-catalogs") as ServerCatalogsModule;
// Entries load on first read, so the render loads the locales it
// renders in, not all of them.
return lazyServerCatalogs();
} catch {
return {};
}
+2 -36
View File
@@ -24,44 +24,10 @@
* what the resolver ACCEPTS.
*/
import { locales, type Locale } from "./config";
import { foldLabel } from "./fold-label";
import { allMessages, resolveKey } from "./shared-resolve";
/**
* Letters Unicode decomposition leaves alone because their mark is part of
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
* way without this. Expressed as ASCII equivalents, the same target the
* combining-mark strip below produces.
*/
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
[/ł/g, "l"],
[/ø/g, "o"],
[/đ/g, "d"],
[/ß/g, "ss"],
[/æ/g, "ae"],
[/œ/g, "oe"],
];
/**
* Fold a label or a free-text term to its match form: compatibility-normalised
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
* "Pain (0–10)" and "pain 0 10" agree).
*
* Applied to BOTH sides of every comparison — a bundle label and the caller's
* word go through the same function, so the index cannot drift from the lookup.
*/
export function foldLabel(raw: string): string {
let folded = raw
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
folded = folded.replace(pattern, ascii);
}
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
}
export { foldLabel };
/** One locale's rendering of `key`, or `null` when that bundle omits it. */
export function labelIn(locale: Locale, key: string): string | null {
+91
View File
@@ -0,0 +1,91 @@
/**
* The message catalogs on the server: read from disk on first use, one
* locale at a time, once per process.
*
* The catalogs used to be eight static JSON imports. A production build gives
* every server bundle its own copy of a shared module (route handlers, the
* RSC/SSR renderer, the instrumentation bundle that runs the worker), so each
* of them carried the whole eight-locale chunk as a source string and parsed
* all eight on load: a boot profile of an idle process found 32 parsed
* catalog objects and two 9.7 MB chunk sources, about a fifth of the live
* heap, for a process whose users read one or two locales.
*
* Now nothing is bundled: a catalog is `messages/<locale>.json` read with
* `readFileSync` the first time a translator or a matcher asks for that
* locale, and parked on `globalThis`, so every bundle copy in the process
* shares one parsed object per locale. The read is synchronous because
* `t()` is; it happens once per locale per process and takes a few
* milliseconds.
*
* The directory is `messages/` under the working directory: the checkout in
* development and tests, `/app` in the image, which copies it there.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { locales, type Locale } from "./config";
export type ServerCatalog = Record<string, unknown>;
const KEY = Symbol.for("healthlog.i18n.serverCatalogs");
function cache(): Map<Locale, ServerCatalog> {
const slot = globalThis as unknown as {
[KEY]?: Map<Locale, ServerCatalog>;
};
slot[KEY] ??= new Map();
return slot[KEY];
}
function catalogDirectory(): string {
// Not a bundler input: the catalogs are read at run time, and tracing
// `process.cwd()` would pull the whole checkout into the output.
return join(/* turbopackIgnore: true */ process.cwd(), "messages");
}
/** One locale's catalog, read and parsed on first use. */
export function serverCatalog(locale: Locale): ServerCatalog {
const loaded = cache();
const hit = loaded.get(locale);
if (hit) return hit;
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;
}
/** Locales whose catalog this process has read so far (for tests and logs). */
export function loadedServerLocales(): Locale[] {
return [...cache().keys()];
}
/**
* A `Record<Locale, catalog>` whose entries load on first read. Indexing one
* locale loads only that one; iterating every locale (the matchers that
* accept a word in any language) loads them all, as before. An assignment
* replaces the entry, which is what the SSR cache in `load-locale.ts` and
* the test helpers do.
*/
export function lazyServerCatalogs(): Record<Locale, ServerCatalog> {
const record = {} as Record<Locale, ServerCatalog>;
for (const locale of locales) {
Object.defineProperty(record, locale, {
enumerable: true,
configurable: true,
get: () => serverCatalog(locale),
set: (value: ServerCatalog) => {
Object.defineProperty(record, locale, {
value,
writable: true,
enumerable: true,
configurable: true,
});
},
});
}
return record;
}
+12 -23
View File
@@ -9,38 +9,27 @@
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
* layout picks the active locale's bundle out of it for the RSC handoff
* and `getServerTranslator()` resolves against it. Do NOT import this
* module from client code — the seven static JSON imports would drag
* every locale back into the client chunk (the ~1.4 MiB regression the
* split removed). Client code uses `./load-locale` (static EN fallback
* floor + dynamic per-locale imports) and `./resolve-key` instead.
* module from client code: it reads the catalogs from disk. Client code
* uses `./load-locale` (dynamic per-locale imports) and `./resolve-key`
* instead.
*/
import deMessages from "../../../messages/de.json";
import enMessages from "../../../messages/en.json";
import frMessages from "../../../messages/fr.json";
import esMessages from "../../../messages/es.json";
import itMessages from "../../../messages/it.json";
import plMessages from "../../../messages/pl.json";
import koMessages from "../../../messages/ko.json";
import nlMessages from "../../../messages/nl.json";
import { locales, type Locale } from "./config";
import { resolveKey } from "./resolve-key";
import { lazyServerCatalogs } from "./server-catalogs";
export { resolveKey };
/**
* Single source of truth for the per-locale message bundle on the
* server (layout RSC handoff, server translator, jobs).
* server (layout RSC handoff, server translator, jobs). Each entry is read
* from `messages/<locale>.json` the first time it is indexed and shared by
* every bundle in the process (`server-catalogs.ts`), so a process holds the
* locales its users read, not all of them.
*/
export const allMessages: Record<Locale, Record<string, unknown>> = {
de: deMessages,
en: enMessages,
fr: frMessages,
es: esMessages,
it: itMessages,
pl: plMessages,
ko: koMessages,
nl: nlMessages,
};
export const allMessages: Record<
Locale,
Record<string, unknown>
> = lazyServerCatalogs();
/**
* Every shipped translation of one key, in `locales` order, deduplicated and
+1 -1
View File
@@ -28,7 +28,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { findDrugIdByBrand } from "@/lib/medications/glp1-knowledge";
import { foldLabel } from "@/lib/i18n/localised-label-index";
import { foldLabel } from "@/lib/i18n/fold-label";
/** The medication classes the storyline can reason about (closed set). */
export type MedTargetClass = "antihypertensive" | "antidiabetic" | "glp1";