From ac696ba8c54fd7a27853f0a391c59effd3726760 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sun, 21 Jun 2026 07:27:33 -0700 Subject: [PATCH] [BF-025] Add performance budget gate --- docs/performance/performance-budget.md | 38 ++++ package.json | 6 +- scripts/gates/check-performance-budget.js | 252 +++++++++++++++++++++ scripts/gates/performance-budgets.json | 64 ++++++ tests/guards/performance-budget-ci.test.ts | 52 +++++ 5 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 docs/performance/performance-budget.md create mode 100644 scripts/gates/check-performance-budget.js create mode 100644 scripts/gates/performance-budgets.json create mode 100644 tests/guards/performance-budget-ci.test.ts diff --git a/docs/performance/performance-budget.md b/docs/performance/performance-budget.md new file mode 100644 index 00000000..55c2f85a --- /dev/null +++ b/docs/performance/performance-budget.md @@ -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. diff --git a/package.json b/package.json index 5bb277ea..b1be5b33 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/gates/check-performance-budget.js b/scripts/gates/check-performance-budget.js new file mode 100644 index 00000000..18321790 --- /dev/null +++ b/scripts/gates/check-performance-budget.js @@ -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(/]*\bsrc=["'][^"']+["'][^>]*>/gi)) { + const assetPath = assetPathFromUrl(getAttribute(match[0], "src")); + if (assetPath) scriptAssets.add(assetPath); + } + + for (const match of html.matchAll(/]*>/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.`); diff --git a/scripts/gates/performance-budgets.json b/scripts/gates/performance-budgets.json new file mode 100644 index 00000000..60572bfa --- /dev/null +++ b/scripts/gates/performance-budgets.json @@ -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 + } + ] +} diff --git a/tests/guards/performance-budget-ci.test.ts b/tests/guards/performance-budget-ci.test.ts new file mode 100644 index 00000000..94f44100 --- /dev/null +++ b/tests/guards/performance-budget-ci.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +const ROOT = process.cwd() +const REQUIRED_ROUTES = ["/", "/en", "/en/all-tools", "/en/json-formatter", "/en/markdown-preview", "/en/image-resizer"] + +function read(relativePath: string) { + return fs.readFileSync(path.join(ROOT, relativePath), "utf8") +} + +describe("performance budget CI guard", () => { + it("defines explicit route budgets for the BF-025 baseline pages", () => { + const config = JSON.parse(read("scripts/gates/performance-budgets.json")) as { + routes: Array> + } + const routes = new Set(config.routes.map((route) => route.route)) + + for (const route of REQUIRED_ROUTES) { + expect(routes.has(route)).toBe(true) + } + + for (const budget of config.routes) { + expect(budget.maxInitialJsGzipBytes).toBeGreaterThan(0) + expect(budget.maxInitialJsGzipBytes).toBeLessThanOrEqual(500000) + expect(budget.maxInitialScriptFiles).toBeGreaterThan(0) + expect(budget.maxInitialScriptFiles).toBeLessThanOrEqual(35) + expect(budget.maxHtmlBytes).toBeGreaterThan(0) + } + }) + + it("wires config checks into validate and route reports into build post checks", () => { + const packageJson = JSON.parse(read("package.json")) as { scripts: Record } + + expect(packageJson.scripts["check:performance-budget"]).toBe( + "node scripts/gates/check-performance-budget.js --check-config", + ) + expect(packageJson.scripts["check:performance-budget:report"]).toBe( + "node scripts/gates/check-performance-budget.js --report", + ) + expect(packageJson.scripts.validate).toContain("npm run check:performance-budget") + expect(packageJson.scripts["build:post"]).toContain("npm run check:performance-budget:report") + }) + + it("documents budget update policy and CI output", () => { + const docs = read("docs/performance/performance-budget.md") + + expect(docs).toContain("npm run check:performance-budget:report") + expect(docs).toContain("before and after values") + expect(docs).toContain("Do not raise budgets to hide accidental regressions") + }) +})