789 lines
30 KiB
JavaScript
789 lines
30 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/lazy-runtime groups and totals CSS 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)
|
|
// lazy_runtime -> self-hosted optional feature runtimes and Web Workers
|
|
// css -> *.css files
|
|
//
|
|
// `initial_payload_js` follows the React Router manifest's entry + root modules
|
|
// and recursively walks their emitted static imports. Dynamic imports and
|
|
// route-only modules remain outside the common boot budget.
|
|
|
|
import fs from "node:fs/promises";
|
|
import fsSync from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { brotliCompressSync, 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.
|
|
//
|
|
// 2026-06-26: absorbs auth WF-P2 + P19 growth. The merge of the new auth
|
|
// screens and P19 grew the route manifest and aggregate JS/CSS past the
|
|
// May-2026 floors with no per-route regression (per-route soft gate stays
|
|
// green). Raised the exceeded groups to current-build + ~10% headroom; vendor
|
|
// and other still pass and are left untouched:
|
|
// root 790_964 -> 1_135_000 (build 1_031_227, +30.4% over old)
|
|
// initial_payload_js now measures the static boot graph rather than filename
|
|
// groups; raw/gzip/brotli budgets include tight headroom.
|
|
// manifest_js 699_563 -> 1_040_000 (build 944_428, +35.0% over old)
|
|
// route 2_650_000 -> 3_175_000 (build 2_883_556, +8.8% over old)
|
|
// application_js 6_776_505 -> 7_750_000 (build 7_033_988, +3.8% over old)
|
|
// css 770_317 -> 1_020_000 (build 923_551, +19.9% over old)
|
|
//
|
|
// 2026-07-12: Monaco and PDF.js executable code was moved from third-party
|
|
// CDNs into same-origin, lazy feature chunks. The former application budget is
|
|
// preserved as `application_js`; `lazy_runtime` separately caps the now-visible
|
|
// optional editor/PDF/worker payload, and `total_js` caps both together.
|
|
// 2026-07-11: absorbs the AK-P16d signature interim screens (project signature
|
|
// panel, wizard dialog, restyled public sign page). The route group grew +0.6%
|
|
// past the June floor with no per-route regression (per-route soft gate green,
|
|
// all other groups pass). Raised only the exceeded group to current-build
|
|
// + ~10% headroom:
|
|
// route 3_175_000 -> 3_500_000 (build 3_193_560, +9.6% headroom)
|
|
//
|
|
// 2026-07-13: absorbs the AK-P21 privacy interim screens (privacy hub, export
|
|
// jobs, deletion wizard, subprocessors, AVV) landing on top of the AK-P19
|
|
// merge. The growth is almost entirely the React Router manifest entries for
|
|
// the new routes (+30.7 KB manifest, +2 tiny icon chunks) — not new static
|
|
// imports in the boot graph; the compressed budgets stay untouched and green
|
|
// (gzip -8.6%, brotli -12.2%). Raised only the exceeded raw group to
|
|
// current-build + ~10% headroom:
|
|
// initial_payload_js 1_700_000 -> 1_875_000 (build 1_703_691, +10.1% headroom)
|
|
//
|
|
// 2026-07-16: absorbs the reviewed R-LEADS and VA-P26 route surfaces after
|
|
// integrating both feature branches. Every per-route gzip budget remains green
|
|
// (594/594), and all other hard groups stay below their existing caps. Raised
|
|
// only the aggregate route group to the combined build + ~10% headroom:
|
|
// route 3_500_000 -> 3_880_000 (build 3_526_222, +10.0% headroom)
|
|
//
|
|
// 2026-07-20: absorbs the AK-P19 restwork surfaces (tenant creation, tenant
|
|
// operations panel, support ticket detail, admin interim page headers). The
|
|
// growth is again the React Router manifest entries for the new admin routes
|
|
// (+16.5 KB raw, manifest_js +45 bytes over its floor) — the compressed boot
|
|
// budgets stay green (gzip -4.7%, brotli -9.8%) and every per-route gzip
|
|
// budget passes (605/605). Raised only the exceeded raw groups to
|
|
// current-build + ~10% headroom:
|
|
// initial_payload_js 1_875_000 -> 2_080_000 (build 1_891_470, +10.0% headroom)
|
|
// manifest_js 1_040_000 -> 1_145_000 (build 1_040_045, +10.1% headroom)
|
|
//
|
|
// 2026-07-22: absorbs the AK-SET-01 settings-hub consolidation and its legacy
|
|
// redirect compatibility routes. The raw root group grew with the React Router
|
|
// manifest, while the measured static boot graph remains below all raw and
|
|
// compressed budgets (gzip -5.0%, brotli -10.3%) and all 653 per-route gzip
|
|
// budgets pass. Raised only the exceeded raw root group to the current build
|
|
// plus ~10% headroom:
|
|
// root 1_135_000 -> 1_270_000 (build 1_154_135, +10.0% headroom)
|
|
//
|
|
// 2026-08-02: enabling the Gitea bundle gate exposed stale aggregate raw-JS
|
|
// floors on the current application. This repository cleanup adds no runtime
|
|
// imports; all 765 per-route budgets and the raw/gzip/brotli boot graph remain
|
|
// green. Raised only the four exceeded aggregate groups to the measured build
|
|
// plus ~10% headroom:
|
|
// root 1_270_000 -> 1_420_000 (build 1_290_398, +10.0% headroom)
|
|
// route 3_880_000 -> 4_515_000 (build 4_103_703, +10.0% headroom)
|
|
// manifest_js 1_145_000 -> 1_330_000 (build 1_208_245, +10.1% headroom)
|
|
// application_js 7_750_000 -> 9_395_000 (build 8_539_172, +10.0% headroom)
|
|
//
|
|
// 2026-08-02: absorbs the reviewed global action-feedback and i18n contracts.
|
|
// The static boot graph grew 0.7% past its previous raw floor, while its gzip
|
|
// and brotli budgets remain green and all 766 per-route budgets pass. Raised
|
|
// only the exceeded raw boot-graph budget to the measured build plus ~10%
|
|
// headroom:
|
|
// initial_payload_js 2_080_000 -> 2_305_000 (build 2_093_881, +10.1% headroom)
|
|
export const BUDGETS = Object.freeze({
|
|
root: 1_420_000,
|
|
initial_payload_js: 2_305_000,
|
|
initial_payload_gzip: 370_000,
|
|
initial_payload_brotli: 320_000,
|
|
manifest_js: 1_330_000,
|
|
vendor: 1_690_674,
|
|
route: 4_515_000,
|
|
other: 1_821_650,
|
|
application_js: 9_395_000,
|
|
lazy_runtime: 15_750_000,
|
|
total_js: 22_500_000,
|
|
css: 1_020_000,
|
|
});
|
|
|
|
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 LAZY_RUNTIME_PATTERNS = [/\.worker(?:\.min)?-/, /^lazy-(?:monaco|pdfjs)-/, /^MonacoEditor(?:\.client)?-/, /^PdfJsLoader\.client-/, /^pdf(?:\.min)?-/];
|
|
const SIMPLE_HASH_SEGMENT = /^[A-Za-z0-9_]{6,}$/;
|
|
const BASE64URL_HASH_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
|
|
export function isJavaScriptAsset(name) {
|
|
return /\.(?:m?js)$/.test(name);
|
|
}
|
|
|
|
export function isLazyRuntimeChunkName(name) {
|
|
return isJavaScriptAsset(name) && LAZY_RUNTIME_PATTERNS.some((re) => re.test(name));
|
|
}
|
|
|
|
function isLazyRuntimeBaselineKey(key) {
|
|
return (
|
|
/\.worker(?:\.min)?$/.test(key) ||
|
|
/^lazy-(?:monaco|pdfjs)-/.test(key) ||
|
|
/^MonacoEditor(?:\.client)?$/.test(key) ||
|
|
/^PdfJsLoader\.client$/.test(key) ||
|
|
/^pdf(?:\.min)?$/.test(key)
|
|
);
|
|
}
|
|
|
|
export function classifyChunk(name) {
|
|
if (name.endsWith(".css")) {
|
|
return "css";
|
|
}
|
|
if (!isJavaScriptAsset(name)) {
|
|
return null;
|
|
}
|
|
if (isLazyRuntimeChunkName(name)) {
|
|
return "lazy_runtime";
|
|
}
|
|
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, initialPayload = null) {
|
|
const groups = { root: 0, vendor: 0, route: 0, other: 0, lazy_runtime: 0, css: 0 };
|
|
const counts = { root: 0, vendor: 0, route: 0, other: 0, lazy_runtime: 0, css: 0 };
|
|
|
|
for (const file of files) {
|
|
const group = classifyChunk(file.name);
|
|
if (!group) {
|
|
continue;
|
|
}
|
|
groups[group] += file.size;
|
|
counts[group] += 1;
|
|
}
|
|
|
|
const applicationJs = groups.root + groups.vendor + groups.route + groups.other;
|
|
const totalJs = applicationJs + groups.lazy_runtime;
|
|
const initialPayloadJs = initialPayload?.rawSize ?? groups.root;
|
|
const initialPayloadCount = initialPayload?.count ?? counts.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", "lazy_runtime"]) {
|
|
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: initialPayloadCount,
|
|
budget: budgets.initial_payload_js,
|
|
pass: initialPayloadJs <= budgets.initial_payload_js,
|
|
});
|
|
if (initialPayload) {
|
|
results.push({
|
|
group: "initial_payload_gzip",
|
|
total: initialPayload.gzipSize,
|
|
count: initialPayload.count,
|
|
budget: budgets.initial_payload_gzip,
|
|
pass: initialPayload.gzipSize <= budgets.initial_payload_gzip,
|
|
});
|
|
results.push({
|
|
group: "initial_payload_brotli",
|
|
total: initialPayload.brotliSize,
|
|
count: initialPayload.count,
|
|
budget: budgets.initial_payload_brotli,
|
|
pass: initialPayload.brotliSize <= budgets.initial_payload_brotli,
|
|
});
|
|
}
|
|
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: "application_js",
|
|
total: applicationJs,
|
|
count: counts.root + counts.vendor + counts.route + counts.other,
|
|
budget: budgets.application_js,
|
|
pass: applicationJs <= budgets.application_js,
|
|
});
|
|
results.push({
|
|
group: "total_js",
|
|
total: totalJs,
|
|
count: counts.root + counts.vendor + counts.route + counts.other + counts.lazy_runtime,
|
|
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 parseReactRouterManifest(source) {
|
|
const marker = "window.__reactRouterManifest=";
|
|
const start = source.indexOf(marker);
|
|
if (start < 0) {
|
|
throw new Error("React Router manifest assignment not found");
|
|
}
|
|
|
|
const jsonStart = source.indexOf("{", start + marker.length);
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
for (let index = jsonStart; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
if (inString) {
|
|
if (escaped) escaped = false;
|
|
else if (character === "\\") escaped = true;
|
|
else if (character === '"') inString = false;
|
|
continue;
|
|
}
|
|
if (character === '"') inString = true;
|
|
else if (character === "{") depth += 1;
|
|
else if (character === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return JSON.parse(source.slice(jsonStart, index + 1));
|
|
}
|
|
}
|
|
}
|
|
throw new Error("React Router manifest JSON is incomplete");
|
|
}
|
|
|
|
export function parseStaticImportSpecifiers(source) {
|
|
const imports = [];
|
|
const pattern = /\bimport(?!\s*\()\s*(?:[^;"']*?\bfrom\s*)?["']([^"']+)["']/g;
|
|
for (const match of source.matchAll(pattern)) {
|
|
imports.push(match[1]);
|
|
}
|
|
return imports;
|
|
}
|
|
|
|
export async function measureInitialPayload({ assetsDir = DEFAULT_ASSETS_DIR, files: assetFiles = null } = {}) {
|
|
const files = assetFiles ?? (await readAssetFiles(assetsDir));
|
|
const manifestFiles = (files ?? []).filter((file) => /^manifest-.+\.js$/.test(file.name));
|
|
if (manifestFiles.length !== 1) {
|
|
throw new Error(`Expected exactly one React Router manifest asset, found ${manifestFiles.length}`);
|
|
}
|
|
|
|
const buffers = new Map();
|
|
const readAsset = async (name) => {
|
|
if (!buffers.has(name)) {
|
|
buffers.set(name, await fs.readFile(path.join(assetsDir, name)));
|
|
}
|
|
return buffers.get(name);
|
|
};
|
|
const manifestFile = manifestFiles[0].name;
|
|
const manifest = parseReactRouterManifest((await readAsset(manifestFile)).toString("utf8"));
|
|
const root = manifest.routes?.root;
|
|
if (!manifest.entry?.module || !root?.module) {
|
|
throw new Error("React Router manifest is missing entry or root module metadata");
|
|
}
|
|
|
|
const available = new Set(files.map((file) => file.name));
|
|
const pending = [manifestFile, manifest.entry.module, ...(manifest.entry.imports ?? []), root.module, ...(root.imports ?? [])].map((specifier) =>
|
|
normalizeManifestAssetName(specifier)
|
|
);
|
|
const closure = new Set();
|
|
|
|
while (pending.length > 0) {
|
|
const name = pending.pop();
|
|
if (!name || !isJavaScriptAsset(name) || closure.has(name)) continue;
|
|
if (!available.has(name)) {
|
|
throw new Error(`Initial payload references missing asset: ${name}`);
|
|
}
|
|
closure.add(name);
|
|
const source = (await readAsset(name)).toString("utf8");
|
|
for (const specifier of parseStaticImportSpecifiers(source)) {
|
|
const importedName = resolveStaticImportName(name, specifier);
|
|
if (importedName && !closure.has(importedName)) pending.push(importedName);
|
|
}
|
|
}
|
|
|
|
let rawSize = 0;
|
|
let gzipSize = 0;
|
|
let brotliSize = 0;
|
|
for (const name of closure) {
|
|
const contents = await readAsset(name);
|
|
rawSize += contents.byteLength;
|
|
gzipSize += gzipSync(contents).byteLength;
|
|
brotliSize += brotliCompressSync(contents).byteLength;
|
|
}
|
|
return { names: [...closure].sort(), count: closure.size, rawSize, gzipSize, brotliSize };
|
|
}
|
|
|
|
function normalizeManifestAssetName(specifier) {
|
|
return specifier.replace(/^\/assets\//, "").replace(/^\.\//, "");
|
|
}
|
|
|
|
function resolveStaticImportName(importerName, specifier) {
|
|
if (specifier.startsWith("/assets/")) return normalizeManifestAssetName(specifier);
|
|
if (!specifier.startsWith(".")) return null;
|
|
return path.posix.normalize(path.posix.join(path.posix.dirname(importerName), specifier));
|
|
}
|
|
|
|
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) => isJavaScriptAsset(file.name) && 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) => isJavaScriptAsset(file.name) && 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 || !parsed.lazyRuntimeChunks) {
|
|
throw new Error(`bundle-budgets.json missing required sections (default/routes/vendorChunks/lazyRuntimeChunks): ${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 (isLazyRuntimeBaselineKey(baselineKey)) {
|
|
const canonicalBaselineKey = baselineKey.replace(/^lazy-(?:monaco|pdfjs)-/u, "");
|
|
const configuredRuntimeKey = Object.keys(config.lazyRuntimeChunks ?? {})
|
|
.filter((key) => canonicalBaselineKey === key || canonicalBaselineKey.startsWith(`${key}-`))
|
|
.sort((left, right) => right.length - left.length)[0];
|
|
const runtimeKey = configuredRuntimeKey ?? canonicalBaselineKey;
|
|
return {
|
|
kind: "lazy-runtime",
|
|
key: runtimeKey,
|
|
budget: config.lazyRuntimeChunks?.[runtimeKey] ?? 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 (!isJavaScriptAsset(name) || isLazyRuntimeChunkName(name)) 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,}\.(?:m?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 (!isJavaScriptAsset(file.name)) continue;
|
|
if (file.size === 0) continue;
|
|
const baselineKey = baselineAssetKey(file.name);
|
|
const lookup = lookupBudget(baselineKey, config);
|
|
// Only report explicit lazy runtimes, vendor chunks or route-like assets,
|
|
// to avoid noise from every shared component split.
|
|
const isLazyRuntime = isLazyRuntimeChunkName(file.name);
|
|
const isVendor = isVendorChunkName(file.name);
|
|
const isRoute = isRouteChunkName(file.name);
|
|
if (!isLazyRuntime && !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 ?? (isLazyRuntime ? "lazy-runtime" : 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 (!isJavaScriptAsset(entry.name) && !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: [] };
|
|
}
|
|
|
|
let initialPayload;
|
|
try {
|
|
initialPayload = await measureInitialPayload({ assetsDir, files });
|
|
} catch (error) {
|
|
logger.error(`FAIL initial_payload_graph ${error.message}`);
|
|
return {
|
|
skipped: false,
|
|
results: [],
|
|
failures: [{ group: "initial_payload_graph", total: 1, count: 1, budget: 0, pass: false }],
|
|
unexpectedEmptyJsChunks: [],
|
|
largestJsChunks: getLargestJsChunks(files),
|
|
files,
|
|
};
|
|
}
|
|
|
|
const results = evaluateBudgets(files, budgets, initialPayload);
|
|
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}`);
|
|
}
|
|
logger.log(
|
|
`Initial static import graph: files=${initialPayload.count} raw=${initialPayload.rawSize} gzip=${initialPayload.gzipSize} brotli=${initialPayload.brotliSize}`
|
|
);
|
|
|
|
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);
|
|
});
|
|
}
|