Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/performance/performance-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Performance Budget

BF-025 adds a route-level performance budget gate for the exported app. The budget is intentionally small and explicit: it covers the root page, English home, All Tools, JSON Formatter, Markdown Preview, and Image Resizer.

## CI Behavior

CI runs `npm run validate`, which checks that the performance budget configuration is present and well formed. After `npm run build:app`, CI runs `npm run build:post`; that command includes `npm run check:performance-budget:report` and prints a route bundle summary.

The report fails when any route exceeds its configured budget. The current budget tracks:

- initial JavaScript gzip bytes
- initial JavaScript raw bytes
- initial script file count
- CSS gzip bytes
- CSS raw bytes
- rendered HTML bytes

## Routes

Budgets live in `scripts/gates/performance-budgets.json` for these baseline routes:

- `/`
- `/en`
- `/en/all-tools`
- `/en/json-formatter`
- `/en/markdown-preview`
- `/en/image-resizer`

## Updating A Budget

Only update a threshold when the route growth is intentional. The PR should include:

- the route and metric that changed
- the before and after values from `npm run check:performance-budget:report`
- why the extra bytes are necessary
- confirmation that heavy dependencies still pass `npm run check:bundle-boundaries`

Do not raise budgets to hide accidental regressions. Prefer route-level code splitting, dynamic imports, or removing duplicated client code before changing the baseline.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
"scripts": {
"dev": "next dev",
"analyze": "ANALYZE=true next build --webpack",
"validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:bundle-boundaries && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:types",
"validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:types",
"build:app": "next build",
"build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run build:sw",
"build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:performance-budget:report && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run build:sw",
"build": "npm run validate && npm run build:app && npm run build:post",
"start": "next start",
"lint": "eslint",
Expand All @@ -80,6 +80,8 @@
"check:privacy-copy": "node scripts/gates/check-privacy-copy.js",
"check:security-headers": "node scripts/gates/check-security-headers-config.js",
"check:bundle-boundaries": "node scripts/gates/check-bundle-boundaries.js",
"check:performance-budget": "node scripts/gates/check-performance-budget.js --check-config",
"check:performance-budget:report": "node scripts/gates/check-performance-budget.js --report",
"check:sw-version": "node scripts/gates/check-sw-version-bump.js",
"build:sw": "node scripts/postprocess/inject-sw-build-id.js",
"check:types": "tsc --noEmit",
Expand Down
252 changes: 252 additions & 0 deletions scripts/gates/check-performance-budget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/usr/bin/env node

import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";

const ROOT = process.cwd();
const OUT_DIR = path.join(ROOT, "out");
const CONFIG_PATH = path.join(ROOT, "scripts/gates/performance-budgets.json");
const REQUIRED_ROUTES = ["/", "/en", "/en/all-tools", "/en/json-formatter", "/en/markdown-preview", "/en/image-resizer"];
const BUDGET_KEYS = [
"maxInitialJsGzipBytes",
"maxInitialJsRawBytes",
"maxInitialScriptFiles",
"maxCssGzipBytes",
"maxCssRawBytes",
"maxHtmlBytes",
];

function readConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
}

function validateConfig(config) {
const failures = [];
if (!Array.isArray(config.routes)) {
failures.push("performance-budgets.json must contain a routes array.");
return failures;
}

const seen = new Set();
for (const entry of config.routes) {
if (!entry || typeof entry !== "object") {
failures.push("Each route budget must be an object.");
continue;
}
if (typeof entry.route !== "string" || !entry.route.startsWith("/")) {
failures.push(`Invalid route budget entry: route must be an absolute path.`);
}
if (typeof entry.label !== "string" || entry.label.trim().length === 0) {
failures.push(`${entry.route ?? "(unknown route)"}: label is required.`);
}
if (seen.has(entry.route)) {
failures.push(`${entry.route}: duplicate route budget.`);
}
seen.add(entry.route);

for (const key of BUDGET_KEYS) {
if (!Number.isInteger(entry[key]) || entry[key] <= 0) {
failures.push(`${entry.route ?? "(unknown route)"}: ${key} must be a positive integer.`);
}
}
if (entry.maxInitialJsGzipBytes > 500000) {
failures.push(`${entry.route}: maxInitialJsGzipBytes is too high for a baseline route.`);
}
if (entry.maxInitialScriptFiles > 35) {
failures.push(`${entry.route}: maxInitialScriptFiles is too high for a baseline route.`);
}
}

for (const route of REQUIRED_ROUTES) {
if (!seen.has(route)) {
failures.push(`Missing required performance budget route: ${route}.`);
}
}

return failures;
}

function routeHtmlPath(route) {
if (route === "/") return path.join(OUT_DIR, "index.html");
const cleanRoute = route.replace(/^\/+/, "");
const flatPath = path.join(OUT_DIR, `${cleanRoute}.html`);
const indexPath = path.join(OUT_DIR, cleanRoute, "index.html");
if (fs.existsSync(flatPath)) return flatPath;
if (fs.existsSync(indexPath)) return indexPath;
return flatPath;
}

function getAttribute(tag, name) {
const match = tag.match(new RegExp(`\\b${name}=["']([^"']+)["']`, "i"));
return match?.[1] ?? "";
}

function assetPathFromUrl(assetUrl) {
const cleanUrl = assetUrl.split(/[?#]/, 1)[0];
if (!cleanUrl.startsWith("/_next/static/")) return null;
return path.join(OUT_DIR, cleanUrl.replace(/^\/+/, ""));
}

function extractInitialAssets(html) {
const scriptAssets = new Set();
const cssAssets = new Set();

for (const match of html.matchAll(/<script\b[^>]*\bsrc=["'][^"']+["'][^>]*>/gi)) {
const assetPath = assetPathFromUrl(getAttribute(match[0], "src"));
if (assetPath) scriptAssets.add(assetPath);
}

for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
const tag = match[0];
const rel = getAttribute(tag, "rel").toLowerCase();
const asType = getAttribute(tag, "as").toLowerCase();
const href = getAttribute(tag, "href");
const assetPath = assetPathFromUrl(href);
if (!assetPath) continue;

if (rel === "stylesheet" && assetPath.endsWith(".css")) {
cssAssets.add(assetPath);
}
if ((rel === "preload" || rel === "modulepreload") && asType === "script") {
scriptAssets.add(assetPath);
}
}

return {
scripts: [...scriptAssets].sort(),
styles: [...cssAssets].sort(),
};
}

function sumAssets(files) {
return files.reduce(
(result, file) => {
if (!fs.existsSync(file)) {
result.missing.push(path.relative(ROOT, file).replace(/\\/g, "/"));
return result;
}
const buffer = fs.readFileSync(file);
result.rawBytes += buffer.length;
result.gzipBytes += zlib.gzipSync(buffer, { level: 9 }).length;
return result;
},
{ rawBytes: 0, gzipBytes: 0, missing: [] },
);
}

function measureRoute(entry) {
const htmlPath = routeHtmlPath(entry.route);
if (!fs.existsSync(htmlPath)) {
return {
entry,
missingHtml: path.relative(ROOT, htmlPath).replace(/\\/g, "/"),
};
}

const html = fs.readFileSync(htmlPath, "utf8");
const assets = extractInitialAssets(html);
const js = sumAssets(assets.scripts);
const css = sumAssets(assets.styles);

return {
entry,
htmlPath,
htmlBytes: fs.statSync(htmlPath).size,
scriptFiles: assets.scripts.length,
cssFiles: assets.styles.length,
jsRawBytes: js.rawBytes,
jsGzipBytes: js.gzipBytes,
cssRawBytes: css.rawBytes,
cssGzipBytes: css.gzipBytes,
missingAssets: [...js.missing, ...css.missing],
};
}

function bytesToKiB(value) {
return `${(value / 1024).toFixed(1)} KiB`;
}

function formatCell(actual, budget) {
return `${bytesToKiB(actual)} / ${bytesToKiB(budget)}`;
}

function printReport(measurements) {
console.log("[check:performance-budget] Route bundle budget report");
console.log("| Route | JS gzip | JS raw | Scripts | CSS gzip | HTML |");
console.log("| --- | ---: | ---: | ---: | ---: | ---: |");
for (const measurement of measurements) {
const entry = measurement.entry;
if (measurement.missingHtml) {
console.log(`| ${entry.route} | missing ${measurement.missingHtml} | - | - | - | - |`);
continue;
}
console.log(
`| ${entry.route} | ${formatCell(measurement.jsGzipBytes, entry.maxInitialJsGzipBytes)} | ${formatCell(measurement.jsRawBytes, entry.maxInitialJsRawBytes)} | ${measurement.scriptFiles} / ${entry.maxInitialScriptFiles} | ${formatCell(measurement.cssGzipBytes, entry.maxCssGzipBytes)} | ${formatCell(measurement.htmlBytes, entry.maxHtmlBytes)} |`,
);
}
}

function collectBudgetFailures(measurement) {
const entry = measurement.entry;
const failures = [];
if (measurement.missingHtml) {
failures.push(`${entry.route}: missing rendered HTML ${measurement.missingHtml}.`);
return failures;
}
for (const asset of measurement.missingAssets) {
failures.push(`${entry.route}: missing initial asset ${asset}.`);
}
if (measurement.jsGzipBytes > entry.maxInitialJsGzipBytes) {
failures.push(`${entry.route}: JS gzip ${bytesToKiB(measurement.jsGzipBytes)} exceeds ${bytesToKiB(entry.maxInitialJsGzipBytes)}.`);
}
if (measurement.jsRawBytes > entry.maxInitialJsRawBytes) {
failures.push(`${entry.route}: JS raw ${bytesToKiB(measurement.jsRawBytes)} exceeds ${bytesToKiB(entry.maxInitialJsRawBytes)}.`);
}
if (measurement.scriptFiles > entry.maxInitialScriptFiles) {
failures.push(`${entry.route}: initial script count ${measurement.scriptFiles} exceeds ${entry.maxInitialScriptFiles}.`);
}
if (measurement.cssGzipBytes > entry.maxCssGzipBytes) {
failures.push(`${entry.route}: CSS gzip ${bytesToKiB(measurement.cssGzipBytes)} exceeds ${bytesToKiB(entry.maxCssGzipBytes)}.`);
}
if (measurement.cssRawBytes > entry.maxCssRawBytes) {
failures.push(`${entry.route}: CSS raw ${bytesToKiB(measurement.cssRawBytes)} exceeds ${bytesToKiB(entry.maxCssRawBytes)}.`);
}
if (measurement.htmlBytes > entry.maxHtmlBytes) {
failures.push(`${entry.route}: HTML ${bytesToKiB(measurement.htmlBytes)} exceeds ${bytesToKiB(entry.maxHtmlBytes)}.`);
}
return failures;
}

const args = new Set(process.argv.slice(2));
const config = readConfig();
const configFailures = validateConfig(config);

if (configFailures.length > 0) {
console.error(`[check:performance-budget] FAILED: ${configFailures.length} budget config issue(s).`);
for (const failure of configFailures) console.error(`- ${failure}`);
process.exit(1);
}

if (args.has("--check-config") || !args.has("--report")) {
console.log(`[check:performance-budget] OK: ${config.routes.length} route budget(s) configured.`);
process.exit(0);
}

if (!fs.existsSync(OUT_DIR)) {
console.error("[check:performance-budget] FAILED: out/ is missing. Run npm run build:app before the report mode.");
process.exit(1);
}

const measurements = config.routes.map(measureRoute);
printReport(measurements);

const failures = measurements.flatMap(collectBudgetFailures);
if (failures.length > 0) {
console.error(`[check:performance-budget] FAILED: ${failures.length} performance budget violation(s).`);
for (const failure of failures) console.error(`- ${failure}`);
console.error("[check:performance-budget] Update scripts/gates/performance-budgets.json only when the route growth is intentional and documented in the PR.");
process.exit(1);
}

console.log(`[check:performance-budget] OK: ${measurements.length} route bundle budget(s) within limits.`);
64 changes: 64 additions & 0 deletions scripts/gates/performance-budgets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"routes": [
{
"route": "/",
"label": "Root x-default",
"maxInitialJsGzipBytes": 220000,
"maxInitialJsRawBytes": 720000,
"maxInitialScriptFiles": 12,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 90000
},
{
"route": "/en",
"label": "English home",
"maxInitialJsGzipBytes": 255000,
"maxInitialJsRawBytes": 900000,
"maxInitialScriptFiles": 17,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 360000
},
{
"route": "/en/all-tools",
"label": "All Tools",
"maxInitialJsGzipBytes": 285000,
"maxInitialJsRawBytes": 1025000,
"maxInitialScriptFiles": 20,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 620000
},
{
"route": "/en/json-formatter",
"label": "JSON Formatter",
"maxInitialJsGzipBytes": 350000,
"maxInitialJsRawBytes": 1220000,
"maxInitialScriptFiles": 27,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 320000
},
{
"route": "/en/markdown-preview",
"label": "Markdown Preview",
"maxInitialJsGzipBytes": 300000,
"maxInitialJsRawBytes": 1060000,
"maxInitialScriptFiles": 23,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 300000
},
{
"route": "/en/image-resizer",
"label": "Image Resizer",
"maxInitialJsGzipBytes": 340000,
"maxInitialJsRawBytes": 1180000,
"maxInitialScriptFiles": 27,
"maxCssGzipBytes": 35000,
"maxCssRawBytes": 220000,
"maxHtmlBytes": 310000
}
]
}
Loading