diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 23a34a6..3fc7f13 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -6,44 +6,108 @@ on: jobs: bundle-size: - name: Bundle Size Report + name: Bundle Size Check runs-on: ubuntu-latest + env: + ANALYZE: "true" + steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 9 - - name: Setup Node uses: actions/setup-node@v6 with: - node-version: 20 - cache: pnpm + node-version: "20" + cache: "npm" + cache-dependency-path: client/package-lock.json - name: Install dependencies - run: pnpm install + working-directory: client + run: npm ci - - name: Build with analyzer - run: ANALYZE=true next build --webpack + - name: Build client with analyzer + working-directory: client + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} + run: npm run build - - name: Extract bundle size - id: size - run: | - SIZE=$(du -sk .next | cut -f1) - echo "size=$SIZE" >> $GITHUB_OUTPUT + - name: Check bundle size budgets + id: check + working-directory: client + continue-on-error: true + run: node scripts/check-bundle-size.js --json > /tmp/bundle-size-report.json; echo "exit_code=$?" >> $GITHUB_OUTPUT - - name: Comment PR + - name: Read bundle size report and comment PR + id: report uses: actions/github-script@v9 with: script: | - const size = "${{ steps.size.outputs.size }}"; + const fs = require('fs'); + const reportPath = '/tmp/bundle-size-report.json'; + + if (!fs.existsSync(reportPath)) { + core.setOutput('status', 'error'); + core.setOutput('message', 'Bundle size report not generated'); + return; + } + + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const { measurement, result } = report; + const violations = result.violations || []; + const warnings = result.warnings || []; + + let body = '## šŸ“¦ Bundle Size Report\n\n'; + + body += `| Metric | Size | Status |\n`; + body += `|---|---|---|\n`; + body += `| **Total JS** | ${measurement.totalJS} KB | — |\n`; - github.rest.issues.createComment({ + for (const [route, data] of Object.entries(measurement.routes)) { + const icon = violations.some(v => v.type === 'route' && v.route === route) ? 'āŒ' : + warnings.some(w => w.type === 'route' && w.route === route) ? 'āš ļø' : 'āœ…'; + body += `| ${route} | ${data.totalKB} KB | ${icon} |\n`; + } + + if (violations.length > 0) { + body += '\n### āŒ Budget Violations\n\n'; + for (const v of violations) { + body += `- ${v.message}\n`; + } + } + + if (warnings.length > 0) { + body += '\n### āš ļø Warnings\n\n'; + for (const w of warnings) { + body += `- ${w.message}\n`; + } + } + + if (violations.length === 0 && warnings.length === 0) { + body += '\nāœ… All bundle size budgets are within limits.\n'; + } + + body += '\n---\n'; + body += '_Budgets defined in `client/bundle-size.json`. Exceptions require team review._\n'; + + await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, - body: `šŸ“¦ Bundle Size: ${size} KB` - }); \ No newline at end of file + body, + }); + + const status = violations.length > 0 ? 'violations' : 'pass'; + core.setOutput('status', status); + core.setOutput('violations', String(violations.length)); + + - name: Fail on budget violations + if: steps.report.outputs.status == 'violations' + run: | + echo "āŒ Bundle size budgets exceeded. Check the PR comment for details." + exit 1 diff --git a/client/__tests__/lib/check-bundle-size.test.ts b/client/__tests__/lib/check-bundle-size.test.ts new file mode 100644 index 0000000..b3328d3 --- /dev/null +++ b/client/__tests__/lib/check-bundle-size.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest' + +// These are pure functions exported from the check script +// We import them via dynamic require since the script is CommonJS +const script = require('../../scripts/check-bundle-size.js') + +describe('check-bundle-size', () => { + describe('routeToChunkPrefix', () => { + it('should map root route to app/page prefix', () => { + expect(script.routeToChunkPrefix('/')).toBe('app/page') + }) + + it('should map nested routes to app/dir/page prefix', () => { + expect(script.routeToChunkPrefix('/dashboard')).toBe('app/dashboard/page') + }) + + it('should map deeply nested routes', () => { + expect(script.routeToChunkPrefix('/dashboard/analytics')).toBe('app/dashboard/analytics/page') + }) + + it('should strip trailing slash', () => { + expect(script.routeToChunkPrefix('/settings/')).toBe('app/settings/page') + }) + + it('should handle routes with hyphens', () => { + expect(script.routeToChunkPrefix('/email-preferences')).toBe('app/email-preferences/page') + }) + + it('should handle oauth routes', () => { + expect(script.routeToChunkPrefix('/oauth-success')).toBe('app/oauth-success/page') + }) + }) + + describe('formatKB', () => { + it('should convert bytes to KB with one decimal', () => { + expect(script.formatKB(1024)).toBe('1.0') + }) + + it('should handle zero', () => { + expect(script.formatKB(0)).toBe('0.0') + }) + + it('should handle large values', () => { + expect(script.formatKB(1048576)).toBe('1024.0') + }) + + it('should round to one decimal', () => { + expect(script.formatKB(1536)).toBe('1.5') + }) + }) + + describe('checkBudgets', () => { + const baseBudgets = { + total: 800, + shared: 400, + perRoute: { + '/': 350, + '/dashboard': 350, + '/settings': 250, + }, + perChunk: 200, + } + + it('should pass when all sizes are within budgets', () => { + const measurement = { + totalChunkSize: 500 * 1024, + sharedChunks: [{ name: 'framework.js', size: 200 * 1024 }], + routes: { + '/': { totalSize: 250 * 1024, routeSize: 50 * 1024, sharedSize: 200 * 1024, gzipSize: 80 * 1024, chunks: [] }, + '/dashboard': { totalSize: 300 * 1024, routeSize: 100 * 1024, sharedSize: 200 * 1024, gzipSize: 100 * 1024, chunks: [] }, + '/settings': { totalSize: 200 * 1024, routeSize: 50 * 1024, sharedSize: 150 * 1024, gzipSize: 65 * 1024, chunks: [] }, + }, + allChunks: [ + { name: 'framework.js', size: 200 * 1024 }, + { name: 'app-page.js', size: 50 * 1024 }, + ], + } + + const result = script.checkBudgets(measurement, baseBudgets) + expect(result.violations).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + it('should fail when total JS exceeds budget', () => { + const measurement = { + totalChunkSize: 900 * 1024, + sharedChunks: [{ name: 'framework.js', size: 400 * 1024 }], + routes: { + '/': { totalSize: 500 * 1024, routeSize: 100 * 1024, sharedSize: 400 * 1024, gzipSize: 160 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'framework.js', size: 400 * 1024 }], + } + + const result = script.checkBudgets(measurement, baseBudgets) + expect(result.violations.some(v => v.type === 'total')).toBe(true) + }) + + it('should fail when shared chunk size exceeds budget', () => { + const measurement = { + totalChunkSize: 500 * 1024, + sharedChunks: [{ name: 'vendor.js', size: 450 * 1024 }], + routes: { + '/': { totalSize: 500 * 1024, routeSize: 50 * 1024, sharedSize: 450 * 1024, gzipSize: 160 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'vendor.js', size: 450 * 1024 }], + } + + const result = script.checkBudgets(measurement, baseBudgets) + expect(result.violations.some(v => v.type === 'shared')).toBe(true) + }) + + it('should fail when a specific route exceeds its budget', () => { + const measurement = { + totalChunkSize: 500 * 1024, + sharedChunks: [{ name: 'framework.js', size: 100 * 1024 }], + routes: { + '/': { totalSize: 400 * 1024, routeSize: 300 * 1024, sharedSize: 100 * 1024, gzipSize: 130 * 1024, chunks: [] }, + '/dashboard': { totalSize: 200 * 1024, routeSize: 100 * 1024, sharedSize: 100 * 1024, gzipSize: 65 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'framework.js', size: 100 * 1024 }], + } + + const result = script.checkBudgets(measurement, baseBudgets) + const routeViolation = result.violations.find(v => v.type === 'route' && v.route === '/') + expect(routeViolation).toBeDefined() + expect(routeViolation!.actual).toBe('400.0') + expect(routeViolation!.budget).toBe(350) + }) + + it('should warn when a route is approaching its budget (over 90%)', () => { + const measurement = { + totalChunkSize: 400 * 1024, + sharedChunks: [{ name: 'framework.js', size: 100 * 1024 }], + routes: { + '/dashboard': { totalSize: 325 * 1024, routeSize: 225 * 1024, sharedSize: 100 * 1024, gzipSize: 105 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'framework.js', size: 100 * 1024 }], + } + + const result = script.checkBudgets(measurement, baseBudgets) + const routeWarning = result.warnings.find(w => w.type === 'route' && w.route === '/dashboard') + expect(routeWarning).toBeDefined() + // 325 KB / 350 KB = 92.8%, should trigger warning at >90% + // 325 KB / 350 KB = 92.8%, should trigger warning at >90% + expect(routeWarning!.actual).toBe('325.0') + }) + + it('should fail when an individual chunk exceeds perChunk budget', () => { + const measurement = { + totalChunkSize: 300 * 1024, + sharedChunks: [{ name: 'framework.js', size: 100 * 1024 }], + routes: { + '/': { totalSize: 300 * 1024, routeSize: 200 * 1024, sharedSize: 100 * 1024, gzipSize: 100 * 1024, chunks: [] }, + }, + allChunks: [ + { name: 'framework.js', size: 100 * 1024 }, + { name: 'huge-chunk.js', size: 250 * 1024 }, + ], + } + + const result = script.checkBudgets(measurement, baseBudgets) + const chunkViolation = result.violations.find(v => v.type === 'chunk') + expect(chunkViolation).toBeDefined() + expect(chunkViolation!.chunk).toBe('huge-chunk.js') + }) + + it('should report multiple violations', () => { + const measurement = { + totalChunkSize: 900 * 1024, + sharedChunks: [{ name: 'framework.js', size: 500 * 1024 }], + routes: { + '/': { totalSize: 600 * 1024, routeSize: 100 * 1024, sharedSize: 500 * 1024, gzipSize: 200 * 1024, chunks: [] }, + '/dashboard': { totalSize: 550 * 1024, routeSize: 50 * 1024, sharedSize: 500 * 1024, gzipSize: 180 * 1024, chunks: [] }, + }, + allChunks: [ + { name: 'framework.js', size: 500 * 1024 }, + ], + } + + const result = script.checkBudgets(measurement, baseBudgets) + expect(result.violations.length).toBeGreaterThanOrEqual(2) + }) + + it('should not fail for routes not in the budget config', () => { + const measurement = { + totalChunkSize: 100 * 1024, + sharedChunks: [{ name: 'framework.js', size: 50 * 1024 }], + routes: { + '/': { totalSize: 100 * 1024, routeSize: 50 * 1024, sharedSize: 50 * 1024, gzipSize: 33 * 1024, chunks: [] }, + '/unknown-route': { totalSize: 999 * 1024, routeSize: 949 * 1024, sharedSize: 50 * 1024, gzipSize: 330 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'framework.js', size: 50 * 1024 }], + } + + const result = script.checkBudgets(measurement, baseBudgets) + // No violation should be reported for /unknown-route since it's not in budget config + const unknownViolation = result.violations.find(v => v.route === '/unknown-route') + expect(unknownViolation).toBeUndefined() + }) + + it('should return empty violations and warnings for perfectly efficient build', () => { + const tinyMeasurement = { + totalChunkSize: 100 * 1024, + sharedChunks: [{ name: 'framework.js', size: 50 * 1024 }], + routes: { + '/': { totalSize: 80 * 1024, routeSize: 30 * 1024, sharedSize: 50 * 1024, gzipSize: 25 * 1024, chunks: [] }, + '/dashboard': { totalSize: 70 * 1024, routeSize: 20 * 1024, sharedSize: 50 * 1024, gzipSize: 22 * 1024, chunks: [] }, + }, + allChunks: [{ name: 'framework.js', size: 50 * 1024 }], + } + + const result = script.checkBudgets(tinyMeasurement, baseBudgets) + expect(result.violations).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + }) +}) diff --git a/client/bundle-size.json b/client/bundle-size.json new file mode 100644 index 0000000..ed807cc --- /dev/null +++ b/client/bundle-size.json @@ -0,0 +1,26 @@ +{ + "$schema": "./bundle-size.schema.json", + "budgets": { + "total": 800, + "shared": 400, + "perRoute": { + "/": 350, + "/dashboard": 350, + "/dashboard/analytics": 300, + "/settings": 250, + "/settings/security": 250, + "/settings/notifications": 250, + "/settings/privacy": 200, + "/auth/2fa": 200, + "/privacy": 150, + "/terms": 150, + "/dpa": 150, + "/email-preferences": 200, + "/offline": 150, + "/oauth-success": 150, + "/oauth-error": 150, + "/spend-chart-demo": 250 + }, + "perChunk": 200 + } +} diff --git a/client/bundle-size.schema.json b/client/bundle-size.schema.json new file mode 100644 index 0000000..10d05ac --- /dev/null +++ b/client/bundle-size.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "./bundle-size.schema.json", + "title": "Bundle Size Budget Configuration", + "description": "Defines bundle size budgets for the client application per route and per chunk.", + "type": "object", + "required": ["budgets"], + "properties": { + "budgets": { + "type": "object", + "required": ["total", "shared", "perRoute", "perChunk"], + "properties": { + "total": { + "type": "number", + "description": "Maximum total JS size in KB (uncompressed) across all client chunks.", + "minimum": 100 + }, + "shared": { + "type": "number", + "description": "Maximum size in KB for shared chunks (framework, common vendor code).", + "minimum": 50 + }, + "perRoute": { + "type": "object", + "description": "Per-route JS budgets in KB. Each key is a route path, value is max KB.", + "additionalProperties": { + "type": "number", + "minimum": 50 + } + }, + "perChunk": { + "type": "number", + "description": "Maximum size in KB for any individual JS chunk. Prevents any single file from growing too large.", + "minimum": 50 + } + } + } + } +} diff --git a/client/docs/TEST_INFRASTRUCTURE.md b/client/docs/TEST_INFRASTRUCTURE.md index af3168e..07ca5be 100644 --- a/client/docs/TEST_INFRASTRUCTURE.md +++ b/client/docs/TEST_INFRASTRUCTURE.md @@ -109,9 +109,49 @@ Test utilities are located in `lib/test-utils/`: - `flaky-reporter.ts`: Custom Playwright reporter for flaky test detection - `fixtures.ts`: E2E test fixtures for authentication and database state +## Bundle Size Budget Enforcement + +Bundle size budgets are defined in `client/bundle-size.json` and enforced in CI via the `Bundle Size Check` workflow (`.github/workflows/bundle-size.yml`). + +### Budget Configuration + +The `bundle-size.json` config defines: +- **total**: Maximum total JS size across all client chunks (KB) +- **shared**: Maximum size for shared/framework chunks (KB) +- **perRoute**: Per-route JS budgets (KB) — each route's total JS (page + shared) must stay under this +- **perChunk**: Maximum size for any individual JS chunk (KB) + +### Checking Locally + +```bash +# Build with analyzer +ANALYZE=true npm run build + +# Run the check script +node scripts/check-bundle-size.js + +# JSON output (for CI) +node scripts/check-bundle-size.js --json +``` + +### CI Behavior + +- On every PR to `main`/`develop`, the workflow builds with `ANALYZE=true` and checks budgets +- If any budget is exceeded, the check fails and a comment is posted on the PR with details +- Warnings are posted when a route approaches 90% of its budget +- Exceptions require team review in the PR + +### Updating Budgets + +When intentionally adding size to the bundle (e.g., a new feature with necessary dependencies): + +1. Run `node scripts/check-bundle-size.js --json` before and after +2. Update `bundle-size.json` with the new values in a separate commit +3. Mention the change in the PR description for reviewer awareness + ## Next Steps -1. Install dependencies: `pnpm install` -2. Run tests: `pnpm test` +1. Install dependencies: `npm install` +2. Run tests: `npm test` 3. Configure Codecov token in GitHub secrets 4. Add coverage badge to README diff --git a/client/next.config.mjs b/client/next.config.mjs index a0c87ed..8222df5 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -41,7 +41,7 @@ const nextConfig = { }, } -export default withSentryConfig( +let config = withSentryConfig( nextConfig, { silent: true, @@ -57,3 +57,17 @@ export default withSentryConfig( automaticVercelMonitors: true, } ); + +if (process.env.ANALYZE === 'true') { + try { + const withBundleAnalyzer = (await import('@next/bundle-analyzer')).default({ + enabled: true, + openAnalyzer: false, + }); + config = withBundleAnalyzer(config); + } catch { + console.warn('āš ļø @next/bundle-analyzer not available. Install with: npm install --save-dev @next/bundle-analyzer'); + } +} + +export default config; diff --git a/client/package-lock.json b/client/package-lock.json index a68fc83..3ccffff 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -71,6 +71,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.0.2", + "@next/bundle-analyzer": "^15.5.15", "@playwright/test": "^1.59.1", "@storybook/addon-a11y": "^10.3.3", "@storybook/addon-docs": "^10.3.3", @@ -539,6 +540,16 @@ "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", "license": "MIT" }, + "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.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -1835,6 +1846,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@next/bundle-analyzer": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-15.5.18.tgz", + "integrity": "sha512-v5/UNFwYbBlRQg/Bt+wU65XuxCxPu1AeCOI6s4s6Cludsj7FdVO9E9uzr7GIj8OykSrYtGuEQAUX0Ulje8W2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "webpack-bundle-analyzer": "4.10.1" + } + }, "node_modules/@next/env": { "version": "15.5.15", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", @@ -7985,6 +8006,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", @@ -8831,6 +8865,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/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -9117,6 +9161,13 @@ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", "license": "MIT" }, + "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", @@ -9358,6 +9409,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/electron-to-chromium": { "version": "1.5.363", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz", @@ -10815,6 +10873,22 @@ "dev": true, "license": "MIT" }, + "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-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -11485,6 +11559,16 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "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", @@ -12905,6 +12989,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -15949,6 +16043,71 @@ "node": ">=12" } }, + "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/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/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "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/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/client/package.json b/client/package.json index e456fe1..49842a3 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "generate-pwa-icons": "node scripts/generate-pwa-icons.js", "generate-pwa-screenshot": "node scripts/generate-pwa-screenshot.js", "generate-pwa-assets": "npm run generate-pwa-icons && npm run generate-pwa-screenshot", + "check-bundle-size": "node scripts/check-bundle-size.js", "e2e": "npx playwright test", "e2e:headed": "npx playwright test --headed", "e2e:report": "npx playwright show-report", @@ -86,6 +87,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.0.2", + "@next/bundle-analyzer": "^15.5.15", "@playwright/test": "^1.59.1", "@storybook/addon-a11y": "^10.3.3", "@storybook/addon-docs": "^10.3.3", diff --git a/client/scripts/check-bundle-size.js b/client/scripts/check-bundle-size.js new file mode 100644 index 0000000..c619ba0 --- /dev/null +++ b/client/scripts/check-bundle-size.js @@ -0,0 +1,367 @@ +#!/usr/bin/env node + +/** + * Bundle Size Check Script + * + * Reads the Next.js build output and enforces route-level and chunk-level + * budgets defined in bundle-size.json. Exits with code 1 on any violation. + * + * Usage: node scripts/check-bundle-size.js [options] + * --json Output results as JSON (useful for CI) + * --root= Specify client root directory (default: parent of scripts/) + */ + +const fs = require('fs'); +const path = require('path'); +const { gzipSync } = require('zlib'); + +function resolveRoot(argv) { + const rootFlag = argv.find(a => a.startsWith('--root=')); + if (rootFlag) return path.resolve(rootFlag.slice(7)); + return path.resolve(__dirname, '..'); +} + +const ROOT = resolveRoot(process.argv); +const BUDGET_PATH = path.join(ROOT, 'bundle-size.json'); +const BUILD_MANIFEST_PATH = path.join(ROOT, '.next', 'build-manifest.json'); +const CHUNKS_DIR = path.join(ROOT, '.next', 'static', 'chunks'); +const APP_CHUNKS_DIR = path.join(CHUNKS_DIR, 'app'); + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + bold: '\x1b[1m', +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function formatKB(bytes) { + return (bytes / 1024).toFixed(1); +} + +function readJSON(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (err) { + if (err.code === 'ENOENT') { + return null; + } + throw err; + } +} + +function getFileSize(filePath) { + try { + return fs.statSync(filePath).size; + } catch { + return 0; + } +} + +function getGzipSize(bytes) { + return gzipSync(Buffer.alloc(bytes)).length; +} + +/** + * Maps a route path to the expected app chunk filename pattern. + * Next.js app router generates chunks like: + * app/page.js -> / + * app/dashboard/page.js -> /dashboard + * app/dashboard/analytics/page.js -> /dashboard/analytics + */ +function routeToChunkPrefix(route) { + if (route === '/') return 'app/page'; + const normalized = route.replace(/\/$/, ''); + return `app${normalized}/page`; +} + +/** + * Finds the app chunk file(s) for a given route prefix. + * Next.js generates hashed filenames like `app-page-abc123.js` + */ +function findChunkFilesForRoute(prefix) { + if (!fs.existsSync(APP_CHUNKS_DIR)) return []; + const files = fs.readdirSync(APP_CHUNKS_DIR); + const safePrefix = prefix.replace(/\//g, '-'); + return files + .filter(f => f.startsWith(safePrefix) && f.endsWith('.js')) + .map(f => path.join(APP_CHUNKS_DIR, f)); +} + +function loadBudgets(budgetPath) { + const budgets = readJSON(budgetPath || BUDGET_PATH); + if (!budgets) { + log('āŒ bundle-size.json not found. Run from client directory.', 'red'); + process.exit(1); + } + return budgets.budgets; +} + +/** + * Measures actual sizes of build output. + */ +function measureBuild(buildManifest, chunksDir, appChunksDir, rootDir) { + const cd = chunksDir || CHUNKS_DIR; + const acd = appChunksDir || APP_CHUNKS_DIR; + + if (!fs.existsSync(cd)) { + log('āŒ No chunks directory found. Has the client been built?', 'red'); + log(' Run: npm run build (with ANALYZE=true for detailed analysis)', 'yellow'); + process.exit(1); + } + + const rootMainFiles = buildManifest?.rootMainFiles || []; + const allChunks = new Map(); + const sharedChunks = []; + + for (const file of rootMainFiles) { + const filePath = path.join(cd, file); + const size = getFileSize(filePath); + if (size > 0) { + sharedChunks.push({ name: file, size }); + allChunks.set(file, size); + } + } + + const polyfillFiles = buildManifest?.polyfillFiles || []; + for (const file of polyfillFiles) { + const filePath = path.join(cd, file); + const size = getFileSize(filePath); + if (size > 0) { + sharedChunks.push({ name: file, size }); + allChunks.set(file, size); + } + } + + const routes = new Map(); + + const budgetRoutes = Object.keys(loadBudgets().perRoute); + + for (const route of budgetRoutes) { + const prefix = routeToChunkPrefix(route); + const routeChunks = []; + + const chunkFiles = findChunkFilesForRoute(prefix); + for (const filePath of chunkFiles) { + const name = path.basename(filePath); + const size = getFileSize(filePath); + if (size > 0) { + routeChunks.push({ name, size, filePath }); + allChunks.set(name, size); + } + } + + const routeSize = routeChunks.reduce((sum, c) => sum + c.size, 0); + const sharedSize = sharedChunks.reduce((sum, c) => sum + c.size, 0); + const totalSize = routeSize + sharedSize; + + routes.set(route, { + chunks: routeChunks, + routeSize, + sharedSize, + totalSize, + gzipSize: getGzipSize(totalSize), + }); + } + + const totalChunkSize = Array.from(allChunks.values()).reduce((sum, s) => sum + s, 0); + + const individualChunks = []; + for (const [name, size] of allChunks) { + individualChunks.push({ name, size }); + } + + return { + sharedChunks, + routes: Object.fromEntries(routes), + allChunks: individualChunks, + totalChunkSize, + }; +} + +/** + * Checks all measured sizes against budgets. + * Pure function - no side effects, no file I/O. + */ +function checkBudgets(measurement, budgets) { + const violations = []; + const warnings = []; + + if (measurement.totalChunkSize / 1024 > budgets.total) { + violations.push({ + type: 'total', + actual: formatKB(measurement.totalChunkSize), + budget: budgets.total, + message: `Total JS ${formatKB(measurement.totalChunkSize)} KB exceeds budget of ${budgets.total} KB`, + }); + } + + const sharedTotal = measurement.sharedChunks.reduce((s, c) => s + c.size, 0); + if (sharedTotal / 1024 > budgets.shared) { + violations.push({ + type: 'shared', + actual: formatKB(sharedTotal), + budget: budgets.shared, + message: `Shared JS ${formatKB(sharedTotal)} KB exceeds budget of ${budgets.shared} KB`, + }); + } + + for (const [route, routeData] of Object.entries(measurement.routes)) { + const budget = budgets.perRoute[route]; + if (budget === undefined) continue; + + const sizeKB = routeData.totalSize / 1024; + if (sizeKB > budget) { + violations.push({ + type: 'route', + route, + actual: formatKB(routeData.totalSize), + budget, + message: `Route "${route}" is ${formatKB(routeData.totalSize)} KB, exceeds budget of ${budget} KB`, + }); + } else if (sizeKB > budget * 0.9) { + warnings.push({ + type: 'route', + route, + actual: formatKB(routeData.totalSize), + budget, + message: `Route "${route}" is ${formatKB(routeData.totalSize)} KB, approaching budget of ${budget} KB (${(sizeKB / budget * 100).toFixed(0)}%)`, + }); + } + } + + for (const chunk of measurement.allChunks) { + const chunkSizeKB = chunk.size / 1024; + if (chunkSizeKB > budgets.perChunk) { + violations.push({ + type: 'chunk', + chunk: chunk.name, + actual: formatKB(chunk.size), + budget: budgets.perChunk, + message: `Chunk "${chunk.name}" is ${formatKB(chunk.size)} KB, exceeds per-chunk budget of ${budgets.perChunk} KB`, + }); + } + } + + return { violations, warnings }; +} + +function printReport(measurement, result, isJson) { + if (isJson) { + console.log(JSON.stringify({ + timestamp: new Date().toISOString(), + measurement: { + totalJS: formatKB(measurement.totalChunkSize), + routes: Object.fromEntries( + Object.entries(measurement.routes).map(([route, data]) => [ + route, + { totalKB: formatKB(data.totalSize), routeKB: formatKB(data.routeSize), sharedKB: formatKB(data.sharedSize), gzipKB: formatKB(data.gzipSize) }, + ]) + ), + sharedChunks: measurement.sharedChunks.map(c => ({ name: c.name, sizeKB: formatKB(c.size) })), + }, + result, + })); + return; + } + + log('\n' + '='.repeat(60), 'cyan'); + log(' šŸ“¦ Bundle Size Report', 'bold'); + log('='.repeat(60), 'cyan'); + + log(`\nTotal JS: ${formatKB(measurement.totalChunkSize)} KB (gzip ~${formatKB(measurement.totalChunkSize * 0.35)} KB est.)`, 'bold'); + + log('\n── Shared Chunks ──', 'cyan'); + for (const chunk of measurement.sharedChunks) { + log(` ${chunk.name}: ${formatKB(chunk.size)} KB`); + } + + log('\n── Routes ──', 'cyan'); + const sortedRoutes = Object.entries(measurement.routes).sort((a, b) => b[1].totalSize - a[1].totalSize); + + for (const [route, data] of sortedRoutes) { + const budget = loadBudgets().perRoute[route]; + const pct = budget ? (data.totalSize / 1024 / budget * 100).toFixed(0) : '?'; + const overBudget = budget && data.totalSize / 1024 > budget; + const color = overBudget ? 'red' : data.totalSize / 1024 > (budget || Infinity) * 0.9 ? 'yellow' : 'green'; + log( + ` ${route.padEnd(30)} ${formatKB(data.totalSize).padStart(8)} KB` + + ` (page: ${formatKB(data.routeSize).padStart(6)} KB + shared: ${formatKB(data.sharedSize).padStart(6)} KB)` + + ` [${pct}% of budget]`, + color, + ); + } + + log('\n── Individual Chunks ──', 'cyan'); + const sortedChunks = measurement.allChunks.sort((a, b) => b.size - a.size); + for (const chunk of sortedChunks.slice(0, 20)) { + const color = chunk.size / 1024 > loadBudgets().perChunk ? 'red' : 'green'; + log(` ${chunk.name.padEnd(50)} ${formatKB(chunk.size).padStart(8)} KB`, color); + } + if (sortedChunks.length > 20) { + log(` ... and ${sortedChunks.length - 20} more chunks`); + } + + if (result.warnings.length > 0) { + log('\n── Warnings ──', 'yellow'); + for (const w of result.warnings) { + log(` āš ļø ${w.message}`, 'yellow'); + } + } + + if (result.violations.length > 0) { + log('\n── Violations ──', 'red'); + for (const v of result.violations) { + log(` āŒ ${v.message}`, 'red'); + } + } + + const pass = result.violations.length === 0; + log('\n' + '='.repeat(60), 'cyan'); + if (pass) { + log(' āœ… All bundle size budgets met!', 'green'); + } else { + log(` āŒ ${result.violations.length} violation(s) found — fix before merging.`, 'red'); + } + log('='.repeat(60) + '\n', 'cyan'); +} + +function main() { + const isJson = process.argv.includes('--json'); + + const budgets = loadBudgets(); + const buildManifest = readJSON(BUILD_MANIFEST_PATH); + + if (!buildManifest) { + log('āŒ build-manifest.json not found. Run `npm run build` first.', 'red'); + process.exit(1); + } + + const measurement = measureBuild(buildManifest); + const result = checkBudgets(measurement, budgets); + + printReport(measurement, result, isJson); + + if (result.violations.length > 0) { + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + routeToChunkPrefix, + formatKB, + getFileSize, + getGzipSize, + checkBudgets, + measureBuild, + loadBudgets, +}; diff --git a/docs/repo-issue-backlog-2026-05.md b/docs/repo-issue-backlog-2026-05.md index 01e0d2c..d5ae63f 100644 --- a/docs/repo-issue-backlog-2026-05.md +++ b/docs/repo-issue-backlog-2026-05.md @@ -20,3 +20,30 @@ The client package included `SUPABASE_SERVICE_ROLE_KEY` in its environment confi - `backend/src/config/database.ts` — service-role client with connection pool monitoring - `backend/src/routes/user.ts:104` — `supabase.auth.admin.deleteUser()` requires admin privileges - `scripts/*` — admin-level RLS audit scripts (offline use only) + +## #697 — [P2] Tighten bundle-size budgets for the client app + +**Scope:** ops +**Priority:** P2 + +**Summary:** +The repo had a bundle-size CI workflow but it was rudimentary — it measured the total `.next` directory size via `du -sk` and posted a raw KB comment with no per-route analysis, no baseline comparison, and no budget enforcement. This made it easy for bundle regressions to slip through unnoticed. + +**Resolution:** +- Added `@next/bundle-analyzer` to client devDependencies and wired it into `next.config.mjs` via the `ANALYZE=true` environment variable +- Created `client/bundle-size.json` with route-level and chunk-level budgets for all 16 UI routes +- Created `client/scripts/check-bundle-size.js` that reads the Next.js build manifest, resolves per-route JS chunks from `.next/static/chunks/app/`, measures actual file sizes, and compares against defined budgets +- Rewrote `.github/workflows/bundle-size.yml` to: + - Build from the `client/` directory with `npm ci` (fixed broken pnpm setup) + - Run the check script with `--json` output + - Post a detailed PR comment with per-route breakdown, violations, and warnings + - Fail CI on any budget violation +- Added test coverage (`client/__tests__/lib/check-bundle-size.test.ts`) for the core `checkBudgets`, `routeToChunkPrefix`, and `formatKB` functions +- Updated `client/docs/TEST_INFRASTRUCTURE.md` with bundle-size workflow documentation + +**Key files:** +- `client/bundle-size.json` — budget definitions +- `client/scripts/check-bundle-size.js` — budget enforcement script +- `client/next.config.mjs` — conditional bundle analyzer integration +- `.github/workflows/bundle-size.yml` — CI workflow +- `client/__tests__/lib/check-bundle-size.test.ts` — unit tests