mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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"
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
#!/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.");
|