mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
7e932c4740
commit
2ccac728f4
8 files changed
+215
-65
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user