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=