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
27 changes: 27 additions & 0 deletions .github/workflows/bundle-budget.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Bundle Budget

on:
pull_request:
paths:
- "frontend/**"
workflow_dispatch:

jobs:
bundle-budget:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build
env:
NEXT_PUBLIC_STELLAR_RECEIVER_ADDRESS: GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA
run: npm run build
- name: Check bundle budget (report-only)
run: npm run bundle-budget
47 changes: 47 additions & 0 deletions frontend/docs/BUNDLE_BUDGET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Frontend Performance Budget

Issue #273 sets a target of **< 200 KB initial JavaScript (gzipped)** and asks for
that budget to be enforced in CI. The bundle analyzer and route-based code
splitting are already wired up (`npm run analyze`, and the `splitChunks` cache
groups in `next.config.js`); this adds the missing **measurement and enforcement**
layer.

## What it measures

`scripts/check-bundle-budget.mjs` reads the Next.js build manifests
(`.next/build-manifest.json` and `.next/app-build-manifest.json`) after a build,
gzips every first-load JavaScript chunk per route, and compares the largest route
against the budget. It is dependency-free (Node built-ins only), so it runs in CI
without an extra install step.

## Configuration

`performance-budget.json` (in the `frontend/` root):
{
"maxInitialJsGzipKb": 200,
"enforce": false,
"ignoreRoutes": ["/_error", "/404", "/500"]
}

- `maxInitialJsGzipKb` - the gzipped first-load budget, in KB.
- `enforce` - when `true`, the check exits non-zero if any route is over budget.
Left `false` initially so the gate can be adopted without breaking existing
builds, then flipped on once routes are under budget.
- `ignoreRoutes` - routes excluded from the check.

Overrides: `--budget=<kb>`, `--enforce`, or env `BUNDLE_BUDGET_KB` /
`BUNDLE_BUDGET_ENFORCE`.

## Run it locally

npm run build
npm run bundle-budget

## CI

`.github/workflows/bundle-budget.yml` builds the frontend and runs the check on
every pull request that touches `frontend/**`. It is **report-only** while
`enforce` is `false`: the budget table shows up in the job log without failing the
build. Flip `enforce` to `true` in `performance-budget.json` to make it a blocking
gate.
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"postinstall": "node scripts/postinstall.js",
"prepare": "husky install",
"analyze": "ANALYZE=true next build",
"bundle-budget": "node scripts/check-bundle-budget.mjs",
"lighthouse": "lighthouse http://localhost:3000 --output=json --output-path=./lighthouse-report.json",
"performance-test": "npm run build && npm run lighthouse"
},
Expand Down
5 changes: 5 additions & 0 deletions frontend/performance-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"maxInitialJsGzipKb": 200,
"enforce": false,
"ignoreRoutes": ["/_error", "/404", "/500"]
}
155 changes: 155 additions & 0 deletions frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// Performance budget check for the AetherMint frontend (issue #273).
//
// Measures the gzipped "first load" JavaScript for each built route from the
// Next.js build manifests and compares the largest route against a configurable
// budget (default: 200 KB gzipped).
//
// Usage:
// npm run build # produce .next/ output first
// npm run bundle-budget # measure and report
//
// Flags / env:
// --enforce fail (exit 1) when a route exceeds the budget
// --budget=<kb> override the KB budget
// BUNDLE_BUDGET_ENFORCE=1 same as --enforce
// BUNDLE_BUDGET_KB=<kb> same as --budget
//
// Config: frontend/performance-budget.json
// { "maxInitialJsGzipKb": 200, "enforce": false, "ignoreRoutes": [...] }
//
// Dependency-free: uses only Node built-ins so it runs in CI without install.

import { readFileSync, existsSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const scriptDir = dirname(fileURLToPath(import.meta.url));
const frontendRoot = join(scriptDir, "..");
const nextDir = join(frontendRoot, ".next");

function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}

function loadConfig() {
const defaults = { maxInitialJsGzipKb: 200, enforce: false, ignoreRoutes: [] };
const configPath = join(frontendRoot, "performance-budget.json");
let config = defaults;
if (existsSync(configPath)) {
try {
config = { ...defaults, ...readJson(configPath) };
} catch (err) {
console.warn("Could not parse performance-budget.json, using defaults:", err.message);
}
}
const budgetArg = process.argv.find((a) => a.startsWith("--budget="));
if (process.env.BUNDLE_BUDGET_KB) config.maxInitialJsGzipKb = Number(process.env.BUNDLE_BUDGET_KB);
if (budgetArg) config.maxInitialJsGzipKb = Number(budgetArg.split("=")[1]);
if (process.env.BUNDLE_BUDGET_ENFORCE === "1" || process.env.BUNDLE_BUDGET_ENFORCE === "true") {
config.enforce = true;
}
if (process.argv.includes("--enforce")) config.enforce = true;
return config;
}

const gzipCache = new Map();
function gzipBytes(relFile) {
if (gzipCache.has(relFile)) return gzipCache.get(relFile);
const abs = join(nextDir, relFile);
let size = 0;
if (existsSync(abs)) {
size = gzipSync(readFileSync(abs), { level: 9 }).length;
}
gzipCache.set(relFile, size);
return size;
}

function isJs(f) {
return typeof f === "string" && f.endsWith(".js");
}

function firstLoadForRoutes() {
const routes = new Map();

// Pages Router: .next/build-manifest.json
const pagesManifestPath = join(nextDir, "build-manifest.json");
if (existsSync(pagesManifestPath)) {
const m = readJson(pagesManifestPath);
const polyfills = (m.polyfillFiles || []).filter(isJs);
const appShared = (m.pages && m.pages["/_app"] ? m.pages["/_app"] : []).filter(isJs);
const shared = [...polyfills, ...appShared];
for (const [route, files] of Object.entries(m.pages || {})) {
if (route === "/_app") continue;
routes.set(route, new Set([...shared, ...files.filter(isJs)]));
}
}

// App Router: .next/app-build-manifest.json
const appManifestPath = join(nextDir, "app-build-manifest.json");
if (existsSync(appManifestPath)) {
const m = readJson(appManifestPath);
for (const [route, files] of Object.entries(m.pages || {})) {
routes.set(route, new Set(files.filter(isJs)));
}
}

return routes;
}

function formatKb(bytes) {
return (bytes / 1024).toFixed(1) + " KB";
}

function main() {
if (!existsSync(nextDir)) {
console.error('No .next build output found. Run "npm run build" before "npm run bundle-budget".');
process.exit(1);
}

const config = loadConfig();
const budgetBytes = config.maxInitialJsGzipKb * 1024;
const ignore = new Set(config.ignoreRoutes || []);
const routes = firstLoadForRoutes();

if (routes.size === 0) {
console.error("Could not read any build manifest (build-manifest.json / app-build-manifest.json).");
process.exit(1);
}

const rows = [];
for (const [route, files] of routes) {
if (ignore.has(route)) continue;
let total = 0;
for (const f of files) total += gzipBytes(f);
rows.push({ route, bytes: total, over: total > budgetBytes });
}
rows.sort((a, b) => b.bytes - a.bytes);

const routeWidth = Math.max(12, ...rows.map((r) => r.route.length));
console.log("");
console.log("First Load JS (gzipped) vs budget of " + config.maxInitialJsGzipKb + " KB");
console.log("-".repeat(routeWidth + 20));
for (const r of rows) {
const flag = r.over ? " OVER" : " ok";
console.log(r.route.padEnd(routeWidth) + " " + formatKb(r.bytes).padStart(10) + flag);
}
console.log("-".repeat(routeWidth + 20));

const over = rows.filter((r) => r.over);
const worst = rows[0];
console.log("Largest route: " + worst.route + " at " + formatKb(worst.bytes));
console.log(over.length + " of " + rows.length + " routes exceed the " + config.maxInitialJsGzipKb + " KB budget.");

if (over.length > 0 && config.enforce) {
console.error("Performance budget exceeded (enforce mode).");
process.exit(1);
}
if (over.length > 0) {
console.log('Report-only mode: not failing the build. Set "enforce": true (or --enforce) to gate.');
}
process.exit(0);
}

main();
Loading