Skip to content

Commit a1e82f7

Browse files
committed
Add App Router-aware bundle size analysis script and update workflow
1 parent 18f0169 commit a1e82f7

3 files changed

Lines changed: 178 additions & 22 deletions

File tree

.github/workflows/analyze.yml

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ jobs:
4848
# npm scripts by forcing `--webpack`.
4949
run: ./node_modules/.bin/next build --webpack
5050

51-
# Here's the first place where next-bundle-analysis' own script is used
52-
# This step pulls the raw bundle stats for the current bundle
51+
# Measure the current build's bundle sizes (App Router-aware).
52+
# See scripts/analyzeBundle.mjs — reads build-manifest.json + .next/static
53+
# and sums gzipped sizes, since nextjs-bundle-analysis only understands the
54+
# Pages Router and reports 0 B for the App Router.
5355
- name: Analyze bundle
54-
run: npx -p nextjs-bundle-analysis@0.5.0 report
56+
run: node scripts/analyzeBundle.mjs report
5557

5658
- name: Upload bundle
5759
uses: actions/upload-artifact@v4
@@ -68,22 +70,12 @@ jobs:
6870
name: bundle_analysis.json
6971
path: .next/analyze/base/bundle
7072

71-
# And here's the second place - this runs after we have both the current and
72-
# base branch bundle stats, and will compare them to determine what changed.
73-
# There are two configurable arguments that come from package.json:
74-
#
75-
# - budget: optional, set a budget (bytes) against which size changes are measured
76-
# it's set to 350kb here by default, as informed by the following piece:
77-
# https://infrequently.org/2021/03/the-performance-inequality-gap/
78-
#
79-
# - red-status-percentage: sets the percent size increase where you get a red
80-
# status indicator, defaults to 20%
81-
#
82-
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
83-
# entry in your package.json file.
73+
# Compare the current build against the base-branch stats downloaded above
74+
# and write the Markdown comment body. Degrades gracefully when the base
75+
# branch has no stats yet (or still has the old format).
8476
- name: Compare with base branch bundle
8577
if: success() && github.event.number
86-
run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
78+
run: node scripts/analyzeBundle.mjs compare
8779

8880
- name: Upload analysis comment
8981
uses: actions/upload-artifact@v4

package.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,6 @@
112112
"engines": {
113113
"node": ">=20.9.0"
114114
},
115-
"nextBundleAnalysis": {
116-
"budget": null,
117-
"budgetPercentIncreaseRed": 10,
118-
"showDetails": true
119-
},
120115
"lint-staged": {
121116
"*.{js,ts,jsx,tsx,css}": "yarn prettier",
122117
"src/**/*.md": "yarn fix-headings"

scripts/analyzeBundle.mjs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
/*
9+
* App Router-aware bundle-size analysis.
10+
*
11+
* Modeled on Next.js's own `next-stats-action`: sum the gzipped sizes of
12+
* groups of build-output files and compare a PR build against the base branch.
13+
* Unlike `nextjs-bundle-analysis`, this reads the real build output
14+
* (`build-manifest.json` + `.next/static`) rather than the Pages-Router-only
15+
* `build-manifest.json.pages`, which the App Router leaves empty.
16+
*
17+
* Usage:
18+
* node scripts/analyzeBundle.mjs report # writes the current build's stats
19+
* node scripts/analyzeBundle.mjs compare # diffs against the base-branch stats
20+
*
21+
* `report` -> .next/analyze/__bundle_analysis.json (uploaded as an artifact)
22+
* `compare` -> .next/analyze/__bundle_analysis_comment.txt (posted by
23+
* analyze_comment.yml). The base-branch artifact is expected under
24+
* .next/analyze/base/bundle/ (downloaded by analyze.yml).
25+
*/
26+
27+
import fs from 'fs';
28+
import path from 'path';
29+
import zlib from 'zlib';
30+
import {fileURLToPath} from 'url';
31+
32+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
33+
const root = path.resolve(__dirname, '..');
34+
const nextDir = path.join(root, '.next');
35+
const analyzeDir = path.join(nextDir, 'analyze');
36+
const statsFile = path.join(analyzeDir, '__bundle_analysis.json');
37+
const baseDir = path.join(analyzeDir, 'base', 'bundle');
38+
const commentFile = path.join(analyzeDir, '__bundle_analysis_comment.txt');
39+
40+
function walk(dir) {
41+
if (!fs.existsSync(dir)) return [];
42+
let out = [];
43+
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
44+
const p = path.join(dir, entry.name);
45+
if (entry.isDirectory()) out = out.concat(walk(p));
46+
else out.push(p);
47+
}
48+
return out;
49+
}
50+
51+
function sumGroup(files) {
52+
let raw = 0;
53+
let gzip = 0;
54+
for (const file of files) {
55+
const buf = fs.readFileSync(file);
56+
raw += buf.length;
57+
gzip += zlib.gzipSync(buf).length;
58+
}
59+
return {raw, gzip, count: files.length};
60+
}
61+
62+
function report() {
63+
const manifest = JSON.parse(
64+
fs.readFileSync(path.join(nextDir, 'build-manifest.json'), 'utf8')
65+
);
66+
// The shared bundle that loads on every page (App Router: rootMainFiles).
67+
const globalFiles = [
68+
...(manifest.rootMainFiles || []),
69+
...(manifest.polyfillFiles || []),
70+
]
71+
.map((f) => path.join(nextDir, f))
72+
.filter((f) => fs.existsSync(f));
73+
const jsFiles = walk(path.join(nextDir, 'static', 'chunks')).filter((f) =>
74+
f.endsWith('.js')
75+
);
76+
const cssFiles = walk(path.join(nextDir, 'static', 'css')).filter((f) =>
77+
f.endsWith('.css')
78+
);
79+
80+
const stats = {
81+
'Global (loads on every page)': sumGroup(globalFiles),
82+
'Total JS': sumGroup(jsFiles),
83+
'Total CSS': sumGroup(cssFiles),
84+
};
85+
86+
fs.mkdirSync(analyzeDir, {recursive: true});
87+
fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
88+
console.log('Wrote', path.relative(root, statsFile));
89+
for (const [name, v] of Object.entries(stats)) {
90+
console.log(` ${name}: ${formatBytes(v.gzip)} gzip (${v.count} files)`);
91+
}
92+
}
93+
94+
function formatBytes(bytes) {
95+
if (Math.abs(bytes) >= 1024 * 1024) {
96+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
97+
}
98+
return `${(bytes / 1024).toFixed(2)} KB`;
99+
}
100+
101+
function formatDelta(cur, base) {
102+
const d = cur - base;
103+
if (d === 0) return 'no change';
104+
return `${d > 0 ? '🔺 +' : '🟢 -'}${formatBytes(Math.abs(d))}`;
105+
}
106+
107+
// New format: every value is {raw, gzip, count}. The old nextjs-bundle-analysis
108+
// artifact was {"/_app": {raw, gzip}, "__global": {...}} with no `count`.
109+
function isNewFormat(obj) {
110+
const vals = obj && typeof obj === 'object' ? Object.values(obj) : [];
111+
return (
112+
vals.length > 0 &&
113+
vals.every((v) => v && typeof v.gzip === 'number' && typeof v.count === 'number')
114+
);
115+
}
116+
117+
function loadBaseStats() {
118+
if (!fs.existsSync(baseDir)) return null;
119+
const jsons = fs.readdirSync(baseDir).filter((f) => f.endsWith('.json'));
120+
if (jsons.length === 0) return null;
121+
try {
122+
return JSON.parse(fs.readFileSync(path.join(baseDir, jsons[0]), 'utf8'));
123+
} catch {
124+
return null;
125+
}
126+
}
127+
128+
function compare() {
129+
const cur = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
130+
const base = loadBaseStats();
131+
132+
let md;
133+
if (base && isNewFormat(base)) {
134+
md =
135+
'| Metric | Size (gzip) | Change vs base |\n|---|---|---|\n' +
136+
Object.entries(cur)
137+
.map(([name, v]) => {
138+
const b = base[name];
139+
const change = b ? formatDelta(v.gzip, b.gzip) : '— (new)';
140+
return `| ${name} | ${formatBytes(v.gzip)} | ${change} |`;
141+
})
142+
.join('\n') +
143+
'\n';
144+
} else {
145+
md =
146+
'_No comparable base-branch data yet — the base branch has not produced ' +
147+
'stats in this format. Showing current sizes only; deltas will appear on ' +
148+
'the next run after this lands on the base branch._\n\n' +
149+
'| Metric | Size (gzip) |\n|---|---|\n' +
150+
Object.entries(cur)
151+
.map(([name, v]) => `| ${name} | ${formatBytes(v.gzip)} |`)
152+
.join('\n') +
153+
'\n';
154+
}
155+
156+
fs.mkdirSync(analyzeDir, {recursive: true});
157+
fs.writeFileSync(commentFile, md);
158+
console.log(md);
159+
}
160+
161+
const mode = process.argv[2];
162+
if (mode === 'report') {
163+
report();
164+
} else if (mode === 'compare') {
165+
compare();
166+
} else {
167+
console.error('Usage: node scripts/analyzeBundle.mjs <report|compare>');
168+
process.exit(1);
169+
}

0 commit comments

Comments
 (0)