mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
perf(i18n): generate the catalog boot scripts as static assets
The dynamic /i18n/[locale] route handler answered with Next's default Cache-Control (private, max-age=0, must-revalidate) and no validator — its immutable-cache header was overridden, so a non-PWA browser would re-download the full catalog on every hard load. Static public/ files keep an ETag (repeat loads revalidate to a 304) and the service worker's cache-first rule covers the offline relaunch; the ?v= build key the layout appends busts both per deploy. scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all six locales as a prebuild/predev step (the sw-version.js pattern); output is gitignored. The route handler is removed. Measured on a production build, authenticated DE session: dashboard document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE catalog moves into a one-time 119 KB gz cached asset. H2
This commit is contained in:
1 parent
cb9d61a311
commit
9f2a3e25fe
10 files changed
+86
-68
No files matched your search
@@ -54,6 +54,7 @@ next-env.d.ts
|
||||
# scripts/generate-sw-version.mjs; loaded by public/sw.js to key
|
||||
# the cache-version baseline.
|
||||
/public/sw-version.js
|
||||
/public/i18n/
|
||||
|
||||
# v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time
|
||||
# via `scripts/fetch-geolite2.sh`. Too large for git; license key
|
||||
|
||||
@@ -113,7 +113,10 @@ export async function mockMoodInsights(page: Page): Promise<void> {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: CORRELATION_DISCOVERY_PAYLOAD, error: null }),
|
||||
body: JSON.stringify({
|
||||
data: CORRELATION_DISCOVERY_PAYLOAD,
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
"scripts": {
|
||||
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs",
|
||||
"predev": "node scripts/generate-i18n-boot.mjs",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=1 next build",
|
||||
"bundle-report": "node scripts/check-bundle-budget.mjs",
|
||||
|
||||
@@ -150,7 +150,9 @@ const budget = existsSync(BUDGET_PATH)
|
||||
|
||||
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
|
||||
console.log(`all client chunks: ${fmt(totalGz)} gz`);
|
||||
console.log(`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`);
|
||||
console.log(
|
||||
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
|
||||
);
|
||||
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
|
||||
console.log("");
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate the locale-catalog boot scripts (`public/i18n/<locale>.js`).
|
||||
*
|
||||
* The root layout emits `<script src="/i18n/<locale>.js?v=<version>" defer>`
|
||||
* ahead of Next's hydration scripts; the body assigns the active locale's
|
||||
* full message bundle to `self.__HL_I18N`, which `src/lib/i18n/load-locale.ts`
|
||||
* reads in its browser cache initializer. This replaces the former RSC-prop
|
||||
* handoff that serialized the ENTIRE active catalog into every document's
|
||||
* flight payload (392 KB of a 505 KB dashboard HTML, re-downloaded on every
|
||||
* hard load).
|
||||
*
|
||||
* Static `public/` files (rather than a route handler) because Next serves
|
||||
* them with an ETag — repeat loads revalidate to a 304 instead of
|
||||
* re-downloading the catalog — and the service worker caches the versioned
|
||||
* URL cache-first for the offline relaunch. The `?v=` query the layout
|
||||
* appends busts both caches per deploy.
|
||||
*
|
||||
* Runs as a `prebuild`/`predev` step (the `sw-version.js` pattern); output
|
||||
* is gitignored. Content is the same public catalog JSON that ships in the
|
||||
* repository — no tenant data, no secrets.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(__dirname, "..");
|
||||
const outDir = join(repoRoot, "public", "i18n");
|
||||
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl"];
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const banner =
|
||||
"// AUTO-GENERATED by scripts/generate-i18n-boot.mjs — do not edit.\n";
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const messages = JSON.parse(
|
||||
readFileSync(join(repoRoot, "messages", `${locale}.json`), "utf8"),
|
||||
);
|
||||
const payload = JSON.stringify({ locale, messages });
|
||||
writeFileSync(
|
||||
join(outDir, `${locale}.js`),
|
||||
`${banner}self.__HL_I18N = ${payload};\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`generate-i18n-boot: wrote ${LOCALES.length} catalogs to public/i18n/\n`,
|
||||
);
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
/**
|
||||
* Locale-catalog boot script.
|
||||
*
|
||||
* The root layout emits `<script src="/i18n/<locale>?v=<version>" defer>`
|
||||
* ahead of Next's hydration scripts. The body assigns the active locale's
|
||||
* full message bundle to `self.__HL_I18N`, which `load-locale.ts` reads in
|
||||
* its browser cache initializer — so the first client render holds the same
|
||||
* strings the server rendered with.
|
||||
*
|
||||
* This replaces the former RSC-prop handoff that serialized the ENTIRE
|
||||
* active catalog into every document's flight payload (392 KB of a 505 KB
|
||||
* dashboard HTML, re-downloaded on every hard load). As a versioned URL the
|
||||
* catalog is immutable-cacheable: one download per deploy per locale, then
|
||||
* HTTP/SW cache. The `?v=` query is only a cache key; the handler always
|
||||
* serves the catalog baked into the running build.
|
||||
*
|
||||
* Public by design (allowlisted in `src/proxy.ts`): the login page needs it
|
||||
* pre-auth, and the catalogs are the same strings that ship in the public
|
||||
* repository — no tenant data, no secrets.
|
||||
*/
|
||||
|
||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { locale } = await params;
|
||||
if (!(locales as readonly string[]).includes(locale)) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
locale,
|
||||
messages: allMessages[locale as Locale],
|
||||
});
|
||||
return new NextResponse(`self.__HL_I18N = ${payload};`, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
"Cache-Control": IMMUTABLE_CACHE,
|
||||
// Belt and suspenders for a script-typed response.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
+6
-5
@@ -98,7 +98,8 @@ export default async function RootLayout({
|
||||
dangerouslySetInnerHTML={{ __html: themeScript }}
|
||||
/>
|
||||
{/*
|
||||
Locale-catalog boot script (see src/app/i18n/[locale]/route.ts).
|
||||
Locale-catalog boot script (generated into public/i18n/ by
|
||||
scripts/generate-i18n-boot.mjs, a prebuild/predev step).
|
||||
Replaces the former RSC-prop handoff that inlined the whole
|
||||
active catalog into every document's flight payload (392 KB of a
|
||||
505 KB dashboard HTML). `defer` keeps it off the parse/paint
|
||||
@@ -106,12 +107,12 @@ export default async function RootLayout({
|
||||
module scripts (both ride the after-parse in-order queue, and
|
||||
this tag comes first), so `load-locale.ts` finds `self.__HL_I18N`
|
||||
set when the client bundle initializes — SSR text and first
|
||||
client render stay identical. The `?v=` build-version key makes
|
||||
the response immutable-cacheable: one catalog download per
|
||||
deploy, then HTTP/SW cache instead of per-document payload.
|
||||
client render stay identical. Served as a static asset: ETag
|
||||
revalidation (304 on repeat loads) + SW cache-first on the
|
||||
versioned URL replace the per-document payload.
|
||||
*/}
|
||||
<script
|
||||
src={`/i18n/${initialLocale}?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||||
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
|
||||
defer
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -55,9 +55,11 @@ import type {
|
||||
// loading shell matches the in-card chart footprint so the layout is stable.
|
||||
const LabBiomarkerChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then((mod) => ({
|
||||
default: mod.LabBiomarkerChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({
|
||||
default: mod.LabBiomarkerChart,
|
||||
}),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function LabBiomarkerChart(
|
||||
@@ -124,7 +126,9 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const localized = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
|
||||
if (localized) map.set(localized, seed.slug);
|
||||
const canonical = enBundle
|
||||
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)?.trim().toLowerCase()
|
||||
? resolveKey(enBundle, `labs.catalog.${seed.slug}`)
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
: undefined;
|
||||
if (canonical && !map.has(canonical)) map.set(canonical, seed.slug);
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ interface MockQueryState {
|
||||
}
|
||||
let queryState: MockQueryState;
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@tanstack/react-query")
|
||||
>("@tanstack/react-query");
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ ...queryState, refetch: vi.fn() }),
|
||||
@@ -59,7 +59,12 @@ import {
|
||||
const store = new Map<string, string>();
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
queryState = { data: undefined, isError: false, error: null, isLoading: false };
|
||||
queryState = {
|
||||
data: undefined,
|
||||
isError: false,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
};
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
|
||||
@@ -59,8 +59,8 @@ 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 {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { allMessages } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./shared-resolve") as typeof import("./shared-resolve");
|
||||
return { ...allMessages };
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user