diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3045d8939..76ecfc4a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,9 @@ jobs: - name: Build run: npm run build + - name: Bundle budget (warn-only until a baseline is enforced) + run: npm run check:bundle-budget + - name: Deployment boot smoke if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') env: diff --git a/bundle-budget.json b/bundle-budget.json new file mode 100644 index 000000000..84feae937 --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,6 @@ +{ + "$comment": "Client JS bundle-size budget. Warn-only until a baseline is captured: after a known-good `npm run build`, run `npm run check:bundle-budget -- --update` to set totalGzipBytes, then flip enforce to true so CI fails on >tolerancePct growth. See scripts/check-bundle-budget.mjs.", + "enforce": false, + "tolerancePct": 10, + "totalGzipBytes": null +} diff --git a/package.json b/package.json index 683023ce3..d7a9ecfb8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "ensure": "node scripts/ensure-local-server.mjs", "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs", "build:analyze": "node scripts/build-analyze.mjs", + "check:bundle-budget": "node scripts/check-bundle-budget.mjs", "start": "node scripts/dev-free-port.mjs start", "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern", "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 000000000..c1a17349c --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +/** + * check-bundle-budget — guard client JS bundle size against regression. + * + * The repo did perf/bundle-hygiene work, but nothing stops a regression: the next + * heavy dependency or an accidental client-side import of a big module (e.g. pulling + * a snapshot into a client component) silently undoes it. This measures the built + * client chunks and compares total gzip size against a committed baseline + * (bundle-budget.json), failing when growth exceeds the tolerance. + * + * ROLLOUT: warn-only until a baseline is captured and enforcement is turned on. + * - `bundle-budget.json` ships with `enforce: false` and `totalGzipBytes: null`. + * - After a known-good production build, run `--update` to record the baseline. + * - Flip `enforce` to true to make CI fail on >tolerancePct growth. + * Reads .next/static/chunks/**.js. If no build output exists it prints a note and + * exits 0 (so it never breaks a run that didn't build). + * + * Flags: --update (write current measurement as the baseline), --json. + */ +import { gzipSync } from "node:zlib"; +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CHUNKS_DIR = path.join(root, ".next", "static", "chunks"); +const BUDGET_PATH = path.join(root, "bundle-budget.json"); + +function walkJsFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkJsFiles(full)); + else if (entry.isFile() && entry.name.endsWith(".js")) out.push(full); + } + return out; +} + +/** Measure client chunks → { files, totalRawBytes, totalGzipBytes, largest[] }. */ +export function measureChunks(files) { + const measured = files.map(({ name, buffer }) => ({ + name, + rawBytes: buffer.length, + gzipBytes: gzipSync(buffer).length, + })); + const totalRawBytes = measured.reduce((sum, f) => sum + f.rawBytes, 0); + const totalGzipBytes = measured.reduce((sum, f) => sum + f.gzipBytes, 0); + const largest = [...measured].sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, 5); + return { files: measured.length, totalRawBytes, totalGzipBytes, largest }; +} + +/** + * Pure comparison. Returns { status: "ok"|"warn"|"fail", overPct, ... }. + * - no baseline → "warn" (nothing to compare yet). + * - within tolerance → "ok". + * - over tolerance → "fail" if enforcing, else "warn". + */ +export function compareToBudget(current, budget) { + const baseline = budget?.totalGzipBytes ?? null; + const tolerancePct = budget?.tolerancePct ?? 10; + const enforce = Boolean(budget?.enforce); + if (baseline == null) { + return { status: "warn", reason: "no baseline recorded", overPct: null, baseline, tolerancePct, enforce }; + } + const overPct = ((current.totalGzipBytes - baseline) / baseline) * 100; + const withinTolerance = overPct <= tolerancePct; + return { + status: withinTolerance ? "ok" : enforce ? "fail" : "warn", + reason: withinTolerance ? "within tolerance" : `+${overPct.toFixed(1)}% vs baseline (tolerance ${tolerancePct}%)`, + overPct, + baseline, + tolerancePct, + enforce, + }; +} + +function kb(bytes) { + return `${(bytes / 1024).toFixed(1)} KiB`; +} + +function loadBudget() { + try { + return JSON.parse(readFileSync(BUDGET_PATH, "utf8")); + } catch { + return { enforce: false, tolerancePct: 10, totalGzipBytes: null }; + } +} + +function main() { + const asJson = process.argv.includes("--json"); + const update = process.argv.includes("--update"); + + if (!existsSync(CHUNKS_DIR)) { + console.log( + `[bundle-budget] no build output at ${path.relative(root, CHUNKS_DIR)} — run \`npm run build\` first. Skipping.`, + ); + process.exit(0); + } + + const files = walkJsFiles(CHUNKS_DIR).map((full) => ({ + name: path.relative(CHUNKS_DIR, full), + buffer: readFileSync(full), + })); + const current = measureChunks(files); + const budget = loadBudget(); + + if (update) { + const next = { ...budget, totalGzipBytes: current.totalGzipBytes, updatedAt: new Date().toISOString() }; + writeFileSync(BUDGET_PATH, JSON.stringify(next, null, 2) + "\n"); + console.log(`[bundle-budget] baseline updated to ${kb(current.totalGzipBytes)} gzip (${current.files} chunks).`); + process.exit(0); + } + + const verdict = compareToBudget(current, budget); + if (asJson) { + console.log(JSON.stringify({ current, verdict }, null, 2)); + } else { + console.log( + `[bundle-budget] client chunks: ${current.files} files, ${kb(current.totalGzipBytes)} gzip (${kb(current.totalRawBytes)} raw).`, + ); + if (verdict.baseline != null) + console.log(`[bundle-budget] baseline ${kb(verdict.baseline)} gzip; ${verdict.reason}.`); + console.log("[bundle-budget] largest chunks (gzip):"); + for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`); + } + + if (verdict.status === "fail") { + console.error( + `[bundle-budget] FAIL — ${verdict.reason}. Refresh intentionally with \`npm run check:bundle-budget -- --update\`.`, + ); + process.exit(1); + } + if (verdict.status === "warn" && verdict.baseline == null) { + console.log( + "[bundle-budget] warn-only: capture a baseline with --update after a known-good build, then set enforce:true.", + ); + } else if (verdict.status === "warn") { + console.warn(`[bundle-budget] WARN (not enforced) — ${verdict.reason}.`); + } + process.exit(0); +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) main(); diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 317714125..a975b6363 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -140,6 +140,7 @@ const sourcePatterns = ["data", "src", "tests", "scripts", "worker", "playwright const coveragePatterns = ["data", "src", "tests", "vitest.config.mts"]; const buildPatterns = [ + "bundle-budget.json", "data", "src", "worker", @@ -149,6 +150,7 @@ const buildPatterns = [ "postcss.config.mjs", "package.json", "package-lock.json", + "scripts/check-bundle-budget.mjs", /^scripts\/(check-node-engine|guard-next-build|dev-free-port|ensure-local-server)\.(?:cjs|mjs)$/, ]; @@ -428,6 +430,13 @@ function selfTest() { workflow_changed: false, build_changed: true, }); + assertScope("bundle-budget-config", ["bundle-budget.json"], { + build_changed: true, + }); + assertScope("bundle-budget-checker", ["scripts/check-bundle-budget.mjs"], { + source_changed: true, + build_changed: true, + }); assertScope("container", ["Dockerfile.worker", "worker/python/requirements.txt"], { container_changed: true, build_changed: true, diff --git a/tests/bundle-budget.test.ts b/tests/bundle-budget.test.ts new file mode 100644 index 000000000..e8831f48f --- /dev/null +++ b/tests/bundle-budget.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { compareToBudget, measureChunks } from "../scripts/check-bundle-budget.mjs"; + +const buf = (n: number) => Buffer.alloc(n, "a"); // highly compressible; gzip < raw + +describe("measureChunks", () => { + it("sums raw and gzip bytes and ranks the largest", () => { + const m = measureChunks([ + { name: "a.js", buffer: buf(1000) }, + { name: "b.js", buffer: buf(4000) }, + ]); + expect(m.files).toBe(2); + expect(m.totalRawBytes).toBe(5000); + expect(m.totalGzipBytes).toBeGreaterThan(0); + expect(m.totalGzipBytes).toBeLessThan(m.totalRawBytes); + expect(m.largest[0].name).toBe("b.js"); + }); +}); + +describe("compareToBudget", () => { + it("warns when there is no baseline", () => { + const v = compareToBudget({ totalGzipBytes: 1000 }, { enforce: true, tolerancePct: 10, totalGzipBytes: null }); + expect(v.status).toBe("warn"); + expect(v.reason).toMatch(/no baseline/); + }); + + it("passes within tolerance", () => { + const v = compareToBudget({ totalGzipBytes: 1050 }, { enforce: true, tolerancePct: 10, totalGzipBytes: 1000 }); + expect(v.status).toBe("ok"); + expect(v.overPct).toBeCloseTo(5, 5); + }); + + it("fails over tolerance when enforcing", () => { + const v = compareToBudget({ totalGzipBytes: 1200 }, { enforce: true, tolerancePct: 10, totalGzipBytes: 1000 }); + expect(v.status).toBe("fail"); + expect(v.overPct).toBeCloseTo(20, 5); + }); + + it("only warns over tolerance when not enforcing", () => { + const v = compareToBudget({ totalGzipBytes: 1200 }, { enforce: false, tolerancePct: 10, totalGzipBytes: 1000 }); + expect(v.status).toBe("warn"); + }); + + it("treats exactly-at-tolerance as ok", () => { + const v = compareToBudget({ totalGzipBytes: 1100 }, { enforce: true, tolerancePct: 10, totalGzipBytes: 1000 }); + expect(v.status).toBe("ok"); + }); +});