import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; const root = process.cwd(); const analyzeDataDirectory = resolve( root, process.env.BUNDLE_ANALYZE_DIR ?? ".next/diagnostics/analyze/data", ); const outputPath = resolve( root, process.env.PERF_REPORT_PATH ?? ".next/diagnostics/bundle-summary.json", ); const routes = parseList( process.env.PERF_ROUTES ?? "/splash,/chat,/subscription,/tip", ); const auditTargets = [ { name: "Stripe React SDK", matches: (path) => path.includes("/node_modules/@stripe/react-stripe-js/"), }, { name: "Stripe loader", matches: (path) => path.includes("/node_modules/@stripe/stripe-js/"), }, { name: "Stripe dialog", matches: (path) => path.includes("/stripe-payment-dialog.tsx"), }, { name: "Reply animation", matches: (path) => path.includes("/lottie-message-bubble"), }, { name: "Lottie runtime", matches: (path) => path.includes("/node_modules/lottie-react/"), }, ]; function main() { const modulesData = new FramedDataFile( resolve(analyzeDataDirectory, "modules.data"), ); const asyncBoundariesByPath = getAsyncBoundariesByModulePath(modulesData); const routeReports = routes.map((route) => analyzeRoute(route, asyncBoundariesByPath), ); const packageJson = JSON.parse( readFileSync(resolve(root, "package.json"), "utf8"), ); const report = { generatedAt: new Date().toISOString(), nextVersion: packageJson.dependencies.next, routes: routeReports, }; mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); console.table( routeReports.map((route) => ({ route: route.route, "eager client JS": formatBytes(route.eagerClientJs.compressedBytes), "async client JS": formatBytes(route.asyncClientJs.compressedBytes), "total client JS": formatBytes(route.totalClientJs.compressedBytes), })), ); for (const route of routeReports) { const presentAudits = route.audits.filter( (audit) => audit.phase !== "absent", ); if (presentAudits.length === 0) continue; console.log(`\n${route.route} audited modules`); console.table( presentAudits.map((audit) => ({ module: audit.name, phase: audit.phase, compressed: formatBytes(audit.compressedBytes), })), ); } console.log(`\nBundle summary written to ${outputPath}`); } function analyzeRoute(route, boundariesByPath) { const routeFile = route === "/" ? resolve(analyzeDataDirectory, "analyze.data") : resolve( analyzeDataDirectory, route.replace(/^\//, ""), "analyze.data", ); const data = new FramedDataFile(routeFile); const sourceIndexes = collectRouteSourceIndexes(data); const clientParts = collectClientJavaScriptParts(data, sourceIndexes); const eagerParts = []; const asyncParts = []; for (const partIndex of clientParts) { const part = data.header.chunk_parts[partIndex]; const sourcePath = normalizeSourcePath( data.getFullSourcePath(part.source_index), ); const boundaryCount = boundariesByPath.get(sourcePath); if (boundaryCount !== undefined && boundaryCount > 0) { asyncParts.push(partIndex); } else { eagerParts.push(partIndex); } } return { route, eagerClientJs: sumParts(data, eagerParts), asyncClientJs: sumParts(data, asyncParts), totalClientJs: sumParts(data, clientParts), audits: auditTargets.map((target) => { const matchingParts = clientParts.filter((partIndex) => { const part = data.header.chunk_parts[partIndex]; return target.matches(data.getFullSourcePath(part.source_index)); }); const boundaryCounts = matchingParts .map((partIndex) => { const part = data.header.chunk_parts[partIndex]; return boundariesByPath.get( normalizeSourcePath(data.getFullSourcePath(part.source_index)), ); }) .filter((value) => value !== undefined); const sizes = sumParts(data, matchingParts); return { name: target.name, phase: matchingParts.length === 0 ? "absent" : boundaryCounts.length > 0 && Math.min(...boundaryCounts) > 0 ? "async" : "eager", ...sizes, }; }), }; } function collectRouteSourceIndexes(data) { const indexes = new Set(); const pending = [...data.header.source_roots]; while (pending.length > 0) { const index = pending.pop(); if (index === undefined || indexes.has(index)) continue; indexes.add(index); pending.push(...data.readEdges(data.header.source_children, index)); } return indexes; } function collectClientJavaScriptParts(data, sourceIndexes) { const parts = new Set(); for (const sourceIndex of sourceIndexes) { for (const partIndex of data.readEdges( data.header.source_chunk_parts, sourceIndex, )) { const part = data.header.chunk_parts[partIndex]; const output = data.header.output_files[part.output_file_index]; if ( output.filename.startsWith("[client-fs]/") && output.filename.endsWith(".js") ) { parts.add(partIndex); } } } return [...parts]; } function sumParts(data, partIndexes) { let bytes = 0; let compressedBytes = 0; for (const partIndex of partIndexes) { const part = data.header.chunk_parts[partIndex]; bytes += part.size; compressedBytes += part.compressed_size; } return { bytes, compressedBytes }; } function getAsyncBoundariesByModulePath(data) { const modules = data.header.modules; const entryDependents = [ "next/dist/esm/build/templates/pages.js", "next/dist/esm/build/templates/pages-api.js", "next/dist/esm/build/templates/pages-edge-api.js", "next/dist/esm/build/templates/edge-ssr.js", "next/dist/esm/build/templates/app-route.js", "next/dist/esm/build/templates/edge-app-route.js", "next/dist/esm/build/templates/app-page.js", "next/dist/esm/build/templates/edge-ssr-app.js", "next/dist/esm/build/templates/middleware.js", "[next]/entry/page-loader.ts", ]; const directEntries = [ "next/dist/client/app-next-turbopack.js", "next/dist/client/next-turbopack.js", ]; const entries = new Set(); for (let index = 0; index < modules.length; index += 1) { const moduleRecord = modules[index]; if (entryDependents.some((entry) => moduleRecord.ident.includes(entry))) { for (const dependency of data.readEdges( data.header.module_dependencies, index, )) { if (!modules[dependency].path.includes("next/dist/")) { entries.add(dependency); } } } if (directEntries.some((entry) => moduleRecord.ident.includes(entry))) { entries.add(index); } } const distances = Array(modules.length).fill(Number.POSITIVE_INFINITY); const buckets = [[...entries]]; for (const entry of entries) distances[entry] = 0; for (let boundaryCount = 0; boundaryCount < buckets.length; boundaryCount += 1) { const bucket = buckets[boundaryCount] ?? []; for (let cursor = 0; cursor < bucket.length; cursor += 1) { const moduleIndex = bucket[cursor]; if (distances[moduleIndex] !== boundaryCount) continue; relaxDependencies( data.header.module_dependencies, moduleIndex, boundaryCount, ); relaxDependencies( data.header.async_module_dependencies, moduleIndex, boundaryCount + 1, ); } } const boundariesByPath = new Map(); for (let index = 0; index < modules.length; index += 1) { const moduleRecord = modules[index]; if (!moduleRecord.ident.includes("[app-client]")) continue; const boundaryCount = distances[index]; if (!Number.isFinite(boundaryCount)) continue; const current = boundariesByPath.get(moduleRecord.path); if (current === undefined || boundaryCount < current) { boundariesByPath.set(moduleRecord.path, boundaryCount); } } return boundariesByPath; function relaxDependencies(reference, moduleIndex, nextDistance) { for (const dependency of data.readEdges(reference, moduleIndex)) { if (nextDistance >= distances[dependency]) continue; distances[dependency] = nextDistance; buckets[nextDistance] ??= []; buckets[nextDistance].push(dependency); } } } function normalizeSourcePath(path) { return path.replace(/ \[app-(?:client|ssr)\].*$/, ""); } function parseList(value) { return value .split(",") .map((item) => item.trim()) .filter(Boolean) .map((route) => (route.startsWith("/") ? route : `/${route}`)); } function formatBytes(bytes) { return `${(bytes / 1024).toFixed(1)} KiB`; } class FramedDataFile { constructor(filePath) { this.buffer = readFileSync(filePath); const headerLength = this.buffer.readUInt32BE(0); this.binaryStart = 4 + headerLength; this.header = JSON.parse( this.buffer.subarray(4, this.binaryStart).toString("utf8"), ); this.sourcePathCache = new Map(); } readEdges(reference, index) { if (reference.length === 0) return []; const referenceStart = this.binaryStart + reference.offset; const indexCount = this.buffer.readUInt32BE(referenceStart); if (index < 0 || index >= indexCount) return []; const offsetsStart = referenceStart + 4; const previousOffset = index === 0 ? 0 : this.buffer.readUInt32BE(offsetsStart + (index - 1) * 4); const nextOffset = this.buffer.readUInt32BE(offsetsStart + index * 4); const dataStart = offsetsStart + indexCount * 4; const edges = []; for (let cursor = previousOffset; cursor < nextOffset; cursor += 1) { edges.push(this.buffer.readUInt32BE(dataStart + cursor * 4)); } return edges; } getFullSourcePath(index) { const cached = this.sourcePathCache.get(index); if (cached !== undefined) return cached; const source = this.header.sources[index]; const path = source.parent_source_index === null ? source.path : `${this.getFullSourcePath(source.parent_source_index)}${source.path}`; this.sourcePathCache.set(index, path); return path; } } main();