From 2ccac728f401cca0d98711e3c09cedd4467a8544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 06:41:16 +0200 Subject: [PATCH] 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/.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. --- Dockerfile | 3 + .../i18n/__tests__/server-catalogs.test.ts | 50 ++++++++++ src/lib/i18n/fold-label.ts | 45 +++++++++ src/lib/i18n/load-locale.ts | 16 +++- src/lib/i18n/localised-label-index.ts | 38 +------- src/lib/i18n/server-catalogs.ts | 91 +++++++++++++++++++ src/lib/i18n/shared-resolve.ts | 35 +++---- src/lib/medications/med-target-map.ts | 2 +- 8 files changed, 215 insertions(+), 65 deletions(-) create mode 100644 src/lib/i18n/__tests__/server-catalogs.test.ts create mode 100644 src/lib/i18n/fold-label.ts create mode 100644 src/lib/i18n/server-catalogs.ts diff --git a/Dockerfile b/Dockerfile index 338364c89..15198e563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/src/lib/i18n/__tests__/server-catalogs.test.ts b/src/lib/i18n/__tests__/server-catalogs.test.ts new file mode 100644 index 000000000..00c471073 --- /dev/null +++ b/src/lib/i18n/__tests__/server-catalogs.test.ts @@ -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); + }); +}); diff --git a/src/lib/i18n/fold-label.ts b/src/lib/i18n/fold-label.ts new file mode 100644 index 000000000..193ff0de6 --- /dev/null +++ b/src/lib/i18n/fold-label.ts @@ -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 = [ + [/ł/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, ""); +} diff --git a/src/lib/i18n/load-locale.ts b/src/lib/i18n/load-locale.ts index 2b6ac178c..df3da17d8 100644 --- a/src/lib/i18n/load-locale.ts +++ b/src/lib/i18n/load-locale.ts @@ -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; @@ -63,10 +67,12 @@ function initialCache(): Partial> { // 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 {}; } diff --git a/src/lib/i18n/localised-label-index.ts b/src/lib/i18n/localised-label-index.ts index b285af109..cf8a45df3 100644 --- a/src/lib/i18n/localised-label-index.ts +++ b/src/lib/i18n/localised-label-index.ts @@ -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 = [ - [/ł/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 { diff --git a/src/lib/i18n/server-catalogs.ts b/src/lib/i18n/server-catalogs.ts new file mode 100644 index 000000000..cd9f1b555 --- /dev/null +++ b/src/lib/i18n/server-catalogs.ts @@ -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/.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; + +const KEY = Symbol.for("healthlog.i18n.serverCatalogs"); + +function cache(): Map { + const slot = globalThis as unknown as { + [KEY]?: Map; + }; + 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` 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 { + const record = {} as Record; + 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; +} diff --git a/src/lib/i18n/shared-resolve.ts b/src/lib/i18n/shared-resolve.ts index c7aba9d32..578400615 100644 --- a/src/lib/i18n/shared-resolve.ts +++ b/src/lib/i18n/shared-resolve.ts @@ -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/.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> = { - de: deMessages, - en: enMessages, - fr: frMessages, - es: esMessages, - it: itMessages, - pl: plMessages, - ko: koMessages, - nl: nlMessages, -}; +export const allMessages: Record< + Locale, + Record +> = lazyServerCatalogs(); /** * Every shipped translation of one key, in `locales` order, deduplicated and diff --git a/src/lib/medications/med-target-map.ts b/src/lib/medications/med-target-map.ts index 620d0d5cd..b4101d1c5 100644 --- a/src/lib/medications/med-target-map.ts +++ b/src/lib/medications/med-target-map.ts @@ -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";