route-bundle-size 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/dist/cli.js +78 -0
- package/dist/config.js +82 -0
- package/dist/lib.js +264 -0
- package/dist/measure.js +51 -0
- package/dist/stats.js +56 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergey Patrakeev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,296 @@
|
|
|
1
|
-
#
|
|
1
|
+
# route-bundle-size
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Per-route first-load JS budget for Next.js Turbopack builds.
|
|
4
|
+
|
|
5
|
+
It reads the route stats that `next build` writes, gzips each route's first-load chunks, and compares a PR build with its merge-base build. It fails when a route grows past the budget.
|
|
6
|
+
|
|
7
|
+
- Sizes are per route, first-load JS only.
|
|
8
|
+
- The budget gates on gzip size by default. Raw bytes are also supported.
|
|
9
|
+
- The [example workflow](#github-actions) uses the PR's merge base as the baseline, so later commits on the base branch never count against the PR.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Next.js >= 16.2 with Turbopack. Turbopack is the default for `next build`.
|
|
14
|
+
- Node.js >= 22.12.
|
|
15
|
+
|
|
16
|
+
Next.js 16.2 and later writes `.next/diagnostics/route-bundle-stats.json` on every Turbopack production build. No flag or env var is needed. `next build --webpack` does not write this file, so webpack builds are not supported.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# At the baseline commit
|
|
22
|
+
npx next build
|
|
23
|
+
npx route-bundle-size measure --app-dir . --out /tmp/baseline.json
|
|
24
|
+
|
|
25
|
+
# At the commit you want to check
|
|
26
|
+
npx next build
|
|
27
|
+
npx route-bundle-size measure --app-dir . --out /tmp/current.json
|
|
28
|
+
|
|
29
|
+
npx route-bundle-size diff --current /tmp/current.json --baseline /tmp/baseline.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
In a monorepo, build the app and point `--app-dir` at it, for example `--app-dir apps/web`.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
### `measure`
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx route-bundle-size measure \
|
|
40
|
+
--app-dir <dir> \
|
|
41
|
+
--out <file> \
|
|
42
|
+
[--stats <file>]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `--app-dir` (required): the app root. Chunk paths in the stats file resolve against it.
|
|
46
|
+
- `--out` (required): where to write the snapshot JSON.
|
|
47
|
+
- `--stats`: path to the stats file. Default: `<app-dir>/.next/diagnostics/route-bundle-stats.json`.
|
|
48
|
+
|
|
49
|
+
`measure` gzips each unique chunk once, then sums the chunks of each route. It writes a snapshot:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{ "schemaVersion": 1, "routes": [{ "route": "/", "rawBytes": 65000, "compressedBytes": 49276, "chunkPaths": ["..."] }] }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It fails if a chunk file is missing under `--app-dir`. It also fails if a route reports first-load JS but lists no chunk paths. A missing chunk is never counted as zero.
|
|
56
|
+
|
|
57
|
+
Run `measure` right after each build. The next build overwrites `.next`.
|
|
58
|
+
|
|
59
|
+
### `diff`
|
|
60
|
+
|
|
61
|
+
`diff` is the default command, so `npx route-bundle-size --current ...` works too.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx route-bundle-size diff \
|
|
65
|
+
--current <file> \
|
|
66
|
+
[--baseline <file>] \
|
|
67
|
+
[--format terminal|markdown] \
|
|
68
|
+
[--config <file>] \
|
|
69
|
+
[--no-exclude]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `--current` (required): the snapshot to check.
|
|
73
|
+
- `--baseline`: the snapshot to compare with. If it is omitted or the file does not exist, `diff` prints the current sizes and exits `0`. Use this to bootstrap.
|
|
74
|
+
- `--format`: `terminal` (default) prints a table to stdout. `markdown` writes a GitHub-flavored table to the file in `$GITHUB_STEP_SUMMARY`, or to stdout when that variable is not set.
|
|
75
|
+
- `--config`: path to the config file. Default: `route-bundle-size.config.json` in the current directory, if it exists.
|
|
76
|
+
- `--no-exclude`: ignore the `exclude` patterns from the config.
|
|
77
|
+
|
|
78
|
+
Both inputs can be a `measure` snapshot or a raw `route-bundle-stats.json`. The tool detects the shape. A raw stats file has no gzip sizes, so with `metric: "compressed"` it is an error. Use `measure` snapshots, or set `metric` to `"raw"`.
|
|
79
|
+
|
|
80
|
+
`--no-exclude` helps when you look for shared-chunk growth. Pages that ship only shared chunks, such as `/_not-found`, all grow by the same amount when a shared chunk grows.
|
|
81
|
+
|
|
82
|
+
## Config
|
|
83
|
+
|
|
84
|
+
`route-bundle-size.config.json` in the current directory, or the file passed with `--config`. All keys are optional.
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"exclude": ["/api/**"],
|
|
89
|
+
"budget": {
|
|
90
|
+
"metric": "compressed",
|
|
91
|
+
"maxIncrease": "30kb",
|
|
92
|
+
"maxIncreasePercent": 10
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `exclude`: glob patterns ([picomatch](https://github.com/micromatch/picomatch)) matched against the route. Default `[]`.
|
|
98
|
+
- `budget.metric`: `"compressed"` (gzip, default) or `"raw"` (uncompressed bytes from the stats file).
|
|
99
|
+
- `budget.maxIncrease`: a byte count, or a string such as `"30kb"`, `"1.5mb"`, `"512b"`. Default `"30kb"`.
|
|
100
|
+
- `budget.maxIncreasePercent`: a non-negative number. Default `10`.
|
|
101
|
+
|
|
102
|
+
A route fails when its size grows by at least `maxIncrease` bytes or at least `maxIncreasePercent` percent, whichever comes first.
|
|
103
|
+
|
|
104
|
+
Units in the config use 1024: `"30kb"` is 30,720 bytes. Output uses 1000-byte kB, so `"30kb"` prints as `30.7 kB`.
|
|
105
|
+
|
|
106
|
+
Unknown keys are an error, so a typo such as `"excludes"` does not silently turn off filtering. A missing default config file means defaults are used. A missing file passed with `--config` is an error.
|
|
107
|
+
|
|
108
|
+
## Output
|
|
109
|
+
|
|
110
|
+
Terminal (`--format terminal`, the default):
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
┌───────────────┬──────────┬───────────┬──────────────────────┬──────────┐
|
|
114
|
+
│ Route │ Baseline │ Current │ Δ bytes │ Δ chunks │
|
|
115
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
116
|
+
│ /settings │ 185 kB │ (removed) │ -185 kB 🟢 │ -6 │
|
|
117
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
118
|
+
│ /onboarding │ (new) │ 173 kB │ +173 kB (new) │ +5 │
|
|
119
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
120
|
+
│ /dashboard │ 255 kB │ 292 kB │ +37.1 kB (+14.5%) 🔴 │ +1 │
|
|
121
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
122
|
+
│ /pricing │ 166 kB │ 148 kB │ -17.9 kB (-10.8%) 🟢 │ -1 │
|
|
123
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
124
|
+
│ / │ 161 kB │ 162 kB │ +470 B (+0.3%) │ — │
|
|
125
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
126
|
+
│ /blog/[slug] │ 157 kB │ 157 kB │ — │ — │
|
|
127
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
128
|
+
│ /_not-found │ 138 kB │ 138 kB │ — │ — │
|
|
129
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
130
|
+
│ TOTAL │ 1.06 MB │ 1.07 MB │ +7.17 kB (+0.7%) │ -1 │
|
|
131
|
+
├───────────────┼──────────┼───────────┼──────────────────────┼──────────┤
|
|
132
|
+
│ avg per route │ 177 kB │ 178 kB │ │ │
|
|
133
|
+
└───────────────┴──────────┴───────────┴──────────────────────┴──────────┘
|
|
134
|
+
🔴 exceeds +30.7 kB OR +10% compressed 🟢 notable improvement 7 route(s)
|
|
135
|
+
::error::Bundle size regression — see step summary
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Markdown (`--format markdown`):
|
|
139
|
+
|
|
140
|
+
```markdown
|
|
141
|
+
## 📦 Bundle size
|
|
142
|
+
|
|
143
|
+
| Route | First Load JS | Δ |
|
|
144
|
+
| -------------- | ------------- | -------------------- |
|
|
145
|
+
| `/settings` | — (removed) | -185 kB 🟢 |
|
|
146
|
+
| `/onboarding` | 173 kB | +173 kB (new) |
|
|
147
|
+
| `/dashboard` | 292 kB | +37.1 kB (+14.5%) 🔴 |
|
|
148
|
+
| `/pricing` | 148 kB | -17.9 kB (-10.8%) 🟢 |
|
|
149
|
+
| `/` | 162 kB | +470 B (+0.3%) |
|
|
150
|
+
| `/blog/[slug]` | 157 kB | — |
|
|
151
|
+
| `/_not-found` | 138 kB | — |
|
|
152
|
+
|
|
153
|
+
### Summary
|
|
154
|
+
|
|
155
|
+
- **Routes:** 7
|
|
156
|
+
- **Total:** 1.06 MB → 1.07 MB (+7.17 kB, +0.7%)
|
|
157
|
+
- **Avg per route:** 177 kB → 178 kB
|
|
158
|
+
- **Chunks:** 30 → 29 (-1)
|
|
159
|
+
|
|
160
|
+
🔴 exceeds `+30.7 kB` OR `+10%` compressed · 🟢 notable improvement (≥30.7 kB OR ≥10% smaller)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
How to read it:
|
|
164
|
+
|
|
165
|
+
- Sizes and deltas use the budget metric. Rows are sorted by the size of the change.
|
|
166
|
+
- 🔴 marks a route over budget. 🟢 marks a route that shrank by at least the budget.
|
|
167
|
+
- A change under 250 bytes shows as `—`.
|
|
168
|
+
- New routes are marked `(new)` and never fail the budget. Removed routes never fail either.
|
|
169
|
+
- The `::error::` line goes to stderr. GitHub Actions shows it as an annotation.
|
|
170
|
+
|
|
171
|
+
Without a baseline, `diff` prints the current routes only. This table always shows raw bytes, and it goes to the step summary or stdout for both formats:
|
|
172
|
+
|
|
173
|
+
```markdown
|
|
174
|
+
## 📦 Bundle size
|
|
175
|
+
|
|
176
|
+
Baseline not yet established. 6 routes captured.
|
|
177
|
+
|
|
178
|
+
| Route | First Load JS |
|
|
179
|
+
| -------------- | ------------- |
|
|
180
|
+
| `/dashboard` | 969 kB |
|
|
181
|
+
| `/onboarding` | 557 kB |
|
|
182
|
+
| `/` | 514 kB |
|
|
183
|
+
| `/blog/[slug]` | 499 kB |
|
|
184
|
+
| `/pricing` | 472 kB |
|
|
185
|
+
| `/_not-found` | 432 kB |
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Exit codes
|
|
189
|
+
|
|
190
|
+
- `0`: every route is within budget, or there is no baseline yet. A `--baseline` path that does not exist also exits `0`, so the first run passes.
|
|
191
|
+
- `1`: at least one route is over budget, or any error: bad config, unreadable input, missing chunk files, or raw stats used with `metric: "compressed"`.
|
|
192
|
+
|
|
193
|
+
## GitHub Actions
|
|
194
|
+
|
|
195
|
+
This workflow builds the PR head and its merge base in one job, then compares them. It writes the result to the job summary. It posts no PR comment and needs only `contents: read`, so it works for PRs from forks.
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
name: Bundle size
|
|
199
|
+
|
|
200
|
+
on:
|
|
201
|
+
pull_request:
|
|
202
|
+
|
|
203
|
+
permissions:
|
|
204
|
+
contents: read
|
|
205
|
+
|
|
206
|
+
jobs:
|
|
207
|
+
bundle-size:
|
|
208
|
+
runs-on: ubuntu-latest
|
|
209
|
+
timeout-minutes: 30
|
|
210
|
+
steps:
|
|
211
|
+
- uses: actions/checkout@v6
|
|
212
|
+
with:
|
|
213
|
+
ref: ${{ github.event.pull_request.head.sha }}
|
|
214
|
+
fetch-depth: 0
|
|
215
|
+
|
|
216
|
+
- uses: actions/setup-node@v6
|
|
217
|
+
with:
|
|
218
|
+
node-version: 22
|
|
219
|
+
cache: npm
|
|
220
|
+
|
|
221
|
+
# An empty sha would make "git checkout --detach" stay at HEAD,
|
|
222
|
+
# so both sides would measure the same build and always pass.
|
|
223
|
+
- name: Resolve merge base
|
|
224
|
+
id: merge_base
|
|
225
|
+
run: |
|
|
226
|
+
sha=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
|
227
|
+
if [ -z "$sha" ]; then
|
|
228
|
+
echo "::error::Could not resolve a merge base with origin/${{ github.base_ref }}."
|
|
229
|
+
exit 1
|
|
230
|
+
fi
|
|
231
|
+
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
|
232
|
+
echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
233
|
+
|
|
234
|
+
- name: Install dependencies
|
|
235
|
+
run: npm ci
|
|
236
|
+
|
|
237
|
+
- name: Build current
|
|
238
|
+
run: npx next build
|
|
239
|
+
|
|
240
|
+
- name: Measure current
|
|
241
|
+
run: npx --yes route-bundle-size@0.1.0 measure --app-dir . --out "$RUNNER_TEMP/current.json"
|
|
242
|
+
|
|
243
|
+
- name: Check out merge base
|
|
244
|
+
run: git checkout --detach ${{ steps.merge_base.outputs.sha }}
|
|
245
|
+
|
|
246
|
+
- name: Install dependencies at merge base
|
|
247
|
+
run: npm ci
|
|
248
|
+
|
|
249
|
+
- name: Build baseline
|
|
250
|
+
run: npx next build
|
|
251
|
+
|
|
252
|
+
- name: Return to PR head
|
|
253
|
+
run: git checkout --detach ${{ steps.merge_base.outputs.head }}
|
|
254
|
+
|
|
255
|
+
# .next is gitignored, so the baseline build survives the checkout.
|
|
256
|
+
# Running diff at the PR head uses the PR's config file.
|
|
257
|
+
- name: Measure baseline
|
|
258
|
+
run: npx --yes route-bundle-size@0.1.0 measure --app-dir . --out "$RUNNER_TEMP/baseline.json"
|
|
259
|
+
|
|
260
|
+
- name: Compare
|
|
261
|
+
run: |
|
|
262
|
+
npx --yes route-bundle-size@0.1.0 diff \
|
|
263
|
+
--current "$RUNNER_TEMP/current.json" \
|
|
264
|
+
--baseline "$RUNNER_TEMP/baseline.json" \
|
|
265
|
+
--format markdown
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Notes:
|
|
269
|
+
|
|
270
|
+
- Pin the tool version. Both snapshots are then made by the same version.
|
|
271
|
+
- The tool runs through `npx`, so it does not need the project's `node_modules`. No reinstall is needed after returning to the PR head.
|
|
272
|
+
- If the merge-base build fails, the job fails. It does not pass quietly.
|
|
273
|
+
- GitHub shows the job summary even when the step exits `1`.
|
|
274
|
+
- The cost is a second build in each PR run.
|
|
275
|
+
- If your repo tracks `next-env.d.ts`, `next build` rewrites it and the checkout fails. Run `git checkout HEAD -- next-env.d.ts` before each `git checkout --detach`. Projects made with `create-next-app` ignore this file.
|
|
276
|
+
|
|
277
|
+
## Limitations
|
|
278
|
+
|
|
279
|
+
- The stats file `route-bundle-stats.json` has no official docs and no stability promise. A Next.js release can change it, so this package stays on 0.x.
|
|
280
|
+
- Turbopack only. `next build --webpack` writes no stats file.
|
|
281
|
+
- The default `--stats` path assumes the default `distDir` (`.next`). With a custom `distDir`, pass `--stats <app-dir>/<distDir>/diagnostics/route-bundle-stats.json`.
|
|
282
|
+
- Only first-load JS is measured.
|
|
283
|
+
- One budget applies to every route. There are no per-route budgets.
|
|
284
|
+
|
|
285
|
+
## Development
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
npm test
|
|
289
|
+
npm run types
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The fixture `fixtures/sample-build/diagnostics/route-bundle-stats.json` is the parser contract. If Next.js changes the stats format, update `parseRawStats` in `src/stats.ts` and the fixture together.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { Command, Option } from "commander";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { assertComparable, buildRows, buildSummary, FORMATS, loadSizes, renderBootstrap, renderMarkdown, renderTerminal, } from "./lib.js";
|
|
6
|
+
import { defaultStatsFile, measure, writeSnapshot } from "./measure.js";
|
|
7
|
+
const writeSummary = (markdown) => {
|
|
8
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
9
|
+
if (summaryPath !== undefined) {
|
|
10
|
+
fs.appendFileSync(summaryPath, markdown + "\n");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
console.log(markdown);
|
|
14
|
+
};
|
|
15
|
+
const diffCommand = new Command("diff")
|
|
16
|
+
.description("Diff route bundle sizes against a baseline; render markdown or terminal table; exit 1 on exceeded.")
|
|
17
|
+
.requiredOption("--current <file>", "Path to the PR's snapshot or route-bundle-stats.json")
|
|
18
|
+
.option("--baseline <file>", "Path to main's snapshot or route-bundle-stats.json (bootstrap mode if absent)")
|
|
19
|
+
.addOption(new Option("--format <format>", "Output format. 'terminal' (default) renders an aligned cli-table3 view for local debugging; 'markdown' writes a GitHub-flavored table to $GITHUB_STEP_SUMMARY and is the CI-only mode.")
|
|
20
|
+
.choices([...FORMATS])
|
|
21
|
+
.default("terminal"))
|
|
22
|
+
.option("--config <file>", "Path to route-bundle-size.config.json (default: the one in the current directory, if any)")
|
|
23
|
+
.option("--no-exclude", "Ignore the configured exclude patterns. Useful in diagnostic mode — bare pages surface pure shared-chunk drift.")
|
|
24
|
+
.action((options) => {
|
|
25
|
+
const config = loadConfig({ configPath: options.config });
|
|
26
|
+
const exclude = options.exclude ? config.exclude : [];
|
|
27
|
+
const budget = config.budget;
|
|
28
|
+
const current = loadSizes(options.current, exclude);
|
|
29
|
+
if (options.baseline === undefined || !fs.existsSync(options.baseline)) {
|
|
30
|
+
writeSummary(renderBootstrap(current.sizes));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const baseline = loadSizes(options.baseline, exclude);
|
|
34
|
+
assertComparable(current, baseline, budget.metric);
|
|
35
|
+
const { rows, anyExceeded } = buildRows(current.sizes, baseline.sizes, budget);
|
|
36
|
+
const summary = buildSummary(rows, budget.metric);
|
|
37
|
+
const output = options.format === "terminal"
|
|
38
|
+
? renderTerminal(rows, summary, budget)
|
|
39
|
+
: renderMarkdown(rows, summary, budget);
|
|
40
|
+
if (options.format === "terminal") {
|
|
41
|
+
console.log(output);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
writeSummary(output);
|
|
45
|
+
}
|
|
46
|
+
if (anyExceeded) {
|
|
47
|
+
console.error("::error::Bundle size regression — see step summary");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const measureCommand = new Command("measure")
|
|
52
|
+
.description("Gzip every unique first-load chunk once and write a snapshot with raw + compressed bytes per route.")
|
|
53
|
+
.requiredOption("--app-dir <dir>", "App root that the chunk paths in the stats file resolve against (the directory that contains .next)")
|
|
54
|
+
.option("--stats <file>", "Path to route-bundle-stats.json (default: <app-dir>/.next/diagnostics/route-bundle-stats.json)")
|
|
55
|
+
.requiredOption("--out <file>", "Path to write the snapshot JSON to")
|
|
56
|
+
.action((options) => {
|
|
57
|
+
var _a;
|
|
58
|
+
const statsFile = (_a = options.stats) !== null && _a !== void 0 ? _a : defaultStatsFile(options.appDir);
|
|
59
|
+
const { snapshot, uniqueChunkCount } = measure({
|
|
60
|
+
appDir: options.appDir,
|
|
61
|
+
statsFile,
|
|
62
|
+
});
|
|
63
|
+
writeSnapshot(options.out, snapshot);
|
|
64
|
+
console.log(`Wrote ${options.out}: ${snapshot.routes.length} route(s), ${uniqueChunkCount} unique chunk(s)`);
|
|
65
|
+
});
|
|
66
|
+
const program = new Command();
|
|
67
|
+
program
|
|
68
|
+
.name("route-bundle-size")
|
|
69
|
+
.description("Per-route bundle size budget for Next.js builds: measure compressed sizes, diff against a baseline, exit 1 on regression.");
|
|
70
|
+
program.addCommand(diffCommand, { isDefault: true });
|
|
71
|
+
program.addCommand(measureCommand);
|
|
72
|
+
try {
|
|
73
|
+
program.parse();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const CONFIG_FILE_NAME = "route-bundle-size.config.json";
|
|
4
|
+
export const METRICS = ["raw", "compressed"];
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
exclude: [],
|
|
7
|
+
budget: {
|
|
8
|
+
metric: "compressed",
|
|
9
|
+
maxIncrease: 30 * 1024,
|
|
10
|
+
maxIncreasePercent: 10,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
const BYTE_UNITS = { b: 1, kb: 1024, mb: 1024 * 1024 };
|
|
14
|
+
const BYTE_SIZE_PATTERN = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb)?\s*$/i;
|
|
15
|
+
export const parseByteSize = (value, field) => {
|
|
16
|
+
var _a;
|
|
17
|
+
const numeric = typeof value === "number" ? value : Number.NaN;
|
|
18
|
+
const match = typeof value === "string" ? BYTE_SIZE_PATTERN.exec(value) : null;
|
|
19
|
+
const rounded = Math.round(match === null
|
|
20
|
+
? numeric
|
|
21
|
+
: Number(match[1]) * BYTE_UNITS[((_a = match[2]) !== null && _a !== void 0 ? _a : "b").toLowerCase()]);
|
|
22
|
+
if (!Number.isSafeInteger(rounded) || rounded < 0) {
|
|
23
|
+
throw new Error(`Invalid ${field}: ${JSON.stringify(value)}. Expected a byte count or a size string like "30kb", "1.5mb", "512b".`);
|
|
24
|
+
}
|
|
25
|
+
return rounded;
|
|
26
|
+
};
|
|
27
|
+
const asObject = (value, label) => {
|
|
28
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
29
|
+
throw new Error(`Invalid ${label}: expected a JSON object.`);
|
|
30
|
+
}
|
|
31
|
+
return Object.assign({}, value);
|
|
32
|
+
};
|
|
33
|
+
const assertKnownKeys = (value, validKeys, where) => {
|
|
34
|
+
for (const key of Object.keys(value)) {
|
|
35
|
+
if (!validKeys.includes(key)) {
|
|
36
|
+
throw new Error(`Unknown key "${key}" in ${where}. Valid keys: ${validKeys.join(", ")}.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
export const parseConfig = (value, source) => {
|
|
41
|
+
const raw = asObject(value, `config in ${source}`);
|
|
42
|
+
assertKnownKeys(raw, Object.keys(DEFAULT_CONFIG), source);
|
|
43
|
+
const { exclude, budget: budgetValue } = Object.assign(Object.assign({}, DEFAULT_CONFIG), raw);
|
|
44
|
+
if (!Array.isArray(exclude) ||
|
|
45
|
+
!exclude.every((item) => typeof item === "string")) {
|
|
46
|
+
throw new Error(`Invalid "exclude" in ${source}: expected an array of glob patterns.`);
|
|
47
|
+
}
|
|
48
|
+
const budgetSource = `"budget" in ${source}`;
|
|
49
|
+
const budgetRaw = asObject(budgetValue, budgetSource);
|
|
50
|
+
assertKnownKeys(budgetRaw, Object.keys(DEFAULT_CONFIG.budget), budgetSource);
|
|
51
|
+
const { metric: metricValue, maxIncrease: maxIncreaseValue, maxIncreasePercent, } = Object.assign(Object.assign({}, DEFAULT_CONFIG.budget), budgetRaw);
|
|
52
|
+
const metric = METRICS.find((known) => known === metricValue);
|
|
53
|
+
if (metric === undefined) {
|
|
54
|
+
throw new Error(`Invalid "budget.metric" in ${source}: ${JSON.stringify(metricValue)}. Valid values: ${METRICS.join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof maxIncreasePercent !== "number" ||
|
|
57
|
+
!Number.isFinite(maxIncreasePercent) ||
|
|
58
|
+
maxIncreasePercent < 0) {
|
|
59
|
+
throw new Error(`Invalid "budget.maxIncreasePercent" in ${source}: ${JSON.stringify(maxIncreasePercent)}. Expected a non-negative number.`);
|
|
60
|
+
}
|
|
61
|
+
const maxIncrease = parseByteSize(maxIncreaseValue, `"budget.maxIncrease" in ${source}`);
|
|
62
|
+
return { exclude, budget: { metric, maxIncrease, maxIncreasePercent } };
|
|
63
|
+
};
|
|
64
|
+
export const loadConfig = (options) => {
|
|
65
|
+
var _a, _b;
|
|
66
|
+
const cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
|
|
67
|
+
const filePath = path.resolve(cwd, (_b = options.configPath) !== null && _b !== void 0 ? _b : CONFIG_FILE_NAME);
|
|
68
|
+
if (!fs.existsSync(filePath)) {
|
|
69
|
+
if (options.configPath === undefined) {
|
|
70
|
+
return DEFAULT_CONFIG;
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Config file not found: ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
return parseConfig(parsed, filePath);
|
|
82
|
+
};
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import Table from "cli-table3";
|
|
3
|
+
import { markdownTable } from "markdown-table";
|
|
4
|
+
import picomatch from "picomatch";
|
|
5
|
+
import prettyBytes from "pretty-bytes";
|
|
6
|
+
import { parseRawStats, parseSnapshot, readJsonFile } from "./stats.js";
|
|
7
|
+
export const FORMATS = ["markdown", "terminal"];
|
|
8
|
+
export const MIN_DISPLAY = 250;
|
|
9
|
+
const routeMatcher = (exclude) => picomatch(exclude, { dot: true });
|
|
10
|
+
export const isExcluded = (route, exclude) => routeMatcher(exclude)(route);
|
|
11
|
+
export const loadSizes = (filePath, exclude) => {
|
|
12
|
+
var _a, _b;
|
|
13
|
+
if (!fs.existsSync(filePath)) {
|
|
14
|
+
throw new Error(`Input file not found: ${filePath}. Expected a "measure" snapshot or a route-bundle-stats.json (a Turbopack production build on Next.js >= 16.2 writes one).`);
|
|
15
|
+
}
|
|
16
|
+
const data = readJsonFile(filePath);
|
|
17
|
+
const excluded = routeMatcher(exclude);
|
|
18
|
+
const sizes = new Map();
|
|
19
|
+
if (Array.isArray(data)) {
|
|
20
|
+
for (const entry of parseRawStats(data, filePath)) {
|
|
21
|
+
if (excluded(entry.route)) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
sizes.set(entry.route, {
|
|
25
|
+
bytes: entry.firstLoadUncompressedJsBytes,
|
|
26
|
+
chunks: (_b = (_a = entry.firstLoadChunkPaths) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0,
|
|
27
|
+
compressedBytes: null,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return { compressed: false, sizes };
|
|
31
|
+
}
|
|
32
|
+
if (typeof data !== "object" || data === null || !("schemaVersion" in data)) {
|
|
33
|
+
throw new Error(`Unrecognized input ${filePath}: expected a route-bundle-stats.json array or a route-bundle-size snapshot with "schemaVersion".`);
|
|
34
|
+
}
|
|
35
|
+
for (const entry of parseSnapshot(data, filePath).routes) {
|
|
36
|
+
if (excluded(entry.route)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
sizes.set(entry.route, {
|
|
40
|
+
bytes: entry.rawBytes,
|
|
41
|
+
chunks: entry.chunkPaths.length,
|
|
42
|
+
compressedBytes: entry.compressedBytes,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return { compressed: true, sizes };
|
|
46
|
+
};
|
|
47
|
+
export const hasCompressedData = (loaded) => loaded.compressed;
|
|
48
|
+
export const assertComparable = (current, baseline, metric) => {
|
|
49
|
+
if (metric !== "compressed") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const uncompressed = [
|
|
53
|
+
hasCompressedData(current) ? null : "current",
|
|
54
|
+
hasCompressedData(baseline) ? null : "baseline",
|
|
55
|
+
].filter((side) => side !== null);
|
|
56
|
+
if (uncompressed.length > 0) {
|
|
57
|
+
throw new Error(`Budget metric is "compressed" but ${uncompressed.join(" and ")} ${uncompressed.length > 1 ? "have" : "has"} no compressed data. Produce snapshots with the "measure" command, or set "budget.metric" to "raw" in the config.`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const metricBytes = (stats, metric) => { var _a; return metric === "compressed" ? ((_a = stats.compressedBytes) !== null && _a !== void 0 ? _a : 0) : stats.bytes; };
|
|
61
|
+
export const buildRows = (current, baseline, budget) => {
|
|
62
|
+
var _a, _b, _c, _d;
|
|
63
|
+
const allRoutes = new Set([...current.keys(), ...baseline.keys()]);
|
|
64
|
+
const rows = [];
|
|
65
|
+
let anyExceeded = false;
|
|
66
|
+
for (const route of allRoutes) {
|
|
67
|
+
const cur = (_a = current.get(route)) !== null && _a !== void 0 ? _a : null;
|
|
68
|
+
const base = (_b = baseline.get(route)) !== null && _b !== void 0 ? _b : null;
|
|
69
|
+
const currentBytes = cur === null ? 0 : metricBytes(cur, budget.metric);
|
|
70
|
+
const baselineBytes = base === null ? 0 : metricBytes(base, budget.metric);
|
|
71
|
+
const delta = currentBytes - baselineBytes;
|
|
72
|
+
const deltaPercent = base !== null && baselineBytes > 0 ? (delta / baselineBytes) * 100 : null;
|
|
73
|
+
const chunkDelta = ((_c = cur === null || cur === void 0 ? void 0 : cur.chunks) !== null && _c !== void 0 ? _c : 0) - ((_d = base === null || base === void 0 ? void 0 : base.chunks) !== null && _d !== void 0 ? _d : 0);
|
|
74
|
+
// New routes (no baseline) never exceeded — a fresh page legitimately ships
|
|
75
|
+
// its full size as "delta", which would otherwise always trip the abs rule.
|
|
76
|
+
const exceeded = base !== null &&
|
|
77
|
+
delta > 0 &&
|
|
78
|
+
(delta >= budget.maxIncrease ||
|
|
79
|
+
(deltaPercent !== null && deltaPercent >= budget.maxIncreasePercent));
|
|
80
|
+
if (exceeded) {
|
|
81
|
+
anyExceeded = true;
|
|
82
|
+
}
|
|
83
|
+
rows.push({
|
|
84
|
+
route,
|
|
85
|
+
current: cur,
|
|
86
|
+
baseline: base,
|
|
87
|
+
delta,
|
|
88
|
+
deltaPercent,
|
|
89
|
+
chunkDelta,
|
|
90
|
+
exceeded,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
rows.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
94
|
+
return { rows, anyExceeded };
|
|
95
|
+
};
|
|
96
|
+
export const buildSummary = (rows, metric) => {
|
|
97
|
+
let totalCurrent = 0;
|
|
98
|
+
let totalBaseline = 0;
|
|
99
|
+
let chunksCurrent = 0;
|
|
100
|
+
let chunksBaseline = 0;
|
|
101
|
+
let routesCurrent = 0;
|
|
102
|
+
let routesBaseline = 0;
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
if (row.current !== null) {
|
|
105
|
+
totalCurrent += metricBytes(row.current, metric);
|
|
106
|
+
chunksCurrent += row.current.chunks;
|
|
107
|
+
routesCurrent += 1;
|
|
108
|
+
}
|
|
109
|
+
if (row.baseline !== null) {
|
|
110
|
+
totalBaseline += metricBytes(row.baseline, metric);
|
|
111
|
+
chunksBaseline += row.baseline.chunks;
|
|
112
|
+
routesBaseline += 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const totalDelta = totalCurrent - totalBaseline;
|
|
116
|
+
const totalDeltaPercent = totalBaseline > 0 ? (totalDelta / totalBaseline) * 100 : null;
|
|
117
|
+
return {
|
|
118
|
+
totalRoutes: rows.length,
|
|
119
|
+
routesCurrent,
|
|
120
|
+
routesBaseline,
|
|
121
|
+
totalCurrent,
|
|
122
|
+
totalBaseline,
|
|
123
|
+
totalDelta,
|
|
124
|
+
totalDeltaPercent,
|
|
125
|
+
avgCurrent: routesCurrent > 0 ? totalCurrent / routesCurrent : 0,
|
|
126
|
+
avgBaseline: routesBaseline > 0 ? totalBaseline / routesBaseline : 0,
|
|
127
|
+
chunksCurrent,
|
|
128
|
+
chunksBaseline,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
export const isNotableShrink = (row, budget) => row.delta < 0 &&
|
|
132
|
+
(row.delta <= -budget.maxIncrease ||
|
|
133
|
+
(row.deltaPercent !== null &&
|
|
134
|
+
row.deltaPercent <= -budget.maxIncreasePercent));
|
|
135
|
+
export const formatDelta = (row, budget) => {
|
|
136
|
+
// Sub-threshold deltas (incl. zero) are visual noise — show as "no change".
|
|
137
|
+
if (Math.abs(row.delta) < MIN_DISPLAY) {
|
|
138
|
+
return "—";
|
|
139
|
+
}
|
|
140
|
+
const sign = row.delta > 0 ? "+" : "";
|
|
141
|
+
const marker = row.exceeded
|
|
142
|
+
? " 🔴"
|
|
143
|
+
: isNotableShrink(row, budget)
|
|
144
|
+
? " 🟢"
|
|
145
|
+
: "";
|
|
146
|
+
// Removed: the size column already says "(removed)"; -100% is noise.
|
|
147
|
+
if (row.current === null) {
|
|
148
|
+
return `${sign}${prettyBytes(row.delta)}${marker}`;
|
|
149
|
+
}
|
|
150
|
+
const percentLabel = row.deltaPercent === null
|
|
151
|
+
? "new"
|
|
152
|
+
: `${sign}${row.deltaPercent.toFixed(1)}%`;
|
|
153
|
+
return `${sign}${prettyBytes(row.delta)} (${percentLabel})${marker}`;
|
|
154
|
+
};
|
|
155
|
+
export const formatSignedBytes = (bytes) => {
|
|
156
|
+
if (bytes === 0)
|
|
157
|
+
return "—";
|
|
158
|
+
const sign = bytes > 0 ? "+" : "";
|
|
159
|
+
return `${sign}${prettyBytes(bytes)}`;
|
|
160
|
+
};
|
|
161
|
+
export const formatPercent = (pct) => {
|
|
162
|
+
if (pct === null)
|
|
163
|
+
return "—";
|
|
164
|
+
const sign = pct > 0 ? "+" : "";
|
|
165
|
+
return `${sign}${pct.toFixed(1)}%`;
|
|
166
|
+
};
|
|
167
|
+
export const formatSignedInt = (n) => {
|
|
168
|
+
if (n === 0)
|
|
169
|
+
return "—";
|
|
170
|
+
return n > 0 ? `+${n}` : `${n}`;
|
|
171
|
+
};
|
|
172
|
+
const budgetLegendSuffix = (budget) => budget.metric === "compressed" ? " compressed" : "";
|
|
173
|
+
export const renderMarkdown = (rows, summary, budget) => {
|
|
174
|
+
if (rows.length === 0) {
|
|
175
|
+
return "## 📦 Bundle size\n\nNo client routes detected.";
|
|
176
|
+
}
|
|
177
|
+
const table = markdownTable([
|
|
178
|
+
["Route", "First Load JS", "Δ"],
|
|
179
|
+
...rows.map((r) => [
|
|
180
|
+
`\`${r.route}\``,
|
|
181
|
+
r.current === null
|
|
182
|
+
? "— (removed)"
|
|
183
|
+
: prettyBytes(metricBytes(r.current, budget.metric)),
|
|
184
|
+
formatDelta(r, budget),
|
|
185
|
+
]),
|
|
186
|
+
]);
|
|
187
|
+
const summaryLines = [
|
|
188
|
+
"",
|
|
189
|
+
"### Summary",
|
|
190
|
+
"",
|
|
191
|
+
`- **Routes:** ${summary.totalRoutes}`,
|
|
192
|
+
`- **Total:** ${prettyBytes(summary.totalBaseline)} → ${prettyBytes(summary.totalCurrent)} (${formatSignedBytes(summary.totalDelta)}, ${formatPercent(summary.totalDeltaPercent)})`,
|
|
193
|
+
`- **Avg per route:** ${prettyBytes(summary.avgBaseline)} → ${prettyBytes(summary.avgCurrent)}`,
|
|
194
|
+
`- **Chunks:** ${summary.chunksBaseline} → ${summary.chunksCurrent} (${formatSignedInt(summary.chunksCurrent - summary.chunksBaseline)})`,
|
|
195
|
+
];
|
|
196
|
+
return [
|
|
197
|
+
"## 📦 Bundle size",
|
|
198
|
+
"",
|
|
199
|
+
table,
|
|
200
|
+
...summaryLines,
|
|
201
|
+
"",
|
|
202
|
+
`🔴 exceeds \`+${prettyBytes(budget.maxIncrease)}\` OR \`+${budget.maxIncreasePercent}%\`${budgetLegendSuffix(budget)} · 🟢 notable improvement (≥${prettyBytes(budget.maxIncrease)} OR ≥${budget.maxIncreasePercent}% smaller)`,
|
|
203
|
+
].join("\n");
|
|
204
|
+
};
|
|
205
|
+
export const renderTerminal = (rows, summary, budget) => {
|
|
206
|
+
if (rows.length === 0) {
|
|
207
|
+
return "📦 Bundle size: no client routes detected.";
|
|
208
|
+
}
|
|
209
|
+
const table = new Table({
|
|
210
|
+
head: ["Route", "Baseline", "Current", "Δ bytes", "Δ chunks"],
|
|
211
|
+
colAligns: ["left", "right", "right", "right", "right"],
|
|
212
|
+
style: { head: [], border: [] },
|
|
213
|
+
});
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
table.push([
|
|
216
|
+
row.route,
|
|
217
|
+
row.baseline === null
|
|
218
|
+
? "(new)"
|
|
219
|
+
: prettyBytes(metricBytes(row.baseline, budget.metric)),
|
|
220
|
+
row.current === null
|
|
221
|
+
? "(removed)"
|
|
222
|
+
: prettyBytes(metricBytes(row.current, budget.metric)),
|
|
223
|
+
formatDelta(row, budget),
|
|
224
|
+
row.chunkDelta === 0
|
|
225
|
+
? "—"
|
|
226
|
+
: `${row.chunkDelta > 0 ? "+" : ""}${row.chunkDelta}`,
|
|
227
|
+
]);
|
|
228
|
+
}
|
|
229
|
+
table.push([
|
|
230
|
+
{ content: "TOTAL", hAlign: "left" },
|
|
231
|
+
prettyBytes(summary.totalBaseline),
|
|
232
|
+
prettyBytes(summary.totalCurrent),
|
|
233
|
+
`${formatSignedBytes(summary.totalDelta)} (${formatPercent(summary.totalDeltaPercent)})`,
|
|
234
|
+
formatSignedInt(summary.chunksCurrent - summary.chunksBaseline),
|
|
235
|
+
]);
|
|
236
|
+
table.push([
|
|
237
|
+
{ content: "avg per route", hAlign: "left" },
|
|
238
|
+
prettyBytes(summary.avgBaseline),
|
|
239
|
+
prettyBytes(summary.avgCurrent),
|
|
240
|
+
"",
|
|
241
|
+
"",
|
|
242
|
+
]);
|
|
243
|
+
return [
|
|
244
|
+
table.toString(),
|
|
245
|
+
`🔴 exceeds +${prettyBytes(budget.maxIncrease)} OR +${budget.maxIncreasePercent}%${budgetLegendSuffix(budget)} 🟢 notable improvement ${summary.totalRoutes} route(s)`,
|
|
246
|
+
].join("\n");
|
|
247
|
+
};
|
|
248
|
+
export const renderBootstrap = (sizes) => {
|
|
249
|
+
const sorted = [...sizes.entries()].sort((a, b) => b[1].bytes - a[1].bytes);
|
|
250
|
+
const table = markdownTable([
|
|
251
|
+
["Route", "First Load JS"],
|
|
252
|
+
...sorted.map(([route, stats]) => [
|
|
253
|
+
`\`${route}\``,
|
|
254
|
+
prettyBytes(stats.bytes),
|
|
255
|
+
]),
|
|
256
|
+
]);
|
|
257
|
+
return [
|
|
258
|
+
"## 📦 Bundle size",
|
|
259
|
+
"",
|
|
260
|
+
`Baseline not yet established. ${sizes.size} routes captured.`,
|
|
261
|
+
"",
|
|
262
|
+
table,
|
|
263
|
+
].join("\n");
|
|
264
|
+
};
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as zlib from "node:zlib";
|
|
4
|
+
import { readRawStats, SNAPSHOT_SCHEMA_VERSION } from "./stats.js";
|
|
5
|
+
export const defaultStatsFile = (appDir) => path.join(appDir, ".next", "diagnostics", "route-bundle-stats.json");
|
|
6
|
+
const MISSING_CHUNKS_SHOWN = 5;
|
|
7
|
+
export const measure = (options) => {
|
|
8
|
+
const entries = readRawStats(options.statsFile);
|
|
9
|
+
const sizedWithoutChunks = entries.filter((entry) => {
|
|
10
|
+
var _a;
|
|
11
|
+
return entry.firstLoadUncompressedJsBytes > 0 &&
|
|
12
|
+
((_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : []).length === 0;
|
|
13
|
+
});
|
|
14
|
+
if (sizedWithoutChunks.length > 0) {
|
|
15
|
+
throw new Error(`${sizedWithoutChunks.length} route(s) in ${options.statsFile} report first-load JS but list no "firstLoadChunkPaths": ${sizedWithoutChunks.map((entry) => entry.route).join(", ")}. Their compressed size would be recorded as 0 and the snapshot would understate their size, so the budget could not see them grow.`);
|
|
16
|
+
}
|
|
17
|
+
const uniqueChunks = [
|
|
18
|
+
...new Set(entries.flatMap((entry) => { var _a; return (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : []; })),
|
|
19
|
+
];
|
|
20
|
+
const missing = uniqueChunks.filter((chunk) => !fs.existsSync(path.resolve(options.appDir, chunk)));
|
|
21
|
+
if (missing.length > 0) {
|
|
22
|
+
const shown = missing.slice(0, MISSING_CHUNKS_SHOWN).join(", ");
|
|
23
|
+
const rest = missing.length > MISSING_CHUNKS_SHOWN
|
|
24
|
+
? ` (and ${missing.length - MISSING_CHUNKS_SHOWN} more)`
|
|
25
|
+
: "";
|
|
26
|
+
throw new Error(`${missing.length} chunk file(s) referenced by ${options.statsFile} are missing under --app-dir ${options.appDir}: ${shown}${rest}. Point --app-dir at the app root that the stats paths resolve against.`);
|
|
27
|
+
}
|
|
28
|
+
const compressedByChunk = new Map();
|
|
29
|
+
for (const chunk of uniqueChunks) {
|
|
30
|
+
compressedByChunk.set(chunk, zlib.gzipSync(fs.readFileSync(path.resolve(options.appDir, chunk)))
|
|
31
|
+
.byteLength);
|
|
32
|
+
}
|
|
33
|
+
const snapshot = {
|
|
34
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
35
|
+
routes: entries.map((entry) => {
|
|
36
|
+
var _a;
|
|
37
|
+
const chunks = (_a = entry.firstLoadChunkPaths) !== null && _a !== void 0 ? _a : [];
|
|
38
|
+
return {
|
|
39
|
+
route: entry.route,
|
|
40
|
+
rawBytes: entry.firstLoadUncompressedJsBytes,
|
|
41
|
+
compressedBytes: chunks.reduce((total, chunk) => { var _a; return total + ((_a = compressedByChunk.get(chunk)) !== null && _a !== void 0 ? _a : 0); }, 0),
|
|
42
|
+
chunkPaths: chunks,
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
return { snapshot, uniqueChunkCount: uniqueChunks.length };
|
|
47
|
+
};
|
|
48
|
+
export const writeSnapshot = (filePath, snapshot) => {
|
|
49
|
+
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
|
|
50
|
+
fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
51
|
+
};
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
export const SNAPSHOT_SCHEMA_VERSION = 1;
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
5
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
6
|
+
export const parseRawStats = (value, source) => {
|
|
7
|
+
if (!Array.isArray(value)) {
|
|
8
|
+
throw new Error(`Invalid stats file ${source}: expected a top-level array of route entries.`);
|
|
9
|
+
}
|
|
10
|
+
return value.map((entry, index) => {
|
|
11
|
+
const { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
|
|
12
|
+
if (typeof route !== "string" ||
|
|
13
|
+
!isFiniteNumber(firstLoadUncompressedJsBytes)) {
|
|
14
|
+
throw new Error(`Invalid stats file ${source}: entry ${index} must have a string "route" and a finite "firstLoadUncompressedJsBytes".`);
|
|
15
|
+
}
|
|
16
|
+
return { route, firstLoadUncompressedJsBytes, firstLoadChunkPaths };
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
export const readJsonFile = (filePath) => {
|
|
20
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
export const readRawStats = (filePath) => {
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
throw new Error(`Stats file not found: ${filePath}. A Turbopack production build on Next.js >= 16.2 writes this file.`);
|
|
31
|
+
}
|
|
32
|
+
return parseRawStats(readJsonFile(filePath), filePath);
|
|
33
|
+
};
|
|
34
|
+
export const parseSnapshot = (value, source) => {
|
|
35
|
+
if (!isRecord(value)) {
|
|
36
|
+
throw new Error(`Invalid snapshot ${source}: expected a JSON object.`);
|
|
37
|
+
}
|
|
38
|
+
if (value.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
39
|
+
throw new Error(`Unsupported snapshot schemaVersion in ${source}: ${JSON.stringify(value.schemaVersion)}. Expected ${SNAPSHOT_SCHEMA_VERSION}.`);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(value.routes)) {
|
|
42
|
+
throw new Error(`Invalid snapshot ${source}: "routes" must be an array.`);
|
|
43
|
+
}
|
|
44
|
+
const routes = value.routes.map((entry, index) => {
|
|
45
|
+
const { route, rawBytes, compressedBytes, chunkPaths } = (entry !== null && entry !== void 0 ? entry : {});
|
|
46
|
+
if (typeof route !== "string" ||
|
|
47
|
+
!isFiniteNumber(rawBytes) ||
|
|
48
|
+
!isStringArray(chunkPaths) ||
|
|
49
|
+
!isFiniteNumber(compressedBytes) ||
|
|
50
|
+
compressedBytes < 0) {
|
|
51
|
+
throw new Error(`Invalid snapshot ${source}: route ${index} must have a string "route", a finite "rawBytes", a "chunkPaths" array of strings, and a non-negative "compressedBytes".`);
|
|
52
|
+
}
|
|
53
|
+
return { route, rawBytes, compressedBytes, chunkPaths };
|
|
54
|
+
});
|
|
55
|
+
return { schemaVersion: SNAPSHOT_SCHEMA_VERSION, routes };
|
|
56
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://www.schemastore.org/package.json",
|
|
2
3
|
"name": "route-bundle-size",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Per-route bundle size budget for Next.js builds: measure compressed sizes, diff against a baseline, exit 1 on regression.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"nextjs",
|
|
8
|
+
"turbopack",
|
|
9
|
+
"bundle-size",
|
|
10
|
+
"performance-budget",
|
|
11
|
+
"ci",
|
|
12
|
+
"cli"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/bra31k/route-bundle-size.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/bra31k/route-bundle-size/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/bra31k/route-bundle-size#readme",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"route-bundle-size": "dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.12.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
35
|
+
"prepack": "npm run build",
|
|
36
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
37
|
+
"types": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"cli-table3": "^0.6.5",
|
|
41
|
+
"commander": "^15.0.0",
|
|
42
|
+
"markdown-table": "^3.0.4",
|
|
43
|
+
"picomatch": "^4.0.4",
|
|
44
|
+
"pretty-bytes": "^7.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@jest/globals": "^30.4.1",
|
|
48
|
+
"@types/node": "^25.6.0",
|
|
49
|
+
"@types/picomatch": "^4.0.3",
|
|
50
|
+
"jest": "^30.4.2",
|
|
51
|
+
"jest-environment-node": "^30.4.1",
|
|
52
|
+
"ts-jest": "^29.4.6",
|
|
53
|
+
"tsx": "^4.9.1",
|
|
54
|
+
"typescript": "^6.0.3"
|
|
55
|
+
}
|
|
56
|
+
}
|