Files
5f688d274a [Bench] Add client-trace attribution pass and document metrics to render-pipeline (#95828)
Added a bunch of stuff to the bench.

---

**New metrics in the HTTP benchmark**
- Report TTFB per route (time to first body byte), next to total
latency.
- Report each route's document size, how many bytes are inline Flight
payload, and the Flight share.

**New script: `pnpm bench:render-pipeline:client`**
- Loads each route in Chrome with tracing and 4x CPU throttling, and
breaks down where client time goes: evaluating chunks, evaluating inline
Flight scripts, compiling, background parsing, GC, time to hydration,
and blocking time before hydration.
- Also prints FCP/LCP/DOMContentLoaded/load from the same trace, and JS
transferred vs parsed.
- Off by default, separate from the timing benchmark, since tracing
perturbs timing.
- Hydration time comes from a small client component added to the
fixture root layout that calls `performance.mark`.

**Bug fixes**
- The benchmark was replacing the fixture's `next.config.js` with an
empty one during runs.
- If the port was already taken, the benchmark could silently measure
whatever server was already running there. Both scripts now refuse to
start if something is already on the port.
- A server that died on startup used to look like a slow server; now it
errors immediately.
- One failed request used to abort the whole run and throw away all
results. Now it costs one sample and gets counted in `errors`.
- Killing an already-dead server used to hang the script.
- Bad flags now error upfront instead of crashing at the end.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-23 09:26:03 +02:00

1033 lines
30 KiB
TypeScript

// This script must be run with tsx
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { existsSync } from 'node:fs'
import { access, copyFile, mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { setTimeout as sleep } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import zlib from 'node:zlib'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const NEXT_BIN = resolve(REPO_ROOT, 'packages/next/dist/bin/next')
const MINIMAL_SERVER = resolve(
REPO_ROOT,
'bench/next-minimal-server/bin/minimal-server.js'
)
type Scenario = 'minimal-server' | 'e2e'
type StreamMode = 'node'
type CliOptions = {
scenario: Scenario
jsonOut?: string
appDir: string
routes: string[]
streamMode: StreamMode
build: boolean
warmupRequests: number
warmupUntilStable: boolean
serialRequests: number
loadRequests: number
loadConcurrency: number
timeoutMs: number
port: number
isolateRoutes: boolean
captureCpu: boolean
captureHeap: boolean
captureTrace: boolean
captureNextTrace: boolean
traceCategories: string
artifactDir: string
}
type BenchStats = {
min: number
median: number
mean: number
stddev: number
p95: number
max: number
}
type RequestSample = {
totalMs: number
ttfbMs: number
}
type FullRoutePhaseResult = {
mode: StreamMode
route: string
phase: 'single-client' | 'under-load'
requests: number
errors: number
concurrency: number
throughputRps: number
// Null when every request in the phase failed.
latency: BenchStats | null
ttfb: BenchStats | null
}
// Byte composition of one route's HTML document. Bytes are uncompressed
// (undici decompresses before we see the body). Flight bytes are the
// contents of inline `self.__next_f.push(...)` scripts.
type RouteDocumentInfo = {
route: string
bytes: number
gzipBytes: number
inlineFlightBytes: number
inlineFlightShare: number
inlineFlightScripts: number
}
type FullRunResult = {
mode: StreamMode
routeResults: FullRoutePhaseResult[]
routeDocuments: RouteDocumentInfo[]
}
function parseBoolean(value: string): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
function parseNumberArg(
args: Map<string, string>,
key: string,
fallback: number
): number {
const value = args.get(key)
if (value === undefined) return fallback
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid numeric value for --${key}: ${value}`)
}
return parsed
}
function parseRoutes(rawRoutes: string | undefined): string[] {
if (!rawRoutes) {
return [
'/',
'/attributes',
'/tailwind',
'/dashboard',
'/docs',
'/blog',
'/streaming/light',
'/streaming/medium',
'/streaming/heavy',
'/streaming/chunkstorm',
'/streaming/wide',
'/streaming/bulk',
]
}
const routes = rawRoutes
.split(',')
.map((route) => route.trim())
.filter(Boolean)
if (routes.length === 0) {
throw new Error('--routes cannot be empty')
}
for (const route of routes) {
if (!route.startsWith('/')) {
throw new Error(`Each route must start with '/': ${route}`)
}
}
return routes
}
function usage() {
console.log(`Usage: pnpm bench:render-pipeline [options]
Options:
--scenario=e2e|minimal-server (default: e2e)
e2e: Real production server (next build + next start).
minimal-server: NextServer with minimalMode, no router-server.
--json-out=<path>
Benchmark options:
--app-dir=<path> (default: bench/basic-app)
--routes=/,/streaming/light,... (default: built-in stress suite)
--stream-mode=node (default: node)
--build=true|false (default: true)
--warmup-requests=<number> (default: 50)
Batch size per warmup iteration.
--warmup-until-stable=true|false (default: true)
Repeat warmup batches until mean latency
stabilizes (<5% delta). Max 10 batches.
--serial-requests=<number> (default: 120)
--load-requests=<number> (default: 1200)
--load-concurrency=<number> (default: 80)
--port=<number> (default: 3199)
--timeout-ms=<number> (default: 30000)
--isolate-routes=true|false (default: false)
Restart the server between routes to avoid
cross-route GC/memory contamination.
Profiling and trace options:
--capture-cpu=true|false (default: false)
--capture-heap=true|false (default: false)
--capture-trace=true|false (default: false)
--capture-next-trace=true|false (default: true)
--trace-categories=<csv> (default: node,node.async_hooks,v8)
--artifact-dir=<path> (default: bench/render-pipeline/artifacts/<timestamp>)
`)
}
function parseCli(): CliOptions {
const rawArgs = process.argv.slice(2)
if (rawArgs.includes('--help')) {
usage()
process.exit(0)
}
const args = new Map<string, string>()
for (const rawArg of rawArgs) {
if (!rawArg.startsWith('--')) continue
const eq = rawArg.indexOf('=')
if (eq === -1) {
args.set(rawArg.slice(2), 'true')
} else {
args.set(rawArg.slice(2, eq), rawArg.slice(eq + 1))
}
}
const scenarioRaw = args.get('scenario') ?? 'e2e'
if (scenarioRaw !== 'minimal-server' && scenarioRaw !== 'e2e') {
throw new Error(
`Invalid --scenario value: ${scenarioRaw}. Use e2e|minimal-server`
)
}
const streamModeRaw = args.get('stream-mode') ?? 'node'
if (streamModeRaw !== 'node') {
throw new Error(`Invalid --stream-mode value: ${streamModeRaw}. Use node`)
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const artifactDir = resolve(
REPO_ROOT,
args.get('artifact-dir') ?? `bench/render-pipeline/artifacts/${timestamp}`
)
const routes = parseRoutes(args.get('routes'))
const build = parseBoolean(args.get('build') ?? 'true')
return {
scenario: scenarioRaw,
jsonOut: args.get('json-out'),
appDir: resolve(REPO_ROOT, args.get('app-dir') ?? 'bench/basic-app'),
routes,
streamMode: streamModeRaw,
build,
warmupRequests: parseNumberArg(args, 'warmup-requests', 50),
warmupUntilStable: parseBoolean(args.get('warmup-until-stable') ?? 'true'),
serialRequests: parseNumberArg(args, 'serial-requests', 120),
loadRequests: parseNumberArg(args, 'load-requests', 1200),
loadConcurrency: parseNumberArg(args, 'load-concurrency', 80),
timeoutMs: parseNumberArg(args, 'timeout-ms', 30_000),
port: parseNumberArg(args, 'port', 3199),
isolateRoutes: parseBoolean(args.get('isolate-routes') ?? 'false'),
captureCpu: parseBoolean(args.get('capture-cpu') ?? 'false'),
captureHeap: parseBoolean(args.get('capture-heap') ?? 'false'),
captureTrace: parseBoolean(args.get('capture-trace') ?? 'false'),
captureNextTrace: parseBoolean(args.get('capture-next-trace') ?? 'true'),
traceCategories: args.get('trace-categories') ?? 'node,node.async_hooks,v8',
artifactDir,
}
}
function computeStats(samples: number[]): BenchStats | null {
if (samples.length === 0) return null
const sorted = [...samples].sort((a, b) => a - b)
const min = sorted[0]
const max = sorted[sorted.length - 1]
const median = sorted[Math.floor(sorted.length / 2)]
const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length
const variance =
samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
samples.length
const stddev = Math.sqrt(variance)
const p95 = sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)]
return { min, median, mean, stddev, p95, max }
}
async function runCommand(
command: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv = process.env
): Promise<void> {
const child = spawn(command, args, {
cwd,
env,
stdio: 'inherit',
})
const [code] = (await once(child, 'exit')) as [number | null]
if (code !== 0) {
throw new Error(
`Command failed: ${command} ${args.join(' ')} (exit ${code})`
)
}
}
async function ensureNextBuilt() {
try {
await access(NEXT_BIN)
} catch {
throw new Error(
`Missing ${NEXT_BIN}. Build Next.js first (pnpm --filter=next build).`
)
}
}
async function waitForServerReady(
url: string,
timeoutMs: number,
serverDied?: () => boolean
): Promise<void> {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
// Without this check, a server that dies on startup (e.g. EADDRINUSE
// against a stale server on the same port) is indistinguishable from
// a slow one — worse, a 200 from whatever else owns the port would
// pass, and the run would silently measure the wrong server.
if (serverDied?.()) {
throw new Error(
`Server process exited before becoming ready (is port already in use?)`
)
}
try {
const response = await fetch(url, { cache: 'no-store' })
await response.arrayBuffer()
if (response.ok) return
} catch {
// server not ready yet
}
await sleep(200)
}
throw new Error(`Server did not become ready within ${timeoutMs}ms`)
}
function spawnedServerDied(server: ReturnType<typeof spawn>): () => boolean {
return () => server.exitCode !== null || server.signalCode !== null
}
// The death check alone is racy on a contested port: a stale server can
// answer the readiness probe before our child fails to bind, and the run
// would silently measure the wrong server.
async function assertPortFree(port: number): Promise<void> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 1000)
try {
await fetch(`http://127.0.0.1:${port}/`, {
cache: 'no-store',
signal: controller.signal,
})
} catch {
return
} finally {
clearTimeout(timeout)
}
throw new Error(
`Port ${port} is already serving responses — another server is running. ` +
`Stop it or pass a different --port.`
)
}
// Reads the body as a stream so TTFB (first body chunk) can be observed
// separately from total latency. Byte counts are of the decompressed body.
async function measureRequest(
url: string,
timeoutMs: number
): Promise<RequestSample> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const start = performance.now()
const response = await fetch(url, {
cache: 'no-store',
signal: controller.signal,
})
if (!response.ok) {
await response.arrayBuffer().catch(() => undefined)
throw new Error(`Request failed (${response.status}) for ${url}`)
}
let ttfbMs = -1
if (response.body) {
const reader = response.body.getReader()
while (true) {
const { done } = await reader.read()
if (done) break
if (ttfbMs < 0) ttfbMs = performance.now() - start
}
}
const totalMs = performance.now() - start
if (ttfbMs < 0) ttfbMs = totalMs
return { totalMs, ttfbMs }
} finally {
clearTimeout(timeout)
}
}
// Tolerates individual request failures (like the load phase) so one
// transient error costs one sample, not the whole run's results.
async function runSerialRequests(
url: string,
count: number,
timeoutMs: number
): Promise<{ samples: RequestSample[]; errors: number }> {
const samples: RequestSample[] = []
let errors = 0
for (let i = 0; i < count; i++) {
try {
samples.push(await measureRequest(url, timeoutMs))
} catch {
errors++
}
}
return { samples, errors }
}
async function inspectRouteDocument(
url: string,
route: string,
timeoutMs: number
): Promise<RouteDocumentInfo> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(url, {
cache: 'no-store',
signal: controller.signal,
})
const text = await response.text()
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${url}`)
}
const bytes = Buffer.byteLength(text)
const gzipBytes = zlib.gzipSync(text).byteLength
let inlineFlightBytes = 0
let inlineFlightScripts = 0
// Inline Flight scripts can carry attributes (e.g. a CSP nonce), so
// match any <script ...> tag; the content anchor identifies them.
const flightScript = /<script[^>]*>(self\.__next_f\.push\(.*?)<\/script>/gs
for (const match of text.matchAll(flightScript)) {
inlineFlightBytes += Buffer.byteLength(match[1])
inlineFlightScripts++
}
if (inlineFlightScripts === 0) {
// Every App Router document carries inline Flight scripts; a
// silent zero would corrupt the flight-share metric.
if (text.includes('__next_f')) {
throw new Error(
`[inspect] ${route}: document contains __next_f but the extraction regex matched no inline Flight scripts — the markup shape changed, update the regex in inspectRouteDocument`
)
}
throw new Error(
`[inspect] ${route}: not an App Router document (no __next_f) — this benchmark only measures App Router routes`
)
}
return {
route,
bytes,
gzipBytes,
inlineFlightBytes,
inlineFlightShare: bytes > 0 ? inlineFlightBytes / bytes : 0,
inlineFlightScripts,
}
} finally {
clearTimeout(timeout)
}
}
// Closed-loop load generator: each worker issues the next request only after
// the current one completes. This means throughput numbers are accurate for
// relative A/B comparison, but latency percentiles (p95, max) are optimistic
// because slow requests reduce back-pressure instead of queuing. Do not compare
// absolute latency values from this benchmark to open-loop tools (k6, wrk2).
async function runConcurrentRequests(
url: string,
totalRequests: number,
concurrency: number,
timeoutMs: number
): Promise<{ samples: RequestSample[]; errors: number }> {
const samples: RequestSample[] = []
let errors = 0
let index = 0
const workers = Array.from({ length: Math.max(1, concurrency) }, async () => {
while (true) {
const current = index
index++
if (current >= totalRequests) return
try {
samples.push(await measureRequest(url, timeoutMs))
} catch {
errors++
}
}
})
await Promise.all(workers)
return { samples, errors }
}
async function copyIfExists(fromPath: string, toPath: string) {
try {
await access(fromPath)
await copyFile(fromPath, toPath)
} catch {
// Ignore missing optional traces.
}
}
function formatKb(bytes: number): string {
return `${(bytes / 1024).toFixed(1)}KB`
}
function printFullResults(label: string, results: FullRunResult[]) {
console.log(`\n${label}`)
for (const result of results) {
console.log(`\nMode: ${result.mode}`)
for (const route of new Set(
result.routeResults.map((entry) => entry.route)
)) {
console.log(` Route: ${route}`)
const document = result.routeDocuments.find(
(entry) => entry.route === route
)
if (document) {
console.log(
` document ${formatKb(document.bytes)} gzip=${formatKb(document.gzipBytes)} flight=${formatKb(document.inlineFlightBytes)} (${(document.inlineFlightShare * 100).toFixed(1)}%, ${document.inlineFlightScripts} inline scripts)`
)
}
const routeEntries = result.routeResults.filter(
(entry) => entry.route === route
)
for (const entry of routeEntries) {
const errorSuffix = entry.errors > 0 ? ` errors=${entry.errors}` : ''
console.log(
` ${entry.phase} requests=${entry.requests} concurrency=${entry.concurrency}${errorSuffix}`
)
if (entry.latency === null || entry.ttfb === null) {
console.log(` all requests failed`)
continue
}
console.log(
` throughput=${entry.throughputRps.toFixed(2)} req/s median=${entry.latency.median.toFixed(2)}ms p95=${entry.latency.p95.toFixed(2)}ms stddev=${entry.latency.stddev.toFixed(2)}ms`
)
console.log(
` ttfb median=${entry.ttfb.median.toFixed(2)}ms p95=${entry.ttfb.p95.toFixed(2)}ms`
)
}
}
}
}
function buildNodeArgs(
options: CliOptions,
modeArtifactDir: string,
mode: string
): string[] {
const args: string[] = []
if (options.captureCpu) {
args.push(
'--cpu-prof',
`--cpu-prof-dir=${modeArtifactDir}`,
`--cpu-prof-name=${mode}.cpuprofile`
)
}
if (options.captureHeap) {
args.push(
'--heap-prof',
`--heap-prof-dir=${modeArtifactDir}`,
`--heap-prof-name=${mode}.heapprofile`
)
}
if (options.captureTrace) {
args.push(
'--trace-events-enabled',
`--trace-event-categories=${options.traceCategories}`,
`--trace-event-file-pattern=${resolve(modeArtifactDir, `${mode}-trace-\${pid}.json`)}`
)
}
return args
}
async function gracefulKill(server: ReturnType<typeof spawn>) {
// once('exit') never resolves for a child that already exited.
if (server.exitCode !== null || server.signalCode !== null) return
const tryKill = async (signal: NodeJS.Signals, timeoutMs: number) => {
server.kill(signal)
const didExit = await Promise.race([
once(server, 'exit')
.then(() => true)
.catch(() => true),
sleep(timeoutMs).then(() => false),
])
return didExit
}
if (!(await tryKill('SIGINT', 3000))) {
if (!(await tryKill('SIGTERM', 3000))) {
server.kill('SIGKILL')
await once(server, 'exit').catch(() => undefined)
}
}
}
async function runWarmup(
url: string,
batchSize: number,
untilStable: boolean,
timeoutMs: number,
label: string
): Promise<number> {
const maxBatches = 10
const stabilityThreshold = 0.05
let totalRequests = 0
let prevMean = Infinity
for (let batch = 0; batch < (untilStable ? maxBatches : 1); batch++) {
const { samples, errors } = await runSerialRequests(
url,
batchSize,
timeoutMs
)
totalRequests += batchSize
if (samples.length === 0) {
throw new Error(
`[${label}] warmup: all ${batchSize} requests failed — server is not serving this route`
)
}
if (errors > 0) {
console.warn(`[${label}] warmup: ${errors}/${batchSize} requests failed`)
}
const mean = samples.reduce((s, v) => s + v.totalMs, 0) / samples.length
if (untilStable && batch > 0) {
const delta = Math.abs(mean - prevMean) / prevMean
if (delta < stabilityThreshold) {
console.log(
`[${label}] warmup stabilized after ${totalRequests} requests ` +
`(batch ${batch + 1}, delta=${(delta * 100).toFixed(1)}%)`
)
return totalRequests
}
}
prevMean = mean
}
if (untilStable) {
console.log(
`[${label}] warmup did not fully stabilize after ${totalRequests} requests, proceeding`
)
}
return totalRequests
}
async function runRoutePhases(
options: CliOptions,
mode: StreamMode,
label: string
): Promise<{
routeResults: FullRoutePhaseResult[]
routeDocuments: RouteDocumentInfo[]
}> {
const routeResults: FullRoutePhaseResult[] = []
const routeDocuments: RouteDocumentInfo[] = []
for (const route of options.routes) {
const url = `http://127.0.0.1:${options.port}${route}`
const warmupLabel = `${label}/${mode} route ${route}`
console.log(
`[${label}/${mode}] route ${route}: warmup (batch=${options.warmupRequests}, untilStable=${options.warmupUntilStable})`
)
await runWarmup(
url,
options.warmupRequests,
options.warmupUntilStable,
options.timeoutMs,
warmupLabel
)
// One inspection request outside the timed phases: byte composition is
// deterministic per build, so a single sample suffices and the text
// scan never contaminates latency measurements.
routeDocuments.push(
await inspectRouteDocument(url, route, options.timeoutMs)
)
console.log(`[${label}/${mode}] route ${route}: single-client phase`)
const serialStart = performance.now()
const serialResult = await runSerialRequests(
url,
options.serialRequests,
options.timeoutMs
)
const serialDurationMs = performance.now() - serialStart
if (serialResult.errors > 0) {
console.warn(
`[${label}/${mode}] route ${route}: ${serialResult.errors}/${options.serialRequests} serial requests failed`
)
}
routeResults.push({
mode,
route,
phase: 'single-client',
requests: options.serialRequests,
errors: serialResult.errors,
concurrency: 1,
throughputRps: options.serialRequests / (serialDurationMs / 1000),
latency: computeStats(serialResult.samples.map((s) => s.totalMs)),
ttfb: computeStats(serialResult.samples.map((s) => s.ttfbMs)),
})
console.log(`[${label}/${mode}] route ${route}: under-load phase`)
const loadStart = performance.now()
const loadResult = await runConcurrentRequests(
url,
options.loadRequests,
options.loadConcurrency,
options.timeoutMs
)
const loadDurationMs = performance.now() - loadStart
if (loadResult.errors > 0) {
console.warn(
`[${label}/${mode}] route ${route}: ${loadResult.errors}/${options.loadRequests} requests failed`
)
}
routeResults.push({
mode,
route,
phase: 'under-load',
requests: options.loadRequests,
errors: loadResult.errors,
concurrency: options.loadConcurrency,
throughputRps: options.loadRequests / (loadDurationMs / 1000),
latency: computeStats(loadResult.samples.map((s) => s.totalMs)),
ttfb: computeStats(loadResult.samples.map((s) => s.ttfbMs)),
})
}
return { routeResults, routeDocuments }
}
// ---------------------------------------------------------------------------
// Scenario: minimal-server (minimalMode: true, no router-server)
// ---------------------------------------------------------------------------
async function runMinimalServerSession(
options: CliOptions,
mode: StreamMode,
modeArtifactDir: string,
routeSubset: CliOptions
): Promise<{
routeResults: FullRoutePhaseResult[]
routeDocuments: RouteDocumentInfo[]
}> {
let server: ReturnType<typeof spawn> | null = null
try {
console.log(`[minimal-server/${mode}] starting server...`)
await assertPortFree(options.port)
const serverArgs = buildNodeArgs(options, modeArtifactDir, mode)
serverArgs.push(MINIMAL_SERVER)
server = spawn('node', serverArgs, {
cwd: options.appDir,
env: {
...process.env,
NODE_ENV: 'production',
NEXT_TELEMETRY_DISABLED: '1',
PORT: String(options.port),
},
stdio: 'ignore',
})
await waitForServerReady(
`http://127.0.0.1:${options.port}${routeSubset.routes[0]}`,
options.timeoutMs,
spawnedServerDied(server)
)
return await runRoutePhases(routeSubset, mode, 'minimal-server')
} finally {
if (server) {
await gracefulKill(server)
}
if (options.captureNextTrace) {
await copyIfExists(
resolve(options.appDir, '.next/trace'),
resolve(modeArtifactDir, 'next-runtime-trace.log')
)
}
}
}
async function runMinimalServerModeBenchmark(
options: CliOptions,
mode: StreamMode
): Promise<FullRunResult> {
const modeArtifactDir = resolve(options.artifactDir, mode)
await mkdir(modeArtifactDir, { recursive: true })
if (options.build) {
await ensureGeneratedClientGraph(options)
console.log(`\n[minimal-server/${mode}] building app fixture...`)
await runCommand('node', [NEXT_BIN, 'build'], options.appDir, {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
})
if (options.captureNextTrace) {
await copyIfExists(
resolve(options.appDir, '.next/trace-build'),
resolve(modeArtifactDir, 'next-trace-build.log')
)
}
}
const routeResults: FullRoutePhaseResult[] = []
const routeDocuments: RouteDocumentInfo[] = []
if (options.isolateRoutes) {
for (let i = 0; i < options.routes.length; i++) {
if (i > 0) await sleep(1000)
const subset = { ...options, routes: [options.routes[i]] }
console.log(
`[minimal-server/${mode}] isolate-routes: restarting server for ${options.routes[i]}`
)
const session = await runMinimalServerSession(
options,
mode,
modeArtifactDir,
subset
)
routeResults.push(...session.routeResults)
routeDocuments.push(...session.routeDocuments)
}
} else {
const session = await runMinimalServerSession(
options,
mode,
modeArtifactDir,
options
)
routeResults.push(...session.routeResults)
routeDocuments.push(...session.routeDocuments)
}
return { mode, routeResults, routeDocuments }
}
async function runMinimalServerBenchmarks(
options: CliOptions
): Promise<FullRunResult[]> {
await ensureNextBuilt()
await mkdir(options.artifactDir, { recursive: true })
const results: FullRunResult[] = []
results.push(await runMinimalServerModeBenchmark(options, options.streamMode))
return results
}
// ---------------------------------------------------------------------------
async function ensureGeneratedClientGraph(options: CliOptions): Promise<void> {
const generator = resolve(options.appDir, 'scripts/generate-client-graph.mjs')
if (!existsSync(generator)) return
await runCommand('node', [generator], options.appDir, {
...process.env,
})
}
// Scenario: e2e (real production server via next build + next start)
// ---------------------------------------------------------------------------
async function runE2EServerSession(
options: CliOptions,
mode: StreamMode,
modeArtifactDir: string,
routeSubset: CliOptions
): Promise<{
routeResults: FullRoutePhaseResult[]
routeDocuments: RouteDocumentInfo[]
}> {
let server: ReturnType<typeof spawn> | null = null
try {
console.log(`[e2e/${mode}] starting production server (next start)...`)
await assertPortFree(options.port)
const serverArgs = buildNodeArgs(options, modeArtifactDir, mode)
serverArgs.push(NEXT_BIN, 'start', '--port', String(options.port))
server = spawn('node', serverArgs, {
cwd: options.appDir,
env: {
...process.env,
NODE_ENV: 'production',
NEXT_TELEMETRY_DISABLED: '1',
},
stdio: 'ignore',
})
await waitForServerReady(
`http://127.0.0.1:${options.port}${routeSubset.routes[0]}`,
options.timeoutMs,
spawnedServerDied(server)
)
return await runRoutePhases(routeSubset, mode, 'e2e')
} finally {
if (server) {
await gracefulKill(server)
}
if (options.captureNextTrace) {
await copyIfExists(
resolve(options.appDir, '.next/trace'),
resolve(modeArtifactDir, 'next-runtime-trace.log')
)
}
}
}
async function runE2EModeBenchmark(
options: CliOptions,
mode: StreamMode
): Promise<FullRunResult> {
const modeArtifactDir = resolve(options.artifactDir, mode)
await mkdir(modeArtifactDir, { recursive: true })
if (options.build) {
await ensureGeneratedClientGraph(options)
console.log(`\n[e2e/${mode}] building app fixture...`)
await runCommand('node', [NEXT_BIN, 'build'], options.appDir, {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
})
if (options.captureNextTrace) {
await copyIfExists(
resolve(options.appDir, '.next/trace-build'),
resolve(modeArtifactDir, 'next-trace-build.log')
)
}
}
const routeResults: FullRoutePhaseResult[] = []
const routeDocuments: RouteDocumentInfo[] = []
if (options.isolateRoutes) {
for (let i = 0; i < options.routes.length; i++) {
if (i > 0) await sleep(1000)
const subset = { ...options, routes: [options.routes[i]] }
console.log(
`[e2e/${mode}] isolate-routes: restarting server for ${options.routes[i]}`
)
const session = await runE2EServerSession(
options,
mode,
modeArtifactDir,
subset
)
routeResults.push(...session.routeResults)
routeDocuments.push(...session.routeDocuments)
}
} else {
const session = await runE2EServerSession(
options,
mode,
modeArtifactDir,
options
)
routeResults.push(...session.routeResults)
routeDocuments.push(...session.routeDocuments)
}
return { mode, routeResults, routeDocuments }
}
async function runE2EBenchmarks(options: CliOptions): Promise<FullRunResult[]> {
await ensureNextBuilt()
await mkdir(options.artifactDir, { recursive: true })
const results: FullRunResult[] = []
results.push(await runE2EModeBenchmark(options, options.streamMode))
return results
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const options = parseCli()
console.log('Render pipeline benchmark')
console.log(`scenario=${options.scenario}`)
let fullResults: FullRunResult[] | undefined
console.log(
`\nRunning ${options.scenario} benchmark: appDir=${options.appDir} streamMode=${options.streamMode}`
)
console.log(`routes=${options.routes.join(', ')}`)
console.log(`artifacts=${options.artifactDir}`)
if (options.scenario === 'e2e') {
fullResults = await runE2EBenchmarks(options)
printFullResults(
'E2E PRODUCTION SERVER BENCHMARKS (next build + next start)',
fullResults
)
} else {
fullResults = await runMinimalServerBenchmarks(options)
printFullResults(
'MINIMAL-SERVER BENCHMARKS (minimalMode, no router-server)',
fullResults
)
}
if (options.jsonOut) {
const outputPath = resolve(process.cwd(), options.jsonOut)
await writeFile(
outputPath,
JSON.stringify(
{
options,
fullResults,
generatedAt: new Date().toISOString(),
node: process.version,
},
null,
2
)
)
console.log(`\nWrote JSON report: ${outputPath}`)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})