Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 85 additions & 21 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
});
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
217 changes: 217 additions & 0 deletions client/__tests__/lib/check-bundle-size.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
26 changes: 26 additions & 0 deletions client/bundle-size.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading