build(bundle): gate every page's first load and the distinct client code

The gate summed every emitted chunk once and watched five pages. The sum
counts a module once per chunk group it lands in, so it rose whenever a
page got lighter by moving code into on-demand chunks, and it needed a
raise in most releases while no reader downloaded more; and a heavy import
into a page outside the five went unseen.

scripts/check-bundle-budget.mjs now measures:

- the first load of every page: rootMainFiles plus the page's
  client-reference chunks, each file counted once by content hash;
- p50, p95 and max of those first loads across pages;
- the distinct client code: every chunk evaluated in a node:vm sandbox
  with a stub TURBOPACK array, each module factory counted once by its
  module id (not its text: each chunk is minified on its own, so copies of
  one module differ in mangled names), catalogs excluded, gzipped once.

The summed aggregate is still printed but no longer gated. The catalog,
per-catalog and one-Recharts checks are unchanged. `--write-baselines`
rewrites the per-page baselines, `--dir` measures another build directory,
`--json` writes the report.

bundle-budget.json is re-based from the v1.44.0 build (local arm64): 110
pages, first load p50 336, p95 437, max 451 KB gz; distinct client code
1904 KB gz in 1224 modules; summed aggregate 3875 KB for comparison. Every
page has a baseline with the same 20 KB allowance; caps are p95 447, max
461 and distinct code 1930 KB. A page without a baseline fails until one
is written in the same change.

Proof: importing zod into the weight insights page measured
/insights/weight at 427.8 KB gz, 88.8 KB over its 339 KB baseline, and the
gate failed; the old gate passed the same build, because the page was not
watched and the summed aggregate did not move (3875 KB). The import was
removed again.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 04:36:21 +02:00
1 parent f64b49cf55
commit 45ea61261e
3 files changed
+386 -143

No files matched your search

+6 -5
View File
@@ -93,11 +93,12 @@ jobs:
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Catches the
# regressions the dead webpack-era tooling (ANALYZE=1, the old
# bundle-report) let through: a statically imported message catalog
# (~100 KB gz on every route) or a chart import bypassing the shared
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
# place CI already has a production build on disk. Measures the first
# load of every page (files deduplicated by content hash) against its
# baseline, the p95 and max across pages, and the distinct client code
# (modules deduplicated by id); see the script header. Catches a
# statically imported message catalog, a duplicated recharts chunk, or
# a heavy module (Zod) pulled into a page that had none.
- run: pnpm bundle-check
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
+117 -10
View File
@@ -1,14 +1,121 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 355,
"/insights/page": 323,
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3900,
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210
"maxCatalogKbGz": 210,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
"/about/page": 282,
"/achievements/page": 292,
"/admin/[section]/page": 451,
"/admin/page": 297,
"/auth/claim/page": 298,
"/auth/login/page": 290,
"/auth/register/page": 288,
"/c/[token]/page": 284,
"/checkups/page": 365,
"/checkups/practitioners/page": 289,
"/claim/[token]/page": 282,
"/coach/conversations/page": 294,
"/coach/page": 355,
"/coach/plans/page": 288,
"/confirm-access/page": 287,
"/custom-metrics/[id]/page": 303,
"/custom-metrics/[id]/values/page": 311,
"/cycle/page": 412,
"/documents/page": 378,
"/enroll-mfa/page": 305,
"/illness/[id]/page": 447,
"/illness/page": 421,
"/insights/active-energy/page": 336,
"/insights/audio-events/page": 337,
"/insights/blood-glucose/page": 340,
"/insights/blood-pressure/page": 336,
"/insights/bmi/page": 331,
"/insights/body-fat/page": 336,
"/insights/body-temperature/page": 336,
"/insights/body-water/page": 336,
"/insights/bone-mass/page": 336,
"/insights/breathing-disturbances/page": 337,
"/insights/cardio-fitness/page": 336,
"/insights/cardio-recovery/page": 337,
"/insights/catalog/page": 317,
"/insights/daylight/page": 336,
"/insights/double-support-time/page": 336,
"/insights/ecg/[id]/page": 316,
"/insights/ecg/page": 316,
"/insights/environmental-audio/page": 337,
"/insights/falls/page": 336,
"/insights/fat-free-mass/page": 336,
"/insights/fat-mass/page": 336,
"/insights/flights-climbed/page": 336,
"/insights/grip-strength/page": 337,
"/insights/headphone-audio/page": 336,
"/insights/health-score/page": 315,
"/insights/hrv/page": 337,
"/insights/lean-body-mass/page": 336,
"/insights/medications/page": 338,
"/insights/mood/page": 337,
"/insights/muscle-mass/page": 337,
"/insights/nutrients/page": 319,
"/insights/oxygen/page": 337,
"/insights/page": 323,
"/insights/pain/page": 336,
"/insights/pulse-wave-velocity/page": 336,
"/insights/pulse/page": 339,
"/insights/recovery/page": 330,
"/insights/respiratory-rate/page": 336,
"/insights/resting-pulse/page": 337,
"/insights/scores/[metric]/page": 328,
"/insights/six-minute-walk/page": 336,
"/insights/skin-temperature/page": 338,
"/insights/sleep/page": 337,
"/insights/sources/page": 321,
"/insights/stair-ascent-speed/page": 336,
"/insights/stair-descent-speed/page": 336,
"/insights/step-length/page": 336,
"/insights/steps/page": 337,
"/insights/values/[type]/page": 350,
"/insights/vascular-age/page": 337,
"/insights/visceral-fat/page": 336,
"/insights/waist-to-height/page": 336,
"/insights/waist/page": 336,
"/insights/walking-asymmetry/page": 336,
"/insights/walking-distance/page": 336,
"/insights/walking-heart-rate/page": 337,
"/insights/walking-speed/page": 336,
"/insights/walking-steadiness/page": 336,
"/insights/weight/page": 339,
"/insights/workouts/[id]/page": 437,
"/insights/workouts/page": 437,
"/insights/wrist-temperature/page": 336,
"/invite/[token]/page": 282,
"/labs/[biomarkerId]/page": 327,
"/labs/[biomarkerId]/values/page": 335,
"/labs/page": 423,
"/measurements/page": 339,
"/medications/[id]/history/page": 283,
"/medications/[id]/page": 402,
"/medications/new/page": 282,
"/medications/page": 355,
"/mental-wellbeing/page": 297,
"/mood/page": 438,
"/notifications/page": 289,
"/onboarding/[step]/page": 445,
"/onboarding/handover/page": 295,
"/onboarding/page": 286,
"/page": 359,
"/privacy/page": 282,
"/profile/page": 285,
"/settings/[section]/page": 311,
"/settings/layout/[module]/page": 380,
"/settings/modules/[module]/page": 374,
"/settings/page": 282,
"/timeline/page": 331,
"/vaccinations/page": 327
}
}
+263 -128
View File
@@ -2,55 +2,90 @@
/**
* 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:
* What a reader downloads is a set of chunk files: the shared
* `rootMainFiles` from `build-manifest.json` plus the chunks a page's
* `*_client-reference-manifest.js` names. This script measures that set per
* page and gates it, and separately measures how much distinct client code
* the build holds.
*
* - 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>).
* METHOD
*
* First load per page. The union of `rootMainFiles` and the page's
* client-reference chunks, deduplicated by content hash (sha-256 of the
* file), each file gzipped at level 9 and summed. A file that two
* manifests list under different names counts once. Every page in the
* build is measured, not a hand-picked few: a heavy import into a page
* nobody watched is how Zod sat in fifty metric pages for three months.
*
* Across pages. p50, p95 and max of the per-page first loads.
*
* Unique client code. Every emitted chunk is evaluated in a `node:vm`
* sandbox with a stub `TURBOPACK` array, which collects the module
* factories the chunk registers without running any of them. Each factory
* is keyed by its Turbopack module id, which is global to the build, so a
* module the bundler copied into forty chunk groups counts once. (Not by
* the factory's text: each chunk is minified on its own, so the copies of
* one module differ in their mangled local names.) One copy per module,
* from the first chunk in file-name order, without the lazy message
* catalogs, is concatenated in id order and gzipped once. A file that
* registers no factories (the runtime, the polyfills) counts as one unit
* by its content hash.
*
* The old aggregate (every emitted chunk gzipped and summed) is still
* printed, for continuity, but no longer gated: it counts a module once
* per chunk group it lands in, so it rose every time a page got lighter
* by moving code into on-demand chunks.
*
* GATES (bundle-budget.json)
*
* - routeBaselineKbGz: measured first load per page; a page fails when it
* is more than routeDriftAllowanceKbGz over its baseline, or when it is
* a page with no baseline (write one with --write-baselines).
* - firstLoadP95KbGz / firstLoadMaxKbGz: caps on the distribution.
* - uniqueClientKbGz: cap on the distinct client code.
* - maxCatalogKbGz: per lazy message catalog.
* - maxRechartsChunks: chunks carrying the Recharts fingerprint (one, the
* shared chart-runtime group).
* - no message catalog referenced by a page or the shared baseline.
*
* Usage:
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
* pnpm bundle-report # print the report
* pnpm bundle-check # enforce bundle-budget.json
* node scripts/check-bundle-budget.mjs --write-baselines
* # rewrite routeBaselineKbGz from
* # this build (a reviewed change)
* node scripts/check-bundle-budget.mjs --dir <build dir> [--json <file>]
*
* 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`.
* Numbers are KB gzip. A local arm64 build measures each page about 1.5 KB
* lighter than the CI runner; the drift allowance covers that and ordinary
* week-to-week movement, while the step changes the gate exists for (a
* statically imported catalog, a duplicated chart library, Zod on a page
* that had none: 60 to 100 KB each) are several times larger.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import vm from "node:vm";
import { gzipSync } from "node:zlib";
const CHECK = process.argv.includes("--check");
const BUILD_DIR = join(process.cwd(), ".next");
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const option = (name) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
const CHECK = flag("--check");
const WRITE_BASELINES = flag("--write-baselines");
const BUILD_DIR = option("--dir") ?? join(process.cwd(), ".next");
const JSON_OUT = option("--json");
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.",
`check-bundle-budget: no ${join(BUILD_DIR, "build-manifest.json")} — run \`pnpm build\` first.`,
);
process.exit(2);
}
@@ -58,30 +93,47 @@ if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
/** 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.
* Message-catalog fingerprint. The key set is identical across all 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();
const sha = (buf) => createHash("sha256").update(buf).digest("hex");
const gz = (buf) => gzipSync(buf, { level: 9 }).length;
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
const round1 = (n) => Math.round(n * 10) / 10;
const fileCache = new Map();
function chunkInfo(file) {
if (!sizeCache.has(file)) {
if (!fileCache.has(file)) {
let info = {
hash: `missing:${file}`,
raw: 0,
gz: 0,
recharts: false,
catalog: false,
};
try {
const buf = readFileSync(join(BUILD_DIR, file));
sizeCache.set(file, {
info = {
hash: sha(buf),
raw: buf.length,
gz: gzipSync(buf, { level: 9 }).length,
gz: gz(buf),
recharts: buf.includes(RECHARTS_MARK),
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
});
buf,
};
} catch {
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
// A manifest naming a file the build did not emit: counted as zero and
// reported by its missing hash rather than crashing the report.
}
fileCache.set(file, info);
}
return sizeCache.get(file);
return fileCache.get(file);
}
function routeChunks(manifestFile) {
@@ -97,24 +149,54 @@ function routeChunks(manifestFile) {
return files;
}
function findRouteManifests(dir, prefix = "") {
function findPageManifests(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,
});
out = out.concat(findPageManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name === "page_client-reference-manifest.js") {
out.push({ route: `${prefix}/page`, file: p });
}
}
return out;
}
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
function percentile(sorted, p) {
if (sorted.length === 0) return 0;
const i = Math.min(
sorted.length - 1,
Math.ceil((p / 100) * sorted.length) - 1,
);
return sorted[Math.max(0, i)];
}
/**
* The `[module id, factory source]` pairs a Turbopack chunk registers,
* without running them: `TURBOPACK.push([script, id, factory, id, factory,
* …])`. Null when the file is not in that shape.
*/
function chunkFactories(buf) {
const sandbox = { TURBOPACK: [] };
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
try {
vm.runInNewContext(buf.toString("utf8"), sandbox, { timeout: 5000 });
} catch {
return null;
}
const modules = [];
for (const entry of sandbox.TURBOPACK) {
if (!Array.isArray(entry)) continue;
for (let i = 1; i < entry.length; i++) {
if (typeof entry[i] !== "function") continue;
const id = entry[i - 1];
if (typeof id !== "number" && typeof id !== "string") continue;
modules.push([String(id), entry[i].toString()]);
}
}
return modules.length > 0 ? modules : null;
}
// ── Shared baseline ─────────────────────────────────────────────────────────
const buildManifest = JSON.parse(
@@ -123,88 +205,135 @@ const buildManifest = JSON.parse(
const rootFiles = buildManifest.rootMainFiles ?? [];
let rootGz = 0;
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
// ── Per-route totals ────────────────────────────────────────────────────────
const manifests = findRouteManifests(APP_DIR);
const routeTotals = new Map();
for (const { route, file } of manifests) {
let gz = 0;
// ── Per-page first load, deduplicated by content hash ──────────────────────
const pages = new Map();
for (const { route, file } of findPageManifests(APP_DIR)) {
const byHash = new Map();
let catalogRef = false;
for (const chunk of routeChunks(file)) {
for (const chunk of [...rootFiles, ...routeChunks(file)]) {
const info = chunkInfo(chunk);
gz += info.gz;
if (info.catalog) catalogRef = true;
byHash.set(info.hash, info.gz);
if (info.catalog && !rootFiles.includes(chunk)) catalogRef = true;
}
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
let total = 0;
for (const size of byHash.values()) total += size;
pages.set(route, { gz: total, catalogRef });
}
const firstLoads = [...pages.values()].map((p) => p.gz).sort((a, b) => a - b);
const p50 = percentile(firstLoads, 50);
const p95 = percentile(firstLoads, 95);
const max = firstLoads[firstLoads.length - 1] ?? 0;
// ── Whole-build chunk scan ──────────────────────────────────────────────────
// ── Whole build: old aggregate, catalogs, recharts, unique modules ─────────
const chunkDir = join(BUILD_DIR, "static", "chunks");
let totalGz = 0;
let summedGz = 0;
let rechartsChunks = 0;
let catalogChunks = 0;
let catalogGz = 0;
let largestCatalog = { file: "-", gz: 0 };
let largest = { file: "-", gz: 0 };
for (const f of readdirSync(chunkDir)) {
const uniqueUnits = new Map();
for (const f of readdirSync(chunkDir).sort()) {
if (!f.endsWith(".js")) continue;
const info = chunkInfo(join("static", "chunks", f));
totalGz += info.gz;
summedGz += info.gz;
if (info.recharts) rechartsChunks += 1;
if (info.catalog) {
catalogChunks += 1;
catalogGz += info.gz;
if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz };
continue;
}
const modules = info.buf ? chunkFactories(info.buf) : null;
if (modules) {
for (const [id, source] of modules) {
if (!uniqueUnits.has(`m:${id}`)) uniqueUnits.set(`m:${id}`, source);
}
} else if (info.buf) {
uniqueUnits.set(`f:${info.hash}`, info.buf.toString("utf8"));
}
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
}
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
const uniqueKeys = [...uniqueUnits.keys()].sort();
const uniqueGz = gz(
Buffer.from(uniqueKeys.map((k) => uniqueUnits.get(k)).join("\n"), "utf8"),
);
// ── Report ──────────────────────────────────────────────────────────────────
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
: null;
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
console.log(`pages measured: ${pages.size}`);
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 message catalog: ${fmt(largestCatalog.gz)} gz (${largestCatalog.file})`,
`first load across pages: p50 ${fmt(p50)}, p95 ${fmt(p95)}, max ${fmt(max)} gz`,
);
console.log(
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
`unique client code: ${fmt(uniqueGz)} gz (${uniqueKeys.length} distinct modules, catalogs excluded)`,
);
console.log(
`lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks, largest ${fmt(largestCatalog.gz)} ${largestCatalog.file})`,
);
console.log(
`summed chunks, not gated: ${fmt(summedGz - catalogGz)} gz (every emitted chunk once, catalogs excluded)`,
);
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 ? "+" : "−";
const rows = [...pages.entries()]
.map(([route, { gz: size }]) => {
const baseline = baselines[route];
return {
route,
kb: round1(kb(size)),
baseline: baseline ?? null,
delta: baseline == null ? null : round1(kb(size) - baseline),
};
})
.sort((a, b) => (b.delta ?? Infinity) - (a.delta ?? Infinity) || b.kb - a.kb);
console.log("largest movers against their baseline:");
for (const r of rows.slice(0, 15)) {
const delta =
r.delta == null
? "no baseline"
: `${r.delta >= 0 ? "+" : "−"}${Math.abs(r.delta).toFixed(1)} KB of ${allowanceKb}`;
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)`,
`${`${r.kb.toFixed(0)} KB`.padStart(8)} gz ${r.route} (${delta})`,
);
}
const report = {
pages: Object.fromEntries(rows.map((r) => [r.route, r.kb])),
firstLoadKbGz: {
p50: round1(kb(p50)),
p95: round1(kb(p95)),
max: round1(kb(max)),
},
uniqueClientKbGz: round1(kb(uniqueGz)),
summedChunksKbGz: round1(kb(summedGz - catalogGz)),
catalogKbGz: round1(kb(catalogGz)),
largestCatalogKbGz: round1(kb(largestCatalog.gz)),
rechartsChunks,
};
if (JSON_OUT) writeFileSync(JSON_OUT, `${JSON.stringify(report, null, 2)}\n`);
if (WRITE_BASELINES) {
if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
budget.routeBaselineKbGz = Object.fromEntries(
rows
.map((r) => [r.route, Math.round(r.kb)])
.sort(([a], [b]) => a.localeCompare(b)),
);
writeFileSync(BUDGET_PATH, `${JSON.stringify(budget, null, 2)}\n`);
console.log(`\nwrote ${rows.length} page baselines to bundle-budget.json`);
}
if (!CHECK) process.exit(0);
// ── Budget gate ─────────────────────────────────────────────────────────────
@@ -212,42 +341,48 @@ 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) {
// A budget that states no page baselines gates nothing, and would do it
// quietly. A build with no pages is a broken build, not a light one.
if (Object.keys(baselines).length === 0 || pages.size === 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.",
"check-bundle-budget: no page baselines in bundle-budget.json, or no " +
"pages in the build. Measure them with --write-baselines.",
);
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) {
for (const r of rows) {
if (r.baseline == null) {
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)`,
`${r.route}: ${r.kb} KB gz and no baseline. A new page gets one with --write-baselines in the same change.`,
);
} else if (r.delta > allowanceKb) {
failures.push(
`${r.route}: ${r.kb} KB gz is ${r.delta.toFixed(1)} KB over its ${r.baseline} KB baseline, past the ${allowanceKb} KB drift allowance`,
);
}
}
// The aggregate leaves the lazy message catalogs out. A reader downloads one
// catalog, the one for their language, so counting all of them made every new
// interface language look like a regression. Each catalog is held to its own
// ceiling instead.
const nonCatalogGz = totalGz - catalogGz;
if (budget.totalClientKbGz && kb(nonCatalogGz) > budget.totalClientKbGz) {
for (const route of Object.keys(baselines)) {
if (!pages.has(route)) {
failures.push(
`${route} has a baseline but is not in the build; remove it with --write-baselines`,
);
}
}
if (budget.firstLoadP95KbGz && kb(p95) > budget.firstLoadP95KbGz) {
failures.push(
`client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
`first-load p95 ${kb(p95).toFixed(1)} KB gz exceeds the ${budget.firstLoadP95KbGz} KB budget`,
);
}
if (budget.firstLoadMaxKbGz && kb(max) > budget.firstLoadMaxKbGz) {
failures.push(
`largest first load ${kb(max).toFixed(1)} KB gz exceeds the ${budget.firstLoadMaxKbGz} KB budget`,
);
}
if (budget.uniqueClientKbGz && kb(uniqueGz) > budget.uniqueClientKbGz) {
failures.push(
`unique client code ${kb(uniqueGz).toFixed(1)} KB gz exceeds the ${budget.uniqueClientKbGz} KB budget`,
);
}
if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) {
@@ -263,12 +398,12 @@ if (
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
);
}
const catalogRefRoutes = [...routeTotals.entries()]
const catalogRefPages = [...pages.entries()]
.filter(([, v]) => v.catalogRef)
.map(([r]) => r);
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
if (catalogRefPages.length > 0 || rootCatalogRef) {
failures.push(
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefPages.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
);
}