UNPKG

next

Version:

The React Framework

963 lines (962 loc) 40.7 kB
/** * `next internal static-routes-info` — analyzes a built Next.js app and * reports per-route bundle sizes statically (without running the app). * * The analysis is split into three steps so it's easy to swap in different * chunking strategies later: * * 1. Capture: for each route, collect a set of files that belong to it, * partitioned into 6 disjoint categories. * 2. Deduplicate: per-route sets are already deduplicated (Set<>), and we * union them across routes for project-wide totals. * 3. Measure: stat each unique file path to get { count, bytes }. * * Output is markdown by default, or JSON with `--json`. `--limit N` keeps * only the top N routes (totals always reflect all routes). */ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "staticRoutesInfoCli", { enumerable: true, get: function() { return staticRoutesInfoCli; } }); const _fs = /*#__PURE__*/ _interop_require_default(require("fs")); const _path = /*#__PURE__*/ _interop_require_default(require("path")); const _config = /*#__PURE__*/ _interop_require_default(require("../../server/config")); const _constants = require("../../shared/lib/constants"); function _interop_require_default(obj) { return obj && obj.__esModule ? obj : { default: obj }; } /** * Available `--sort` keys. `name` sorts ascending alphabetically by route; * every other key is a numeric byte-total and sorts descending (biggest * first). Composite keys (`client`, `server`, `total`) sum across multiple * categories — see `sortValue` for the exact mapping. */ const SORT_KEYS = [ 'name', 'client', 'client-js', 'client-css', 'client-map', 'server', 'server-bundled-js', 'server-unbundled', 'server-map', 'total' ]; // --------------------------------------------------------------------------- // Categories // --------------------------------------------------------------------------- /** * The 6 file categories we partition each route's files into. Each file is * placed into exactly one category to avoid double-counting. * * To add a new category, extend this tuple, add a label below, and update * the relevant collector(s). */ const CATEGORIES = [ 'clientJs', 'clientCss', 'clientMaps', 'serverBundled', 'serverUnbundled', 'serverMaps' ]; /** Human-readable column titles, in the same order as CATEGORIES. */ const CATEGORY_LABELS = { clientJs: 'Client JS', clientCss: 'Client CSS', clientMaps: 'Client Source Maps', serverBundled: 'Server Bundled JS', serverUnbundled: 'Server Unbundled', serverMaps: 'Server Source Maps' }; function emptyFileSets() { const sets = {}; for (const cat of CATEGORIES)sets[cat] = new Set(); return sets; } /** * Pages Router infrastructure entries we never report as routes. * `_app` / `_document` / `_error` aren't really routes, and `404` / `500` * are HTML-only error pages. */ const SKIP_PAGES_ENTRIES = new Set([ '/_app', '/_document', '/_error', '/404', '/500' ]); /** App Router infrastructure entries we never report as routes. */ const SKIP_APP_ENTRIES = new Set([ '/_global-error/page' ]); function readJsonFile(filePath) { try { return JSON.parse(_fs.default.readFileSync(filePath, 'utf8')); } catch { return null; } } function discoverRoutes(distDir) { const middlewareManifest = readJsonFile(_path.default.join(distDir, 'server', 'middleware-manifest.json')); // Edge route handlers (per-route runtime: 'edge') live in `functions`. // Their key matches either an app-paths-manifest internal key (e.g. // `/api/edge/route`) or a pages-manifest route (e.g. `/api/edge` for // pages-router edge APIs). We use these inside the pages/app discovery // below to pick `runtime: edge` instead of node and get the bundled-file // list from middleware-manifest rather than `.nft.json`. const edgeFunctions = (middlewareManifest == null ? void 0 : middlewareManifest.functions) ?? {}; return [ ...discoverPagesRoutes(distDir, edgeFunctions), ...discoverAppRoutes(distDir, edgeFunctions), ...discoverMiddleware((middlewareManifest == null ? void 0 : middlewareManifest.middleware) ?? {}) ]; } function discoverPagesRoutes(distDir, edgeFunctions) { const manifest = readJsonFile(_path.default.join(distDir, 'server', 'pages-manifest.json')); if (!manifest) return []; const routes = []; for (const [route, entry] of Object.entries(manifest)){ if (SKIP_PAGES_ENTRIES.has(route)) continue; const isApi = route.startsWith('/api/'); const edge = edgeFunctions[route]; if (edge) { // Edge runtime — bundle files come from middleware-manifest, not nft. routes.push({ type: isApi ? 'pages-api' : 'pages', route, runtime: { kind: 'edge', files: edge.files } }); } else if (entry.endsWith('.js')) { routes.push({ type: isApi ? 'pages-api' : 'pages', route, runtime: { kind: 'node', serverEntry: entry } }); } else if (entry.endsWith('.html')) { // Statically pre-rendered page — no server JS bundle, but still ships // client JS via build-manifest.json. routes.push({ type: 'pages-static', route }); } } return routes; } function discoverAppRoutes(distDir, edgeFunctions) { const appPathsManifest = readJsonFile(_path.default.join(distDir, 'server', 'app-paths-manifest.json')); if (!appPathsManifest) return []; // Maps internal entry keys (e.g. "/blog/[slug]/page") to their URL path // ("/blog/[slug]"). Optional — falls back to the internal key if missing. const appPathRoutesManifest = readJsonFile(_path.default.join(distDir, 'app-path-routes-manifest.json')); const routes = []; for (const [internalKey, entry] of Object.entries(appPathsManifest)){ if (SKIP_APP_ENTRIES.has(internalKey)) continue; const type = internalKey.endsWith('/route') ? 'app-route' : 'app-page'; const route = (appPathRoutesManifest == null ? void 0 : appPathRoutesManifest[internalKey]) ?? internalKey; const edge = edgeFunctions[internalKey]; if (edge) { // Edge runtime — turbopack writes a placeholder entry value (e.g. // `app-edge-has-no-entrypoint`) here, while webpack writes a real .js // path; either way the actual bundle files come from the // middleware-manifest entry. routes.push({ type, route, runtime: { kind: 'edge', files: edge.files } }); } else if (entry.endsWith('.js')) { routes.push({ type, route, runtime: { kind: 'node', serverEntry: entry } }); } } return routes; } function discoverMiddleware(middleware) { // The middleware manifest keys an entry by `/` for the project's // `middleware.ts`. We use the entry's `name` (e.g. "middleware") as the // displayed route, since `/` would collide with an app-page at "/". return Object.entries(middleware).map(([key, def])=>({ type: 'middleware', route: def.name ?? key, files: def.files })); } // --------------------------------------------------------------------------- // File collection // --------------------------------------------------------------------------- /** * Strip the `_next/` URL prefix that some manifests use (with or without a * leading slash) so all client paths are consistently relative to `distDir`. */ function stripNextPrefix(p) { return p.replace(/^\/?_next\//, ''); } /** * Walk the entry's `.nft.json` (Node File Trace) and partition its files: * - `.map` files → `serverMaps` (regardless of in/out of distDir) * - other `.js` files inside distDir → `serverBundled` (server chunks) * - any other file outside distDir → `serverUnbundled` (traced node_modules * and other on-disk deps the server entry needs at runtime) * * Files inside distDir that are neither `.js` nor `.map` (e.g. `.json` * manifests, `_client-reference-manifest.js`) are skipped — they're either * bundler bookkeeping or already accounted for elsewhere. */ function collectServerEntryFiles(distDir, serverEntry, sets) { const entryRel = _path.default.join('server', serverEntry) // e.g. server/app/page.js ; const entryDirRel = _path.default.dirname(entryRel) // e.g. server/app ; const entryDirAbs = _path.default.join(distDir, entryDirRel); // The entry .js is always part of the bundle, even if no nft.json exists. sets.serverBundled.add(entryRel); const nft = readJsonFile(_path.default.join(distDir, entryRel + '.nft.json')); if (!(nft == null ? void 0 : nft.files)) return; for (const relPath of nft.files){ // Resolve relative to the entry's dir. If the normalized result stays // inside distDir it's a server chunk; if it leaves distDir it's an // unbundled trace dep (e.g. ../../../node_modules/...). const inDistDirPath = _path.default.normalize(_path.default.join(entryDirRel, relPath)); const outsideDistDir = inDistDirPath.startsWith('..'); const isMap = inDistDirPath.endsWith('.map'); if (isMap) { // Source maps go into the maps category whether they're in or outside // distDir, so they don't double-count under serverUnbundled. sets.serverMaps.add(outsideDistDir ? _path.default.resolve(entryDirAbs, relPath) : inDistDirPath); } else if (outsideDistDir) { sets.serverUnbundled.add(_path.default.resolve(entryDirAbs, relPath)); } else if (inDistDirPath.endsWith('.js') && !inDistDirPath.endsWith('_client-reference-manifest.js')) { sets.serverBundled.add(inDistDirPath); } } } /** * Read a `_client-reference-manifest.js` file and extract the JSON blob. * * The file is a JS module that assigns a JSON object to a global. The exact * shape varies by bundler: * * Turbopack (with optional suffix that re-writes `clientModules[k] = val` * when a deployment ID is set): * globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {}; * globalThis.__RSC_MANIFEST["/page"] = {...}; * for (const key in globalThis.__RSC_MANIFEST["/page"].clientModules) { * globalThis.__RSC_MANIFEST["/page"].clientModules[key] = val; * ... * } * * Webpack (no spaces around `=`, single line): * globalThis.__RSC_MANIFEST=(globalThis.__RSC_MANIFEST||{});globalThis.__RSC_MANIFEST["/page"]={...}; * * Rather than evaluating user-bundled code, locate the FIRST * `globalThis.__RSC_MANIFEST[` occurrence (which is always the entry-key * assignment — the `MANIFEST = MANIFEST || {}` boilerplate has no `[` * after the global). Then properly walk the JS string literal that holds * the entry name (handling escapes), so route names containing `]` — * e.g. dynamic segments like `[teamSlug]` or route groups inside dynamic * params — don't terminate the bracket early. After the closing `]` we * expect `=` then `{`, and balance-walk the object body. * * Returns `null` only when the file doesn't exist (a normal case for * server entries that have no client-reference manifest, e.g. middleware * or non-app routes). Any structural surprise — manifest header missing, * unterminated string/object, or invalid JSON — throws so we never * silently undercount client JS/CSS. */ function parseClientReferenceManifest(filePath) { let content; try { content = _fs.default.readFileSync(filePath, 'utf8'); } catch { return null; } const ANCHOR = 'globalThis.__RSC_MANIFEST['; const anchorIdx = content.indexOf(ANCHOR); if (anchorIdx === -1) { throw Object.defineProperty(new Error(`Could not find 'globalThis.__RSC_MANIFEST[' in ${filePath}; client reference manifest format may have changed.`), "__NEXT_ERROR_CODE", { value: "E1234", enumerable: false, configurable: true }); } // Walk a JS string literal starting at `i` (which must point at the // opening quote). Returns the index just past the closing quote. const skipString = (i)=>{ const quote = content[i]; if (quote !== '"' && quote !== "'") { throw Object.defineProperty(new Error(`Expected string literal as entry name in ${filePath} at offset ${i}, got ${JSON.stringify(content[i])}.`), "__NEXT_ERROR_CODE", { value: "E1238", enumerable: false, configurable: true }); } i++; let escape = false; while(i < content.length){ const ch = content[i]; if (escape) { escape = false; } else if (ch === '\\') { escape = true; } else if (ch === quote) { return i + 1; } i++; } throw Object.defineProperty(new Error(`Unterminated entry-name string literal in ${filePath}.`), "__NEXT_ERROR_CODE", { value: "E1235", enumerable: false, configurable: true }); }; // Skip whitespace forward from `i` and assert the next char is `expected`. const expectChar = (i, expected)=>{ while(i < content.length && /\s/.test(content[i]))i++; if (content[i] !== expected) { throw Object.defineProperty(new Error(`Expected '${expected}' after entry name in ${filePath} at offset ${i}, got ${JSON.stringify(content[i] ?? '<eof>')}.`), "__NEXT_ERROR_CODE", { value: "E1233", enumerable: false, configurable: true }); } return i + 1; }; // After `globalThis.__RSC_MANIFEST[` walk: <string> ] = { let i = skipString(anchorIdx + ANCHOR.length); i = expectChar(i, ']'); i = expectChar(i, '='); while(i < content.length && /\s/.test(content[i]))i++; if (content[i] !== '{') { throw Object.defineProperty(new Error(`Expected '{' after 'globalThis.__RSC_MANIFEST[...] =' in ${filePath} at offset ${i}, got ${JSON.stringify(content[i] ?? '<eof>')}.`), "__NEXT_ERROR_CODE", { value: "E1237", enumerable: false, configurable: true }); } // Balance-walk `{...}`, ignoring `{` / `}` inside string literals so a // CSS path like "{foo}" inside JSON doesn't throw the count off. const start = i; let depth = 0; let inString = false; let quote = ''; let escape = false; for(; i < content.length; i++){ const ch = content[i]; if (inString) { if (escape) { escape = false; } else if (ch === '\\') { escape = true; } else if (ch === quote) { inString = false; } continue; } if (ch === '"' || ch === "'") { inString = true; quote = ch; } else if (ch === '{') { depth++; } else if (ch === '}') { depth--; if (depth === 0) { const body = content.slice(start, i + 1); try { return JSON.parse(body); } catch (err) { throw Object.defineProperty(new Error(`Failed to parse JSON body of ${filePath}: ${err.message}`), "__NEXT_ERROR_CODE", { value: "E1236", enumerable: false, configurable: true }); } } } } throw Object.defineProperty(new Error(`Unterminated JSON object in ${filePath}.`), "__NEXT_ERROR_CODE", { value: "E1239", enumerable: false, configurable: true }); } /** * Add a chunk path to the right client-side category. Strips a `?dpl=...` * (or any other) query suffix that webpack appends, then dispatches by * extension. Returns true if the path looked like a real asset (had an * extension we recognize) — used by the webpack `clientModules` walker * below to filter out chunk IDs. */ function addClientChunk(rawPath, sets) { // Some manifests append `?dpl=ID` to chunk URLs. const cleaned = stripNextPrefix(rawPath).split('?')[0]; if (cleaned.endsWith('.map')) sets.clientMaps.add(cleaned); else if (cleaned.endsWith('.css')) sets.clientCss.add(cleaned); else if (cleaned.endsWith('.js')) sets.clientJs.add(cleaned); else return false; return true; } /** * Collect client JS chunks and CSS files for an App Router page/route. * Source priority: * 1. `entryJSFiles` from the route's `_client-reference-manifest.js` * (Turbopack-only field — explicit list of all JS files needed for * the entry's segments). * 2. As a fallback, walk `clientModules[*].chunks` (the canonical client- * reference list, populated by both bundlers). This picks up the * chunks for any actual `'use client'` components imported by the * route. Note webpack interleaves chunkIds with file names — the * extension filter in `addClientChunk` skips the IDs. * * Plus `entryCSSFiles` for CSS, and the per-route `build-manifest.json` * (Turbopack only) for shared App Router root chunks. * * `.map` paths are routed to `clientMaps`, `.css` to `clientCss`, `.js` to * `clientJs` — anything else is dropped. */ function collectAppClientFiles(distDir, serverEntry, sets) { const entryDir = _path.default.dirname(serverEntry); const entryBase = _path.default.basename(serverEntry, '.js'); const baseDir = _path.default.join(distDir, 'server', entryDir); const crm = parseClientReferenceManifest(_path.default.join(baseDir, `${entryBase}_client-reference-manifest.js`)); if (crm) { if (crm.entryJSFiles) { // Turbopack: explicit per-segment chunk list. for (const chunks of Object.values(crm.entryJSFiles)){ for (const chunk of chunks)addClientChunk(chunk, sets); } } else if (crm.clientModules) { // Webpack: no entryJSFiles — walk clientModules. Each entry's // `chunks` array is `[chunkId, fileName, chunkId, fileName, ...]` // (alternating). `addClientChunk` filters by extension so chunkIds // (which have no extension) are dropped automatically. for (const mod of Object.values(crm.clientModules)){ for (const chunk of mod.chunks ?? []){ if (typeof chunk === 'string') addClientChunk(chunk, sets); } } } for (const cssFiles of Object.values(crm.entryCSSFiles ?? {})){ for (const css of cssFiles){ const cssPath = typeof css === 'string' ? css : css.path; if (cssPath) addClientChunk(cssPath, sets); } } } // Add the App Router framework / main-app chunks shared across every // app-page. Both bundlers list them in the global `build-manifest.json` // under `rootMainFiles`. Turbopack also writes a per-route // `build-manifest.json` containing the same files; webpack does not. const globalBm = readJsonFile(_path.default.join(distDir, 'build-manifest.json')); for (const chunk of (globalBm == null ? void 0 : globalBm.rootMainFiles) ?? [])addClientChunk(chunk, sets); } /** * Collect client JS for a Pages Router route. The global `build-manifest.json` * lists each page's chunks (`pages[route]`), the shared baseline (`/_app`), * and `polyfillFiles`. Per-page CSS is not tracked in the Pages build output, * so it's not collected here. `.map` paths are routed to `clientMaps` * defensively. */ function collectPagesClientFiles(distDir, route, sets) { var _bm_pages, _bm_pages1; const bm = readJsonFile(_path.default.join(distDir, 'build-manifest.json')); if (!bm) return; const chunks = [ ...((_bm_pages = bm.pages) == null ? void 0 : _bm_pages['/_app']) ?? [], ...((_bm_pages1 = bm.pages) == null ? void 0 : _bm_pages1[route]) ?? [], ...bm.polyfillFiles ?? [] ]; for (const chunk of chunks){ if (chunk.endsWith('.map')) sets.clientMaps.add(chunk); else sets.clientJs.add(chunk); } } /** * For each file in `source`, find its source map (if any) and add it to * `target`. We try two strategies, in order: * * 1. Read the `//# sourceMappingURL=...` trailer that bundlers emit at * the end of `.js` / `.css` files. This is the most accurate way * because the URL filename can differ from the source filename * (e.g. Turbopack hashes `.map` content separately). * 2. If no trailer is present (e.g. tiny "loader" entry files Turbopack * emits without a comment), fall back to a co-located `<file>.map`. * * Only same-directory relative URLs are followed — `data:` URLs (inline * source maps) and absolute URLs are ignored. * * `urlCache` memoizes the trailer read across routes: a chunk shared by N * routes is only opened once. */ function deriveSourceMaps(distDir, source, target, urlCache) { for (const f of source){ const fullPath = _path.default.isAbsolute(f) ? f : _path.default.join(distDir, f); let mapFromUrl = urlCache.get(fullPath); if (mapFromUrl === undefined) { mapFromUrl = readSourceMappingURL(fullPath); urlCache.set(fullPath, mapFromUrl); } if (mapFromUrl) { // Resolve relative to the source file's directory, then re-express // relative to distDir so paths join consistently. const mapRel = _path.default.normalize(_path.default.join(_path.default.dirname(f), mapFromUrl)); if (!mapRel.startsWith('..') && _fs.default.existsSync(_path.default.join(distDir, mapRel))) { target.add(mapRel); continue; } } // Fallback: co-located `<file>.map`. const adjacent = f + '.map'; const adjacentFull = _path.default.isAbsolute(adjacent) ? adjacent : _path.default.join(distDir, adjacent); if (_fs.default.existsSync(adjacentFull)) target.add(adjacent); } } /** * Read the trailing `//# sourceMappingURL=...` (JS) or `/*# sourceMappingURL=... *​/` * (CSS) comment from a file and return the URL, or null if absent or * inline (`data:`). * * We only need to read the tail of the file — the comment is conventionally * the very last line — so reading 4 KiB is more than enough. */ function readSourceMappingURL(filePath) { let fd; try { fd = _fs.default.openSync(filePath, 'r'); } catch { return null; } try { const stat = _fs.default.fstatSync(fd); const len = Math.min(stat.size, 4096); const buf = Buffer.alloc(len); _fs.default.readSync(fd, buf, 0, len, stat.size - len); const tail = buf.toString('utf8'); // Match either `//# sourceMappingURL=<url>` or // `/*# sourceMappingURL=<url> */` near the end. const match = tail.match(/[/*]#\s*sourceMappingURL=([^\s'"*]+)/); if (!match) return null; const url = match[1]; if (url.startsWith('data:')) return null; // Skip absolute URLs (http://, https://, /abs). if (/^[a-z]+:\/\//i.test(url) || url.startsWith('/')) return null; return url; } catch { return null; } finally{ _fs.default.closeSync(fd); } } /** * Collect bundled `.js` files for an edge runtime entry. Edge bundles don't * have a `.nft.json`; their files are listed inline by middleware-manifest. * `.map` files are routed to `serverMaps` so they don't pollute the bundle * count; other extensions (manifest .json siblings) are dropped. */ function collectEdgeFiles(files, sets) { for (const f of files){ if (f.endsWith('.js')) sets.serverBundled.add(f); else if (f.endsWith('.map')) sets.serverMaps.add(f); } } /** Collect all 6 file-sets for a single route. */ function collectFiles(distDir, entry, urlCache) { const sets = emptyFileSets(); switch(entry.type){ case 'middleware': // Middleware always runs in the edge runtime; same shape as edge // route handlers (inline files list). collectEdgeFiles(entry.files, sets); break; case 'pages-static': collectPagesClientFiles(distDir, entry.route, sets); break; case 'pages': case 'pages-api': case 'app-page': case 'app-route': // Server bundle: node entries are traced via .nft.json; edge entries // list their bundle files directly in the middleware-manifest. if (entry.runtime.kind === 'node') { collectServerEntryFiles(distDir, entry.runtime.serverEntry, sets); } else { collectEdgeFiles(entry.runtime.files, sets); } // Client-side: pages-router uses the global build-manifest; // app-router pages use the per-route _client-reference-manifest plus // shared `rootMainFiles` from the global build-manifest. App-router // route handlers (`app-route`) and edge runtime entries don't ship // client JS — skip client collection there. if (entry.type === 'pages') { collectPagesClientFiles(distDir, entry.route, sets); } else if (entry.type === 'app-page' && entry.runtime.kind === 'node') { collectAppClientFiles(distDir, entry.runtime.serverEntry, sets); } break; default: // Exhaustiveness check — TS will error here if a new RouteEntry // variant is added without a matching case. entry; } // Source maps for everything we collected above. Both .js.map and // .css.map files are picked up by reading the `sourceMappingURL` // trailer of each source file. deriveSourceMaps(distDir, sets.serverBundled, sets.serverMaps, urlCache); deriveSourceMaps(distDir, sets.clientJs, sets.clientMaps, urlCache); deriveSourceMaps(distDir, sets.clientCss, sets.clientMaps, urlCache); return sets; } /** * Stat every unique file across every route's file sets and cache the size. * Symlinks and non-files (directories, etc.) are recorded as `null` so we * don't re-stat and so they're excluded from later counts. */ function buildSizeCache(distDir, allFileSets) { const cache = new Map(); for (const sets of allFileSets){ for (const cat of CATEGORIES){ for (const f of sets[cat]){ if (cache.has(f)) continue; const fullPath = _path.default.isAbsolute(f) ? f : _path.default.join(distDir, f); try { const stat = _fs.default.lstatSync(fullPath); cache.set(f, stat.isFile() && !stat.isSymbolicLink() ? stat.size : null); } catch { cache.set(f, null); } } } } return cache; } /** Sum sizes for the files in `set` using the precomputed cache. */ function measureFromCache(set, cache) { let count = 0; let bytes = 0; for (const f of set){ const size = cache.get(f); if (size != null) { count++; bytes += size; } } return { count, bytes }; } function measureFileSets(sets, cache) { const result = {}; for (const cat of CATEGORIES){ result[cat] = measureFromCache(sets[cat], cache); } return result; } /** * For each category, compute the average size of the intersection of this * route's files with each peer route's files (a "peer" is another route of * the same `type`). Returns `null` per category if there are no peers. * * Files counted multiple times across peers contribute to the average each * time — e.g. if a chunk is shared with all 5 peers, it contributes 5×size * to the sum, then we divide by 5 to get the average. */ function measureSharedAvg(routeIndex, allFileSets, routeEntries, cache) { const myType = routeEntries[routeIndex].type; const peers = []; for(let j = 0; j < routeEntries.length; j++){ if (j !== routeIndex && routeEntries[j].type === myType) peers.push(j); } const result = {}; for (const cat of CATEGORIES){ if (peers.length === 0) { result[cat] = null; continue; } const mySet = allFileSets[routeIndex][cat]; let sumCount = 0; let sumBytes = 0; for (const j of peers){ const peerSet = allFileSets[j][cat]; // Iterate the smaller set and probe the larger; saves work when sizes // differ a lot (e.g. an empty serverUnbundled vs a big one). const [small, big] = mySet.size <= peerSet.size ? [ mySet, peerSet ] : [ peerSet, mySet ]; for (const f of small){ if (!big.has(f)) continue; const size = cache.get(f); if (size != null) { sumCount++; sumBytes += size; } } } result[cat] = { count: sumCount / peers.length, bytes: sumBytes / peers.length }; } return result; } /** Union of all per-route file sets. Used to compute project-wide totals. */ function mergeSets(all) { const merged = emptyFileSets(); for (const sets of all){ for (const cat of CATEGORIES){ for (const f of sets[cat])merged[cat].add(f); } } return merged; } function totalBytes(stats) { let sum = 0; for (const cat of CATEGORIES)sum += stats[cat].bytes; return sum; } /** * Compute the byte total a route should be ordered by, for a given sort key. * `name` is special-cased by the caller; every other key returns a numeric * total that sorts descending. */ function sortValue(r, key) { switch(key){ case 'client': return r.clientJs.bytes + r.clientCss.bytes; case 'client-js': return r.clientJs.bytes; case 'client-css': return r.clientCss.bytes; case 'client-map': return r.clientMaps.bytes; case 'server': return r.serverBundled.bytes + r.serverUnbundled.bytes; case 'server-bundled-js': return r.serverBundled.bytes; case 'server-unbundled': return r.serverUnbundled.bytes; case 'server-map': return r.serverMaps.bytes; case 'total': return totalBytes(r); default: key; throw Object.defineProperty(new Error(`unreachable sort key: ${key}`), "__NEXT_ERROR_CODE", { value: "E1232", enumerable: false, configurable: true }); } } /** * Sort `routes` in-place by the given key. `name` sorts ascending * alphabetically; every other key sorts descending by byte total, with a * stable tiebreaker on the route name (so two routes with identical sizes * always appear in the same order). */ function sortRoutes(routes, key) { if (key === 'name') { routes.sort((a, b)=>a.route.localeCompare(b.route)); return; } routes.sort((a, b)=>sortValue(b, key) - sortValue(a, key) || a.route.localeCompare(b.route)); } /** * Convert an internal path to one expressed relative to `distDir`. Paths * already relative are passed through; absolute paths (traced node_modules * deps) are rewritten so the output JSON is independent of the user's * absolute filesystem layout. */ function toDistRelative(distDir, p) { return _path.default.isAbsolute(p) ? _path.default.relative(distDir, p) : p; } /** * Sorted, dist-relative file list for a single category, used when * `--files` is enabled. Entries with `null` in the size cache (symlinks, * directories, missing files) are filtered out so the list stays in sync * with `count` (which excludes them too). Sorting keeps JSON output * deterministic across runs / platforms. */ function fileListFor(distDir, set, sizeCache) { const out = []; for (const p of set){ if (sizeCache.get(p) != null) out.push(toDistRelative(distDir, p)); } return out.sort(); } // --------------------------------------------------------------------------- // Output // --------------------------------------------------------------------------- function formatBytes(n) { if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(2) + ' KB'; return Math.round(n) + ' B'; } /** File counts can be fractional in averages — print 1 decimal in that case. */ function formatCount(n) { return Number.isInteger(n) ? `${n}` : n.toFixed(1); } function formatCell(stats) { // Render empty cells as `-` rather than `0 files / 0 B`. The vast // majority of cells in a typical app have *some* content for every // category — when a cell IS empty (e.g. a route handler ships no client // JS) the placeholder makes the table much easier to scan visually // because non-zero values stand out. if (stats.count === 0 && stats.bytes === 0) return '-'; return `${formatCount(stats.count)} files / ${formatBytes(stats.bytes)}`; } /** * Cell for the "Shared" table: returns "n/a" if a route has no peers (i.e. * `stats` is `null`), otherwise the same `count files / bytes` rendering as * the routes table, augmented with the percentage of own count/bytes that * the average shared portion represents — e.g. `5 files (83%) / 1.2 MB (40%)`. * * Empty intersections render as `-` for the same readability reason as * `formatCell`. `n/a` (no peers) is preserved separately because it has a * different meaning from "shared with peers but the intersection is empty". */ function formatSharedCell(stats) { if (stats == null) return 'n/a'; if (stats.count === 0 && stats.bytes === 0) return '-'; return `${formatCount(stats.count)} files (${Math.round(stats.percentCount)}%)` + ` / ${formatBytes(stats.bytes)} (${Math.round(stats.percentBytes)}%)`; } /** * Compute the percent-shared annotation for a (own, sharedAvg) pair. * Returns `null` unchanged when the route has no peers; otherwise extends * the raw {count, bytes} averages with `percentCount` and `percentBytes`. * Avoids 0/0 by returning 0 when own.count or own.bytes is 0 (the * intersection of an empty set with anything is also 0, so 0% is a * coherent answer rather than NaN). */ function annotateShared(own, shared) { if (shared == null) return null; return { count: shared.count, bytes: shared.bytes, percentCount: own.count > 0 ? shared.count / own.count * 100 : 0, percentBytes: own.bytes > 0 ? shared.bytes / own.bytes * 100 : 0 }; } /** Render a fixed-width markdown table — pads each cell to align columns. */ function renderMarkdownTable(headers, rows) { const widths = headers.map((h, i)=>Math.max(h.length, ...rows.map((r)=>r[i].length))); const formatRow = (cells)=>'| ' + cells.map((c, i)=>c.padEnd(widths[i])).join(' | ') + ' |'; const divider = '| ' + widths.map((w)=>'-'.repeat(w)).join(' | ') + ' |'; return [ formatRow(headers), divider, ...rows.map(formatRow) ].join('\n'); } function printMarkdown(routes, totals) { const categoryHeaders = CATEGORIES.map((c)=>CATEGORY_LABELS[c]); const routeRows = routes.map((r)=>[ r.route, r.type, ...CATEGORIES.map((c)=>formatCell(r[c])) ]); console.log('## Routes\n'); console.log(renderMarkdownTable([ 'Route', 'Type', ...categoryHeaders ], routeRows)); // Shared (averaged across peers of same type) — printed in the same row // order as the routes table. Routes with no peers show `n/a`. const sharedRows = routes.map((r)=>[ r.route, r.type, ...CATEGORIES.map((c)=>formatSharedCell(r[c].sharedAvg)) ]); console.log('\n## Shared (avg per other route of same type)\n'); console.log(renderMarkdownTable([ 'Route', 'Type', ...categoryHeaders ], sharedRows)); const totalsRow = [ '**Total**', ...CATEGORIES.map((c)=>formatCell(totals[c])) ]; console.log('\n## Totals\n'); console.log(renderMarkdownTable([ '', ...categoryHeaders ], [ totalsRow ])); } function printJson(routes, totals) { console.log(JSON.stringify({ routes, totals }, null, 2)); } async function staticRoutesInfoCli(options, directory) { // Validate options up front so we fail fast with a clear error before // doing any expensive work (loading config, reading manifests). const sortKey = options.sort ? SORT_KEYS.includes(options.sort) ? options.sort : (()=>{ console.error(`Error: invalid --sort key '${options.sort}'. Valid keys: ${SORT_KEYS.join(', ')}.`); process.exit(1); })() : 'name'; if (options.files && !options.json) { console.error('Error: --files requires --json.'); process.exit(1); } const dir = _path.default.resolve(directory ?? process.cwd()); const config = await (0, _config.default)(_constants.PHASE_PRODUCTION_BUILD, dir); const distDir = _path.default.join(dir, config.distDir); // BUILD_ID is the standard sentinel that a Next.js build completed. if (!_fs.default.existsSync(_path.default.join(distDir, 'BUILD_ID'))) { console.error(`Error: No build found at ${distDir}. Run \`next build\` first.`); process.exit(1); } // Step 1+2: capture per-route files (sets implicitly deduplicate). The // `urlCache` memoizes `sourceMappingURL` reads — a chunk shared by N // routes only opens its file once. const routeEntries = discoverRoutes(distDir); const urlCache = new Map(); const allFileSets = routeEntries.map((entry)=>collectFiles(distDir, entry, urlCache)); // Step 3a: stat every unique file once and cache the size, so per-route // measurement and shared-avg calculation don't repeat syscalls. const sizeCache = buildSizeCache(distDir, allFileSets); // Step 3b: measure per-route. Each category also carries a `sharedAvg` // against its same-type peers; under `--files` it also carries the // dist-relative file list that contributed to the metric. const routeInfos = routeEntries.map((entry, i)=>{ const stats = measureFileSets(allFileSets[i], sizeCache); const shared = measureSharedAvg(i, allFileSets, routeEntries, sizeCache); const merged = {}; for (const cat of CATEGORIES){ merged[cat] = { ...stats[cat], sharedAvg: annotateShared(stats[cat], shared[cat]) }; if (options.files) { merged[cat].files = fileListFor(distDir, allFileSets[i][cat], sizeCache); } } return { route: entry.route, type: entry.type, ...merged }; }); sortRoutes(routeInfos, sortKey); // Project-wide totals — union of all route sets, regardless of --limit. const mergedSets = mergeSets(allFileSets); const totals = measureFileSets(mergedSets, sizeCache); if (options.files) { for (const cat of CATEGORIES){ totals[cat].files = fileListFor(distDir, mergedSets[cat], sizeCache); } } const displayRoutes = options.limit != null && options.limit > 0 ? routeInfos.slice(0, options.limit) : routeInfos; if (options.json) { printJson(displayRoutes, totals); } else { printMarkdown(displayRoutes, totals); } } //# sourceMappingURL=static-routes-info.js.map