Files
HealthLog/scripts/check-bundle-budget.mjs
T
Marc-André Bombeck 69aac232b4 build: budget the routes on measured drift, not on a ceiling they touch
Three of the four watched routes sat within a kilobyte of their ceiling —
444 of 445, 459 of 460, 459 of 460 — because the numbers had been re-stated
for four releases and never re-based. A budget a route is already touching
does not measure the route; it measures whichever dependency bump landed that
week. e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the
445 KB budget` with nothing in the diff that touched the bundle, and it was one
of at least four e2e failures that were the budget gate rather than a test.

The file states the measured value per route now, plus one drift allowance over
it, and a route fails on the delta. Measured on a local build at d48bbff01:
/ 447, /insights 434, /measurements 433, /insights/mood 447, allowance 20 KB, so
the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The
allowance covers the runner delta and ordinary week-to-week drift; the step
change the gate exists for, a statically imported catalog or a duplicated chart
library, is several times larger and still fails. Every run prints the delta, so
a route that has crept most of the way through its allowance is legible in the
log long before it is red. The reason and the re-base instructions are written
into the file's running note, as with every raise before it.

A budget file with no baselines used to gate nothing quietly, since the loop
had nothing to walk; it now refuses.

Gates, against the current build:

  real baselines                 passed; /page +0.3, /insights +0.3,
                                 /measurements +0.2, /insights/mood +0.3 KB
  /page baseline 425 (cap 445),  FAILED: "/page: 447 KB gz is 21.6 KB over the
  /insights 413 (cap 433)        425 KB baseline, past the 20 KB drift
                                 allowance (cap 445 KB)" and the same for
                                 /insights/page, exit 1
  routeBaselineKbGz removed      exit 2, "states no routeBaselineKbGz entries,
                                 so no route is gated"
2026-09-09 22:48:16 +02:00

266 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Client-bundle report + budget gate for the Turbopack build.
*
* The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op
* under Turbopack and the old `.next/analyze/client.json` reader had
* nothing to read — so bundle regressions (a statically imported message
* catalog, a duplicated recharts chunk group) shipped invisibly. This
* script derives the signal straight from the build output instead:
*
* - per-route client-JS totals (gzip) from the Turbopack
* `*_client-reference-manifest.js` files plus the shared
* `rootMainFiles` baseline from `build-manifest.json`;
* - the total gzip weight of every emitted client chunk;
* - a recharts-duplication guard: the number of chunks carrying the
* recharts library fingerprint must stay exactly 1 (the shared
* chart-runtime chunk group);
* - a catalog guard: no message-catalog-fingerprinted chunk may be
* referenced by any route's client-reference manifest or by the
* shared baseline (catalogs load only lazily / via /i18n/<locale>).
*
* Usage:
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
*
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip.
*
* Per-route the file states a MEASURED baseline and one drift allowance
* over it, and a route fails on the delta rather than on a ceiling it is
* already touching. Flat ceilings had been re-stated but never re-based, so
* three routes ended up sitting within a kilobyte of theirs and any
* unrelated dependency bump failed the e2e job on a regression nobody had
* made. The delta is printed on every run, so a route that has crept most of
* the way through its allowance is readable in the log before it is red.
*
* The gate still exists to catch step changes (a ~100 KB statically imported
* catalog, a ~90 KB duplicate library), which are several times the
* allowance. Re-base consciously, in the same PR that pays the cost, with
* the reason written into `$comment`.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
const CHECK = process.argv.includes("--check");
const BUILD_DIR = join(process.cwd(), ".next");
const APP_DIR = join(BUILD_DIR, "server", "app");
const BUDGET_PATH = join(process.cwd(), "bundle-budget.json");
if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
console.error(
"check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.",
);
process.exit(2);
}
/** Recharts library fingerprint (present once in the shared runtime chunk). */
const RECHARTS_MARK = "CartesianGrid";
/**
* Message-catalog fingerprint. The key set is identical across all six
* locales, so a KEY-position match ("typeTimeInDaylight" directly followed
* by a colon, in either the plain or the JSON.parse-escaped chunk encoding)
* marks a bundled catalog. Source modules only ever carry the namespaced
* call-site form (`measurements.typeTimeInDaylight`), which matches neither.
*/
const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":'];
const sizeCache = new Map();
function chunkInfo(file) {
if (!sizeCache.has(file)) {
try {
const buf = readFileSync(join(BUILD_DIR, file));
sizeCache.set(file, {
raw: buf.length,
gz: gzipSync(buf, { level: 9 }).length,
recharts: buf.includes(RECHARTS_MARK),
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
});
} catch {
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
}
}
return sizeCache.get(file);
}
function routeChunks(manifestFile) {
const src = readFileSync(manifestFile, "utf8");
const idx = src.indexOf("= {");
const json = JSON.parse(src.slice(idx + 2).replace(/;\s*$/, ""));
const files = new Set();
for (const key of Object.keys(json.clientModules ?? {})) {
for (const chunk of json.clientModules[key].chunks ?? []) {
if (chunk.endsWith(".js")) files.add(chunk.replace(/^\/_next\//, ""));
}
}
return files;
}
function findRouteManifests(dir, prefix = "") {
let out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name.endsWith("_client-reference-manifest.js")) {
out.push({
route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`,
file: p,
});
}
}
return out;
}
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
// ── Shared baseline ─────────────────────────────────────────────────────────
const buildManifest = JSON.parse(
readFileSync(join(BUILD_DIR, "build-manifest.json"), "utf8"),
);
const rootFiles = buildManifest.rootMainFiles ?? [];
let rootGz = 0;
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
// ── Per-route totals ────────────────────────────────────────────────────────
const manifests = findRouteManifests(APP_DIR);
const routeTotals = new Map();
for (const { route, file } of manifests) {
let gz = 0;
let catalogRef = false;
for (const chunk of routeChunks(file)) {
const info = chunkInfo(chunk);
gz += info.gz;
if (info.catalog) catalogRef = true;
}
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
}
// ── Whole-build chunk scan ──────────────────────────────────────────────────
const chunkDir = join(BUILD_DIR, "static", "chunks");
let totalGz = 0;
let rechartsChunks = 0;
let catalogChunks = 0;
let catalogGz = 0;
let largest = { file: "-", gz: 0 };
for (const f of readdirSync(chunkDir)) {
if (!f.endsWith(".js")) continue;
const info = chunkInfo(join("static", "chunks", f));
totalGz += info.gz;
if (info.recharts) rechartsChunks += 1;
if (info.catalog) {
catalogChunks += 1;
catalogGz += info.gz;
}
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
}
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
// ── Report ──────────────────────────────────────────────────────────────────
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
: null;
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
console.log(`all client chunks: ${fmt(totalGz)} gz`);
console.log(
` lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks)`,
);
console.log(` other client chunks: ${fmt(totalGz - catalogGz)} gz`);
console.log(
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
);
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
/** The ceiling a route's measured baseline plus the drift allowance buys it. */
const capFor = (route) =>
baselines[route] == null ? null : baselines[route] + allowanceKb;
const watched = Object.keys(baselines);
const rows = [...routeTotals.entries()]
.filter(([route]) => !watched.length || watched.includes(route))
.sort((a, b) => b[1].gz - a[1].gz);
for (const [route, { gz }] of rows.slice(0, watched.length || 15)) {
const baseline = baselines[route];
if (baseline == null) {
console.log(`${fmt(gz).padStart(8)} gz ${route}`);
continue;
}
const delta = kb(gz) - baseline;
const sign = delta >= 0 ? "+" : "−";
console.log(
`${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` +
`${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` +
`cap ${capFor(route)} KB)`,
);
}
if (!CHECK) process.exit(0);
// ── Budget gate ─────────────────────────────────────────────────────────────
if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
// A budget file that states no baselines gates nothing, and would do it
// quietly — the loop below would simply have nothing to walk. Say so instead.
if (watched.length === 0) {
console.error(
"check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " +
"entries, so no route is gated. Measure the eager routes and write " +
"them in, with a routeDriftAllowanceKbGz over them.",
);
process.exit(2);
}
const failures = [];
for (const [route, baselineKb] of Object.entries(baselines)) {
const actual = routeTotals.get(route);
if (!actual) {
failures.push(`route ${route} not found in the build output`);
continue;
}
const delta = kb(actual.gz) - baselineKb;
if (delta > allowanceKb) {
failures.push(
`${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` +
`${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` +
`allowance (cap ${capFor(route)} KB)`,
);
}
}
if (budget.totalClientKbGz && kb(totalGz) > budget.totalClientKbGz) {
failures.push(
`total client JS ${fmt(totalGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
);
}
if (
budget.maxRechartsChunks != null &&
rechartsChunks > budget.maxRechartsChunks
) {
failures.push(
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
);
}
const catalogRefRoutes = [...routeTotals.entries()]
.filter(([, v]) => v.catalogRef)
.map(([r]) => r);
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
failures.push(
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
);
}
if (failures.length > 0) {
console.error("\nBundle budget check FAILED:");
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log("\nBundle budget check passed.");