#!/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); }); }