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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:

permissions:
contents: read
actions: read
pull-requests: write

jobs:
verify:
runs-on: ubuntu-latest
Expand All @@ -26,4 +31,36 @@ jobs:
run: npm test

- name: Build and performance budget
id: budget
run: npm run build

- name: Upload perf snapshot
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.budget.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: perf-snapshot
path: perf/snapshot.json
retention-days: 30

- name: Comment on the pull request
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (steps.budget.outcome == 'success' || steps.budget.outcome == 'failure')
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_REPOSITORY: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ ! -f perf/snapshot.json ]; then
echo "No perf snapshot to comment on."
exit 0
fi
mkdir -p perf-base
id=$(gh run list --workflow ci.yml --branch main --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty')
if [ -n "$id" ]; then
gh run download "$id" --name perf-snapshot --dir perf-base || true
fi
args=(--head perf/snapshot.json --run-url "$RUN_URL" --post)
if [ -f perf-base/snapshot.json ]; then
args+=(--base perf-base/snapshot.json)
fi
node scripts/perf-comment.mjs "${args[@]}"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules
perf/snapshot.json
perf-base/
.next
out
dist
Expand Down
6 changes: 6 additions & 0 deletions docs/perf/budgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ LCP 2500 ms, INP 200 ms, CLS 0.1. These are the Core Web Vitals "good" threshold

The production-labor benchmark (human minutes per finished audio minute) is a different number. It lives in [benchmark.md](../benchmark.md).

## Pull request comment

CI writes `perf/snapshot.json` after the production build: parsed bytes and level-9 gzip for each budgeted route. A sticky comment, `<!-- perf-budget -->`, diffs that file against the snapshot from the last successful build on `main`. `baselineBytes` is not the diff. A growth of at least 100 B or 1% is marked. The comment does not fail the build. The gzip ceiling still does. Raise `maxBytes` in the same change that grows a page.

There is no browser bench in CI. The production-labor benchmark is human minutes, and a lab paint time would move with the machine.

## Commands

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"sfx:init": "tsx scripts/generate-sfx.ts",
"mix": "tsx scripts/mix-episode.ts",
"benchmark": "tsx scripts/benchmark.ts",
"test": "node --test scripts/bundle-budget.test.mjs",
"test": "node --test scripts/bundle-budget.test.mjs scripts/perf-comment.test.mjs",
"perf:budget": "node scripts/check-bundle-budget.mjs",
"postbuild": "node scripts/check-bundle-budget.mjs",
"test:parser": "tsx scripts/test-parser.ts",
Expand Down
60 changes: 46 additions & 14 deletions scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* Print the measurement without enforcing: `node scripts/check-bundle-budget.mjs --print`
*/
import { createRequire } from "node:module";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";

Expand Down Expand Up @@ -106,18 +106,18 @@ export function measureRoutes(distDir) {
// that double-counts and no longer matches the First Load JS column.
const appFiles = (pages["/_app"] ?? []).filter((file) => file.endsWith(".js"));

/** @type {Map<string, number>} */
const gzipCache = new Map();
const gzip = (rel) => {
const cached = gzipCache.get(rel);
/** @type {Map<string, { parsed: number, gzip: number }>} */
const sizeCache = new Map();
const sizeOf = (rel) => {
const cached = sizeCache.get(rel);
if (cached != null) return cached;
const abs = path.join(distDir, rel);
const bytes = gzipSize.fileSync(abs);
gzipCache.set(rel, bytes);
return bytes;
const measured = { parsed: statSync(abs).size, gzip: gzipSize.fileSync(abs) };
sizeCache.set(rel, measured);
return measured;
};

/** @type {{ path: string, bytes: number }[]} */
/** @type {{ path: string, bytes: number, parsed: number }[]} */
const routes = [];
const seen = new Set();
for (const [key, files] of Object.entries(appManifest.pages ?? {})) {
Expand All @@ -126,9 +126,14 @@ export function measureRoutes(distDir) {
if (seen.has(route)) continue;
seen.add(route);
const js = [...new Set([...(files ?? []), ...appFiles])].filter((file) => file.endsWith(".js"));
let total = 0;
for (const file of js) total += gzip(file);
routes.push({ path: route, bytes: total });
let gzip = 0;
let parsed = 0;
for (const file of js) {
const sized = sizeOf(file);
gzip += sized.gzip;
parsed += sized.parsed;
}
routes.push({ path: route, bytes: gzip, parsed });
}
routes.sort((a, b) => a.path.localeCompare(b.path));
return routes;
Expand All @@ -138,6 +143,7 @@ export function measureAssets(assets) {
return assets.map((asset) => ({
path: asset.path,
bytes: gzipSize.fileSync(asset.path),
parsed: statSync(asset.path).size,
}));
}

Expand Down Expand Up @@ -168,12 +174,37 @@ export function measureMarkers(distDir, markers) {
const hits = files.filter((file) => read(file).includes(marker.contains));
if (hits.length === 0) continue;
let bytes = 0;
for (const file of hits) bytes += gzipSize.fileSync(file);
measured.push({ path: marker.id, bytes });
let parsed = 0;
for (const file of hits) {
bytes += gzipSize.fileSync(file);
parsed += statSync(file).size;
}
measured.push({ path: marker.id, bytes, parsed });
}
return measured;
}

function writeSnapshot(bundles) {
const snapshotPath = path.resolve("perf/snapshot.json");
let benches = [];
if (existsSync(snapshotPath)) {
try {
benches = readJson(snapshotPath).benches ?? [];
} catch {
benches = [];
}
}
writeFileSync(snapshotPath, `${JSON.stringify({ bundles, benches }, null, 2)}\n`);
}

function snapshotBundles(routes, assets, markers) {
return [
...routes.map((route) => ({ name: route.path, parsed: route.parsed, gzip: route.bytes })),
...assets.map((asset) => ({ name: asset.path, parsed: asset.parsed, gzip: asset.bytes })),
...markers.map((marker) => ({ name: marker.path, parsed: marker.parsed, gzip: marker.bytes })),
];
}

function printRow(label, bytes, maxBytes) {
const max = maxBytes == null ? "" : ` max ${formatKb(maxBytes)}`;
console.log(` ${label.padEnd(32)} ${formatKb(bytes).padStart(10)}${max}`);
Expand All @@ -189,6 +220,7 @@ function main() {
const markerLimits = budgets?.markers ?? [];
const assets = assetLimits.length > 0 ? measureAssets(assetLimits) : [];
const markers = markerLimits.length > 0 ? measureMarkers(distDir, markerLimits) : [];
writeSnapshot(snapshotBundles(routes, assets, markers));

console.log("First-load JS (gzip, level 9 — same number as next build)");
const maxByRoute = new Map((budgets?.routes ?? []).map((route) => [route.path, route.maxBytes]));
Expand Down
Loading
Loading