Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-bundle-size.mjs
T

535 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Asserts that the React Router web build stays within hard byte budgets so
// regressions in initial payload / route splitting are caught in CI. Reads the
// generated `apps/web/build/client/assets/` directory, classifies each JS chunk
// into root/route/vendor/other groups and totals the CSS files separately,
// then compares each group total against a hard-coded byte budget.
//
// Budgets were seeded from the May 2026 build output plus ~20% headroom and
// must be re-evaluated whenever an intentional bundle change lands. The gate
// SKIPs gracefully when no build artifacts are present so it can run on a
// clean tree without forcing an expensive `pnpm build`.
//
// Classification rules (kept intentionally narrow so unrelated chunk renames
// do not silently shift mass between groups):
// root -> entry.client-*, root-*, manifest-* (initial payload)
// vendor -> chunk-*, vendor-* (manualChunks output)
// route -> React Router v7 route id chunks, including route groups,
// dynamic segments, and top-level route ids
// other -> every other JS chunk (component-level splits, libs)
// css -> *.css files
//
// `initial_payload_js` is an explicit alias for the root-classified JS total.
// The current asset parser does not read Rollup/Vite dependency graphs, so it
// intentionally avoids pulling lazy vendor/route/other chunks into that budget.
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
const FILENAME = fileURLToPath(import.meta.url);
const REPO_ROOT = path.resolve(FILENAME, "..", "..");
const DEFAULT_ASSETS_DIR = path.join(REPO_ROOT, "apps", "web", "build", "client", "assets");
const DEFAULT_BUDGETS_CONFIG_PATH = path.join(REPO_ROOT, "scripts", "bundle-budgets.json");
// Hard budgets (bytes). Seeded from current build + tight headroom.
// If you intentionally grow the bundle, update these and call it out in the
// commit so reviewers can sanity-check the new floor.
export const BUDGETS = Object.freeze({
root: 790_964,
initial_payload_js: 790_964,
manifest_js: 699_563,
vendor: 1_690_674,
route: 2_650_000,
other: 1_821_650,
total_js: 6_776_505,
css: 770_317,
});
export const EMPTY_JS_CHUNK_ALLOWLIST = Object.freeze([
"___entity.export",
"callback",
"dry-run",
"healthcheck",
"healthz",
"logout",
"onboarding._id",
"postman_collection_._json",
"preferences",
"readyz",
"stream",
"submit",
"swagger",
]);
const ROOT_PATTERNS = [/^entry\.client-/, /^root-/, /^manifest-/];
const VENDOR_PATTERNS = [/^chunk-/, /^vendor-/];
const SIMPLE_HASH_SEGMENT = /^[A-Za-z0-9_]{6,}$/;
const BASE64URL_HASH_SEGMENT = /^[A-Za-z0-9_-]+$/;
export function classifyChunk(name) {
if (name.endsWith(".css")) {
return "css";
}
if (!name.endsWith(".js")) {
return null;
}
if (ROOT_PATTERNS.some((re) => re.test(name))) {
return "root";
}
if (VENDOR_PATTERNS.some((re) => re.test(name))) {
return "vendor";
}
if (isRouteChunkName(name)) {
return "route";
}
return "other";
}
export function evaluateBudgets(files, budgets = BUDGETS) {
const groups = { root: 0, vendor: 0, route: 0, other: 0, css: 0 };
const counts = { root: 0, vendor: 0, route: 0, other: 0, css: 0 };
for (const file of files) {
const group = classifyChunk(file.name);
if (!group) {
continue;
}
groups[group] += file.size;
counts[group] += 1;
}
const totalJs = groups.root + groups.vendor + groups.route + groups.other;
const initialPayloadJs = groups.root;
const manifestJs = files
.filter((file) => file.name.startsWith("manifest-") && file.name.endsWith(".js"))
.reduce((total, file) => total + file.size, 0);
const results = [];
for (const group of ["root", "vendor", "route", "other"]) {
results.push({
group,
total: groups[group],
count: counts[group],
budget: budgets[group],
pass: groups[group] <= budgets[group],
});
}
results.push({
group: "initial_payload_js",
total: initialPayloadJs,
count: counts.root,
budget: budgets.initial_payload_js,
pass: initialPayloadJs <= budgets.initial_payload_js,
});
results.push({
group: "manifest_js",
total: manifestJs,
count: files.filter((file) => file.name.startsWith("manifest-") && file.name.endsWith(".js")).length,
budget: budgets.manifest_js,
pass: manifestJs <= budgets.manifest_js,
});
results.push({
group: "total_js",
total: totalJs,
count: counts.root + counts.vendor + counts.route + counts.other,
budget: budgets.total_js,
pass: totalJs <= budgets.total_js,
});
results.push({
group: "css",
total: groups.css,
count: counts.css,
budget: budgets.css,
pass: groups.css <= budgets.css,
});
return results;
}
export function normalizeAssetBaselineName(name) {
const ext = path.extname(name);
const withoutExt = ext ? name.slice(0, -ext.length) : name;
const parts = withoutExt.split("-");
if (parts.length < 2) {
return withoutExt;
}
const last = parts.at(-1) ?? "";
if (SIMPLE_HASH_SEGMENT.test(last)) {
return parts.slice(0, -1).join("-");
}
const previous = parts.at(-2) ?? "";
const splitHash = `${previous}-${last}`;
if (splitHash.length === 8 && BASE64URL_HASH_SEGMENT.test(splitHash)) {
return parts.slice(0, -2).join("-");
}
return withoutExt;
}
export function findUnexpectedEmptyJsChunks(files, allowlist = EMPTY_JS_CHUNK_ALLOWLIST) {
const allowed = new Set(allowlist);
return files
.filter((file) => file.name.endsWith(".js") && file.size === 0)
.map((file) => ({ ...file, baselineName: normalizeAssetBaselineName(file.name) }))
.filter((file) => !allowed.has(file.baselineName))
.sort((left, right) => left.name.localeCompare(right.name));
}
export function getLargestJsChunks(files, limit = 10) {
return files
.filter((file) => file.name.endsWith(".js") && file.size > 0)
.sort((left, right) => {
if (right.size !== left.size) {
return right.size - left.size;
}
return left.name.localeCompare(right.name);
})
.slice(0, limit);
}
export function countRouteFiles(routesDir = path.join(REPO_ROOT, "apps", "web", "app", "routes")) {
if (!fsSync.existsSync(routesDir)) {
return null;
}
let count = 0;
const visit = (dir) => {
for (const entry of fsSync.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(full);
} else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !/\.test\.(ts|tsx)$/.test(entry.name) && !/\.spec\.(ts|tsx)$/.test(entry.name) && !/\.server\.(ts|tsx)$/.test(entry.name)) {
count += 1;
}
}
};
visit(routesDir);
return count;
}
export function formatDriftPercent(total, budget) {
if (budget <= 0) {
return "n/a";
}
const drift = ((total - budget) / budget) * 100;
const sign = drift >= 0 ? "+" : "";
return `${sign}${drift.toFixed(1)}%`;
}
// ----- Per-route bundle-budget enforcement (#71) ---------------------------
// Reads scripts/bundle-budgets.json and matches each emitted JS asset to a
// route or vendor-chunk budget. Sizes are measured in gzip-bytes so the
// numbers reflect what the client actually downloads.
//
// Status taxonomy:
// - "ok" gzip <= warningKb
// - "warning" warningKb < gzip <= limitKb
// - "exceeds" gzip > limitKb (reported, never fails CI)
// "exceeds" is intentionally non-fatal: PR-comment + colored CI output draw
// attention without blocking legitimate feature work; reviewers decide.
export function loadBudgetsConfig(configPath = DEFAULT_BUDGETS_CONFIG_PATH) {
const raw = fsSync.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed.default || !parsed.routes || !parsed.vendorChunks) {
throw new Error(`bundle-budgets.json missing required sections (default/routes/vendorChunks): ${configPath}`);
}
return parsed;
}
const VENDOR_CHUNK_BASELINE = /^(vendor|chunk)-/i;
// Stable name → budget key. Strips the rollup content-hash suffix and the
// file extension so "vendor-react-DXdLCaXs.js" → "vendor-react" and
// "auth.magic-link-BjyKP6Px.js" → "auth.magic-link".
export function baselineAssetKey(name) {
return normalizeAssetBaselineName(name);
}
// Map a baselineKey back to a route path used in bundle-budgets.json.
// React Router v7 emits route ids like "auth.magic-link",
// "app.$tenant.dashboard", "kb.$slug", "(_lang)._index", "_index".
// We support both dot- and slash-segment lookups so the config stays
// readable for humans.
export function lookupBudget(baselineKey, config) {
if (!baselineKey) return null;
if (VENDOR_CHUNK_BASELINE.test(baselineKey)) {
const configuredChunkKey = Object.keys(config.vendorChunks)
.filter((key) => baselineKey === key || baselineKey.startsWith(`${key}-`))
.sort((left, right) => right.length - left.length)[0];
const chunkKey = configuredChunkKey ?? baselineKey;
if (config.vendorChunks[chunkKey]) {
return { kind: "vendor", key: chunkKey, budget: config.vendorChunks[chunkKey] };
}
return { kind: "vendor", key: chunkKey, budget: null };
}
// Route lookup: try dot form first, then slash form derived from it.
const dotForm = baselineKey;
const slashForm = baselineKey.replaceAll(".", "/");
for (const candidate of [dotForm, slashForm]) {
if (config.routes[candidate]) {
return { kind: "route", key: candidate, budget: config.routes[candidate] };
}
}
return { kind: "route", key: baselineKey, budget: null };
}
export function classifyBudgetStatus(gzipBytes, budget) {
if (!budget) return "unbudgeted";
const limit = budget.limitKb * 1024;
const warn = budget.warningKb * 1024;
if (gzipBytes > limit) return "exceeds";
if (gzipBytes > warn) return "warning";
return "ok";
}
export async function computeGzipSize(filePath) {
const buf = await fs.readFile(filePath);
return gzipSync(buf).length;
}
export function isVendorChunkName(name) {
return /^(vendor|chunk)-/.test(name);
}
export function isRouteChunkName(name) {
if (!name.endsWith(".js")) return false;
if (name.startsWith("entry.client") || name.startsWith("root-") || name.startsWith("manifest-")) {
return false;
}
if (isVendorChunkName(name)) return false;
// Route chunks: start with "(" (route groups), contain "._" segments
// (RR7 dynamic segments), or match top-level route ids known to be routes.
return name.startsWith("(") || /\._/.test(name) || /^[a-z][\w.-]*-[A-Za-z0-9_]{6,}\.js$/.test(name);
}
export async function evaluatePerAssetBudgets({ assetsDir = DEFAULT_ASSETS_DIR, config, files: assetFiles = null }) {
const files = assetFiles ?? (await readAssetFiles(assetsDir));
if (!files || files.length === 0) {
return { skipped: true, entries: [] };
}
const entries = [];
for (const file of files) {
if (!file.name.endsWith(".js")) continue;
if (file.size === 0) continue;
const baselineKey = baselineAssetKey(file.name);
const lookup = lookupBudget(baselineKey, config);
// Only report assets that are either vendor chunks or look like routes,
// to avoid noise from every shared component split.
const isVendor = isVendorChunkName(file.name);
const isRoute = isRouteChunkName(file.name);
if (!isVendor && !isRoute) continue;
const filePath = path.join(assetsDir, file.name);
const gzipSize = await computeGzipSize(filePath);
const effectiveBudget = lookup?.budget ?? config.default;
const status = classifyBudgetStatus(gzipSize, effectiveBudget);
entries.push({
file: file.name,
key: lookup?.key ?? baselineKey,
kind: lookup?.kind ?? (isVendor ? "vendor" : "route"),
rawSize: file.size,
gzipSize,
budget: effectiveBudget,
hasExplicitBudget: Boolean(lookup?.budget),
status,
});
}
return { skipped: false, entries };
}
export function summarizePerAssetEntries(entries) {
const totals = { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 };
for (const e of entries) totals[e.status] += 1;
return totals;
}
export function formatPerAssetReport(entries, { logger = console } = {}) {
if (entries.length === 0) {
logger.log("Per-route budget: no eligible assets found");
return;
}
// Sort by status severity then gzip size desc.
const order = { exceeds: 0, warning: 1, unbudgeted: 2, ok: 3 };
const sorted = [...entries].sort((a, b) => {
if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
return b.gzipSize - a.gzipSize;
});
logger.log("Per-route bundle budgets (gzip):");
for (const e of sorted) {
const kb = (e.gzipSize / 1024).toFixed(1);
const limit = e.budget ? `${e.budget.limitKb}KB` : "—";
const tag = e.hasExplicitBudget ? e.kind : `${e.kind} (default)`;
const line = `${e.status.padEnd(10)} ${tag.padEnd(18)} ${e.key.padEnd(40)} ${kb}KB / ${limit} (${e.file})`;
if (e.status === "exceeds") logger.error(line);
else logger.log(line);
}
}
export async function runPerRouteBudgetReport({
assetsDir = DEFAULT_ASSETS_DIR,
configPath = DEFAULT_BUDGETS_CONFIG_PATH,
logger = console,
files = null,
} = {}) {
let config;
try {
config = loadBudgetsConfig(configPath);
} catch (error) {
logger.error(`Per-route budget: failed to load config (${error.message})`);
return { skipped: false, entries: [], totals: { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 }, error: error.message };
}
const result = await evaluatePerAssetBudgets({ assetsDir, config, files });
if (result.skipped) {
logger.log("Per-route budget: SKIP (no build artifacts)");
return { skipped: true, entries: [], totals: { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 } };
}
formatPerAssetReport(result.entries, { logger });
const totals = summarizePerAssetEntries(result.entries);
logger.log(
`Per-route summary: ok=${totals.ok} warning=${totals.warning} exceeds=${totals.exceeds} unbudgeted=${totals.unbudgeted}`,
);
return { skipped: false, entries: result.entries, totals };
}
export async function readAssetFiles(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
const files = [];
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".css")) {
continue;
}
const full = path.join(dir, entry.name);
const stat = await fs.stat(full);
files.push({ name: entry.name, size: stat.size });
}
return files;
}
export async function runBundleSizeGate({ assetsDir = DEFAULT_ASSETS_DIR, budgets = BUDGETS, logger = console } = {}) {
const files = await readAssetFiles(assetsDir);
if (files === null || files.length === 0) {
logger.log('SKIP build artifacts not present; run "pnpm build" first');
return { skipped: true, results: [] };
}
const results = evaluateBudgets(files, budgets);
const unexpectedEmptyJsChunks = findUnexpectedEmptyJsChunks(files);
const largestJsChunks = getLargestJsChunks(files);
const failures = [];
for (const result of results) {
const drift = formatDriftPercent(result.total, result.budget);
if (result.pass) {
logger.log(
`PASS group=${result.group} total=${result.total} budget=${result.budget} files=${result.count} drift=${drift}`,
);
} else {
failures.push(result);
logger.error(
`FAIL group=${result.group} total=${result.total} exceeds budget=${result.budget} files=${result.count} drift=${drift}`,
);
}
}
if (unexpectedEmptyJsChunks.length > 0) {
failures.push({ group: "empty_js_chunks", total: unexpectedEmptyJsChunks.length, count: unexpectedEmptyJsChunks.length, budget: 0, pass: false });
logger.error(`FAIL empty_js_chunks unexpected=${unexpectedEmptyJsChunks.length}`);
for (const chunk of unexpectedEmptyJsChunks) {
logger.error(`- ${chunk.name} baseline=${chunk.baselineName}`);
}
} else {
logger.log("PASS empty_js_chunks unexpected=0");
}
logger.log("Largest JS chunks:");
for (const chunk of largestJsChunks) {
logger.log(`- ${chunk.name} size=${chunk.size}`);
}
const routeFileCount = countRouteFiles();
if (routeFileCount !== null) {
logger.log(`Route files: count=${routeFileCount}`);
}
const budgetFailures = results.filter((result) => !result.pass).length;
const emptyChunkFailures = unexpectedEmptyJsChunks.length > 0 ? 1 : 0;
logger.log(`Summary: ${results.length - budgetFailures + (emptyChunkFailures === 0 ? 1 : 0)} passed, ${failures.length} failed`);
return { skipped: false, results, failures, unexpectedEmptyJsChunks, largestJsChunks, files };
}
export function toPerRouteJsonReport(report) {
return {
skipped: report.skipped,
totals: report.totals,
entries: report.entries.map((e) => ({
file: e.file,
key: e.key,
kind: e.kind,
rawSize: e.rawSize,
gzipSize: e.gzipSize,
status: e.status,
hasExplicitBudget: e.hasExplicitBudget,
limitKb: e.budget?.limitKb ?? null,
warningKb: e.budget?.warningKb ?? null,
})),
};
}
export async function runCli({
args = process.argv.slice(2),
logger = console,
stdout = process.stdout,
} = {}) {
const jsonMode = args.includes("--json");
const perRouteOnly = args.includes("--per-route-only");
if (perRouteOnly || jsonMode) {
const report = await runPerRouteBudgetReport({
logger: jsonMode ? { log: () => {}, error: () => {} } : logger,
});
if (jsonMode) {
stdout.write(`${JSON.stringify(toPerRouteJsonReport(report), null, 2)}\n`);
return 0;
}
if (perRouteOnly) return 0;
}
const outcome = await runBundleSizeGate({ logger });
if (!jsonMode && !perRouteOnly) {
await runPerRouteBudgetReport({ logger, files: outcome.skipped ? null : outcome.files });
}
if (!outcome.skipped && outcome.failures.length > 0) {
return 1;
}
return 0;
}
async function main() {
const exitCode = await runCli();
if (exitCode !== 0) {
process.exit(exitCode);
}
}
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === FILENAME;
if (invokedDirectly) {
main().catch((error) => {
// eslint-disable-next-line no-console -- CLI gate must surface fatal errors on stderr.
console.error(error);
process.exit(1);
});
}