535 lines
18 KiB
JavaScript
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);
|
|
});
|
|
}
|