next.js
eab3ab87 - Add `next internal static-routes-info` CLI command (#93399)

Commit
88 days ago
Add `next internal static-routes-info` CLI command (#93399) ### What? Adds a new internal CLI subcommand: ``` next internal static-routes-info [directory] [options] ``` It runs against an already-built Next.js app (`.next/` from `next build`), reads the manifests under `distDir`, and reports per-route bundle sizes split into six file categories. Supports markdown (default) or JSON output, sorting, limiting, and per-category file listings. ### Why? We want a **static, build-output-based** way to compare different chunking strategies for JS and CSS without running the user's app. Existing tooling either requires running the app (`@next/bundle-analyzer` is a webpack plugin), is bundler-specific, or aggregates per-bundle/per-asset rather than per-route. This tool answers the concrete question "how much JS / CSS does each route ship today?" purely by reading static manifests, so it can be diffed across builds, branches, or bundlers (Turbopack vs webpack) and used to evaluate chunking changes. It is namespaced under `next internal` because the output format and category boundaries are tied to internal manifest shapes; we don't intend to make it a stable public surface yet. ### How? #### Command surface ``` Usage: next internal static-routes-info [directory] [options] Options: --json Output as JSON instead of markdown. --limit <n> Only show the first N routes after sorting (totals always reflect all routes). --sort <key> Sort routes by: name (default, ascending), or one of client, client-js, client-css, client-map, server, server-bundled-js, server-unbundled, server-map, total (descending). --files Include the list of files (relative to the output directory) per category in the JSON output. Requires --json. -h, --help Displays this message. ``` `--limit` and `--sort` always consider every route for the totals; only the displayed table is trimmed/reordered. `--files` is only meaningful in JSON output and errors otherwise. Invalid `--sort` keys error with the valid set listed. #### Six categories per route Each file the tool sees is placed into **exactly one** of these six buckets, so totals are not double-counted: | Category | Description | | ------------------ | -------------------------------------------------------------------------------------------------- | | Client JS | `.js` chunks loaded by the browser | | Client CSS | `.css` files loaded by the browser | | Client Source Maps | `.map` files for client JS / CSS | | Server Bundled JS | `.js` chunks executed on the server (App Router, Pages SSR, route handlers, middleware) | | Server Unbundled | Files traced via `*.nft.json` outside `distDir` (typically `node_modules` deps for `serverExternalPackages` / Pages SSR) | | Server Source Maps | `.map` files for server JS, including `.map`s referenced from nft.json | Source maps are discovered three ways: `.map` extension matches, `//# sourceMappingURL=...` trailers in JS, and `/*# sourceMappingURL=...*/` trailers in CSS. Maps always go into the `Maps` category even when the manifest puts them next to their bundle, so they never inflate Bundled or CSS counts. `sourceMappingURL` reads are memoized per chunk so a chunk shared by N routes is opened once. #### Two-step measurement 1. **Capture per-route file sets** by reading manifests: - `pages-manifest.json` and `app-path-routes-manifest.json` for the route list. - `<entry>.nft.json` for server-bundled chunks plus traced node_modules deps. - `<entry>_client-reference-manifest.js` for App Router client JS/CSS (both `entryJSFiles` / `entryCSSFiles` on Turbopack and `clientModules.chunks` on webpack — the parser handles both layouts). - `build-manifest.json` for shared App Router root chunks (`rootMainFiles`) and Pages Router client chunks. - `middleware-manifest.json` for middleware and edge route handlers. 2. **Deduplicate inside each per-route category, then measure.** A global `lstat` cache stats every unique path once across the whole run; per-category sets dedupe via string equality. Files are routed by extension (`.map` → Maps, `.css` → CSS, `.js` → bundled / unbundled depending on whether the path stays inside `distDir`) at the point they enter a set, so e.g. an `.nft.json` referencing both bundle and `.map` paths places each in the right bucket. #### Shared metric For each route, every category also carries a `sharedAvg`: the average size of the *intersection* between this route and each peer route of the same type. Computed as ``` sharedAvg = (Σ over peers p: |files(this) ∩ files(p)|) / number_of_peers ``` with both file count and bytes reported. The metric is also expressed as a percentage of the route's own count and bytes (`percentCount`, `percentBytes`) to make sharing easy to interpret at a glance — e.g. `5.3 files (88%) / 424.12 KB (100%)` means "88% of this route's files, and effectively all of its bytes, are also shipped by an average peer". Routes with no peers (only one route of their type) get `null`. Note that percentages are NOT commutative across peers (they're divided by each route's own count/bytes) while raw intersection numbers are. #### Output Markdown (default), with three sections — `## Routes`, `## Shared (avg per other route of same type)`, `## Totals` — each rendered as a fixed-width aligned table. Empty cells render as `-` (and routes with no peers in the Shared section as `n/a`) so meaningful values stand out: ``` ## Routes | Route | Type | Client JS | Client CSS | Client Source Maps | Server Bundled JS | Server Unbundled | Server Source Maps | | ------------ | ---------- | ------------------- | --------------- | ------------------ | -------------------- | ------------------- | ------------------- | | / | app-page | 6 files / 424.40 KB | 2 files / 153 B | - | 16 files / 384.47 KB | 140 files / 1.37 MB | 16 files / 2.31 MB | | /api/edge | app-route | - | - | - | 9 files / 296.82 KB | - | 4 files / 1.53 MB | … ## Shared (avg per other route of same type) | Route | Type | Client JS | Client CSS | Client Source Maps | Server Bundled JS | Server Unbundled | Server Source Maps | | ------------ | -------- | ---------------------------------- | ---------------------------- | ------------------ | -------------------------------- | --------------------------------- | ------------------------------ | | / | app-page | 5.3 files (88%) / 424.12 KB (100%) | 1.3 files (63%) / 52 B (34%) | - | 11 files (69%) / 357.52 KB (93%) | 140 files (100%) / 1.37 MB (100%) | 11 files (69%) / 2.19 MB (95%) | … ``` JSON has the same per-category structure (count + bytes + sharedAvg + optional files list when `--files` is used) with identical category ordering: `clientJs`, `clientCss`, `clientMaps`, `serverBundled`, `serverUnbundled`, `serverMaps`. JSON values are exact (e.g. `0/0` is preserved as `{count:0, bytes:0}` rather than `-`) so machine consumers aren't affected by the markdown placeholder. Totals also expose dedup'd `files` arrays under `--files`. #### Route types Reported types: `app-page`, `app-route`, `pages`, `pages-static`, `pages-api`, `middleware`. App Router route handlers with `runtime: 'edge'` report as `app-route` (not a separate `edge-function`) so they're directly comparable with their Node-runtime peers. Middleware is a first-class type rather than being lumped under edge-function. #### Robust manifest parsing `_client-reference-manifest.js` is a JS module, not JSON. Both bundlers emit it but with different layouts: - Turbopack: multi-line, with a `for (const key in MANIFEST[entry].clientModules) MANIFEST[entry].clientModules[k] = val` suffix when a deployment ID is set. - Webpack: single-line, no whitespace around `=`. We extract the JSON body without evaluating the file. The implementation locates the `globalThis.__RSC_MANIFEST[` anchor, walks the JS string literal that holds the entry name (honoring `\\` escapes), then balance-walks the `{...}` body. This handles entry names that contain `]` characters, e.g. ```js globalThis.__RSC_MANIFEST["/(dashboard)/[teamSlug]/(team)/~/stores/(store-details)/blob/[storeId]/page"] = {...} ``` Any structural surprise (anchor missing, unterminated string/object, JSON parse failure) throws with the file path and offset — we never silently undercount client JS/CSS for a route. Only file-not-found stays as a `null` return — that's a normal case for server entries with no client-reference manifest (middleware, route handlers, etc.). ### Tests `test/production/static-routes-info/` is a real fixture covering every route type: - App Router: `/`, `/about`, `/no-client`, `/items/[itemId]` (a dynamic segment inside a `(group)` route group, which forces `]` to appear unescaped in the manifest entry name and exercises the parser), plus the auto-generated `/_not-found`. - App Router route handlers: `/api/node` (default Node runtime), `/api/edge` (`runtime: 'edge'`). - Pages Router: `/pages-ssr`, `/pages-ssr-2` (siblings sharing chunks), `/pages-static`, `/api/hello`. - Middleware: `middleware.ts`. - A shared lib (`lib/shared.ts`) imported by both pages-router siblings and `/`-`/about` to give the shared-avg metric something non-trivial to measure. - A `'use client'` `Counter` component imported by `/` and `/about` (but not `/no-client`), which itself imports `counter.module.css`. Routes that import Counter must ship strictly more client JS (and on Turbopack, more client CSS) than `/no-client` — this is asserted, and it's the cross-bundler regression check for the App Router client-JS collection on webpack via `clientModules.chunks` (without it, every webpack app-page reports `clientJs.count = 0`). The test file (`static-routes-info.test.ts`) covers all the above plus output formats, sort options, limit semantics, file-list integrity, totals dedup, shared-avg correctness against a hand-computed reference, markdown/JSON consistency, and the empty-cell `-` placeholder. The shared-avg metric is verified three independent ways: against a from-scratch reimplementation that walks the `--files` lists and recomputes every (route, category) cell; against a "sharedAvg.count == own.count IFF every peer is a strict superset" invariant that makes 100% values load-bearing; and against a hand-known case where one route ships a chunk no peer does, forcing strictly-below-100% sharing. 31 tests, passing on both Turbopack and webpack. The tool was also exercised against `bench/basic-app`, `bench/heavy-npm-deps`, `bench/nested-deps`, `bench/app-router-server`, and `bench/nested-deps-app-router` while developing. ### Notes for reviewers - New error codes added to `errors.json` for the manifest-parser throws and other invariant violations. - The command is registered under `next internal`; not advertised in user-facing docs by design. - Webpack quirk documented in the test: `flight-manifest-plugin.ts`'s `mergeManifest` merges every app-page's `entryCSSFiles` into every other route's CRM, so per-route CSS attribution on webpack is inherently fuzzy — the test asserts CSS attribution on Turbopack only, and the comment explains why. <!-- NEXT_JS_LLM_PR --> --------- Co-authored-by: v-work-app[bot] <262237222+v-work-app[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Tobias Koppers <sokra@users.noreply.github.com>
Author
Parents
Loading