mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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]>
686 lines
22 KiB
TypeScript
686 lines
22 KiB
TypeScript
// This script must be run with tsx
|
|
//
|
|
// Client-side attribution pass for the render-pipeline fixtures. Drives
|
|
// Chromium over the production server with CDP tracing enabled and
|
|
// decomposes main-thread time into framework-attributable buckets
|
|
// (per-chunk script compile/eval, inline Flight script cost, hydration,
|
|
// GC, long tasks). Paint milestones (FCP/LCP/DCL/load) are extracted from
|
|
// the same trace as free secondary rows — the buckets are the numbers to
|
|
// compare, milestones are sanity anchors.
|
|
//
|
|
// Tracing perturbs timing, so this is a separate opt-in pass (like
|
|
// --capture-cpu in benchmark.ts), not part of the default benchmark run.
|
|
// Attribution buckets are meaningful at low sample counts; the default is
|
|
// 3 samples per route.
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { once } from 'node:events'
|
|
import { existsSync } from 'node:fs'
|
|
import { access, mkdir, writeFile } from 'node:fs/promises'
|
|
import { resolve } from 'node:path'
|
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { chromium } from 'playwright'
|
|
import type { Browser, Page } from 'playwright'
|
|
|
|
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
|
const NEXT_BIN = resolve(REPO_ROOT, 'packages/next/dist/bin/next')
|
|
|
|
const TRACE_CATEGORIES = [
|
|
'devtools.timeline',
|
|
'disabled-by-default-devtools.timeline',
|
|
'blink.user_timing',
|
|
'loading',
|
|
'v8',
|
|
]
|
|
|
|
type CliOptions = {
|
|
appDir: string
|
|
routes: string[]
|
|
samples: number
|
|
cpuThrottle: number
|
|
build: boolean
|
|
startServer: boolean
|
|
port: number
|
|
timeoutMs: number
|
|
settleMs: number
|
|
artifactDir: string
|
|
jsonOut?: string
|
|
}
|
|
|
|
// All times are ms relative to navigationStart. Buckets are sums of
|
|
// main-thread trace event durations, except parseBackgroundMs which sums
|
|
// v8.parseOnBackgroundParsing across the renderer's script-streamer
|
|
// threads — actual off-main-thread parse CPU, not the enclosing
|
|
// v8.parseOnBackground wall time, which is dominated by waiting for
|
|
// network bytes. Buckets overlap: compile events nest inside eval
|
|
// events, and GC pauses can fire inside eval too. They are reported
|
|
// separately for attribution, not meant to sum to wall time. The
|
|
// blocking-time fields are null when the bench:hydrated mark was not
|
|
// observed — without it there is no well-defined pre-hydration window.
|
|
type TraceSample = {
|
|
fcpMs: number | null
|
|
lcpMs: number | null
|
|
domContentLoadedMs: number | null
|
|
loadMs: number | null
|
|
hydratedMs: number | null
|
|
evalChunksMs: number
|
|
evalChunkCount: number
|
|
evalInlineMs: number
|
|
evalInlineCount: number
|
|
compileMs: number
|
|
parseBackgroundMs: number
|
|
gcMs: number
|
|
longTasksToHydrated: number | null
|
|
blockingTimeToHydratedMs: number | null
|
|
jsTransferBytes: number
|
|
jsParsedBytes: number
|
|
jsFileCount: number
|
|
}
|
|
|
|
type RouteTraceResult = {
|
|
route: string
|
|
samples: TraceSample[]
|
|
median: TraceSample
|
|
}
|
|
|
|
type TraceEvent = {
|
|
name: string
|
|
cat: string
|
|
ph: string
|
|
ts: number
|
|
dur?: number
|
|
pid: number
|
|
tid: number
|
|
args?: {
|
|
data?: {
|
|
url?: string
|
|
documentLoaderURL?: string
|
|
}
|
|
}
|
|
}
|
|
|
|
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 usage() {
|
|
console.log(`Usage: pnpm bench:render-pipeline:client [options]
|
|
|
|
Options:
|
|
--app-dir=<path> (default: bench/basic-app)
|
|
--routes=/,/dashboard,... (default: /,/dashboard,/docs,/blog,/tailwind)
|
|
--samples=<number> (default: 3)
|
|
--cpu-throttle=<number> (default: 4) CDP CPU throttling rate
|
|
--build=true|false (default: false) build the fixture first
|
|
--start-server=true|false (default: true) set false to attach to a
|
|
server already running on --port
|
|
--port=<number> (default: 3199)
|
|
--timeout-ms=<number> (default: 30000)
|
|
--settle-ms=<number> (default: 750) post-hydration wait so
|
|
prefetch processing lands in the trace
|
|
--artifact-dir=<path> (default: bench/render-pipeline/artifacts/<timestamp>-client)
|
|
--json-out=<path>
|
|
`)
|
|
}
|
|
|
|
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 routes = (args.get('routes') ?? '/,/dashboard,/docs,/blog,/tailwind')
|
|
.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}`)
|
|
}
|
|
}
|
|
|
|
const samples = parseNumberArg(args, 'samples', 3)
|
|
if (samples < 1) {
|
|
throw new Error(`--samples must be at least 1, got ${samples}`)
|
|
}
|
|
// CDP rejects rates below 1 (1 = no throttling).
|
|
const cpuThrottle = parseNumberArg(args, 'cpu-throttle', 4)
|
|
if (cpuThrottle < 1) {
|
|
throw new Error(`--cpu-throttle must be at least 1, got ${cpuThrottle}`)
|
|
}
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
|
|
return {
|
|
appDir: resolve(REPO_ROOT, args.get('app-dir') ?? 'bench/basic-app'),
|
|
routes,
|
|
samples,
|
|
cpuThrottle,
|
|
build: parseBoolean(args.get('build') ?? 'false'),
|
|
startServer: parseBoolean(args.get('start-server') ?? 'true'),
|
|
port: parseNumberArg(args, 'port', 3199),
|
|
timeoutMs: parseNumberArg(args, 'timeout-ms', 30_000),
|
|
settleMs: parseNumberArg(args, 'settle-ms', 750),
|
|
artifactDir: resolve(
|
|
REPO_ROOT,
|
|
args.get('artifact-dir') ??
|
|
`bench/render-pipeline/artifacts/${timestamp}-client`
|
|
),
|
|
jsonOut: args.get('json-out'),
|
|
}
|
|
}
|
|
|
|
async function runCommand(
|
|
command: string,
|
|
args: string[],
|
|
cwd: string
|
|
): Promise<void> {
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
|
|
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 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`)
|
|
}
|
|
|
|
// 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, pass a different --port, or use --start-server=false to ` +
|
|
`trace against it deliberately.`
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
function median(values: number[]): number {
|
|
const sorted = [...values].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
// Average the middle pair for even counts: with samples=2, taking the
|
|
// upper-middle would report every field's worse run.
|
|
if (sorted.length % 2 === 0) {
|
|
return (sorted[mid - 1] + sorted[mid]) / 2
|
|
}
|
|
return sorted[mid]
|
|
}
|
|
|
|
function medianOrNull(values: Array<number | null>): number | null {
|
|
const present = values.filter((v): v is number => v !== null)
|
|
if (present.length === 0) return null
|
|
return median(present)
|
|
}
|
|
|
|
function medianSample(samples: TraceSample[]): TraceSample {
|
|
const num = (pick: (s: TraceSample) => number) => median(samples.map(pick))
|
|
const opt = (pick: (s: TraceSample) => number | null) =>
|
|
medianOrNull(samples.map(pick))
|
|
return {
|
|
fcpMs: opt((s) => s.fcpMs),
|
|
lcpMs: opt((s) => s.lcpMs),
|
|
domContentLoadedMs: opt((s) => s.domContentLoadedMs),
|
|
loadMs: opt((s) => s.loadMs),
|
|
hydratedMs: opt((s) => s.hydratedMs),
|
|
evalChunksMs: num((s) => s.evalChunksMs),
|
|
evalChunkCount: num((s) => s.evalChunkCount),
|
|
evalInlineMs: num((s) => s.evalInlineMs),
|
|
evalInlineCount: num((s) => s.evalInlineCount),
|
|
compileMs: num((s) => s.compileMs),
|
|
parseBackgroundMs: num((s) => s.parseBackgroundMs),
|
|
gcMs: num((s) => s.gcMs),
|
|
longTasksToHydrated: opt((s) => s.longTasksToHydrated),
|
|
blockingTimeToHydratedMs: opt((s) => s.blockingTimeToHydratedMs),
|
|
jsTransferBytes: num((s) => s.jsTransferBytes),
|
|
jsParsedBytes: num((s) => s.jsParsedBytes),
|
|
jsFileCount: num((s) => s.jsFileCount),
|
|
}
|
|
}
|
|
|
|
function analyzeTrace(
|
|
events: TraceEvent[],
|
|
pageUrl: string,
|
|
resourceInfo: {
|
|
transferBytes: number
|
|
parsedBytes: number
|
|
fileCount: number
|
|
}
|
|
): TraceSample {
|
|
// Trace event arrays are not guaranteed timestamp-ordered (per-thread
|
|
// buffers flush independently), so sort before any first/last lookup.
|
|
const sorted = [...events].sort((a, b) => a.ts - b.ts)
|
|
|
|
// The main-thread pid/tid pair is identified by the navigationStart
|
|
// user-timing event for our document; main-thread buckets filter on it
|
|
// so worker/compositor/browser-process events never pollute the sums.
|
|
const navStart = sorted.findLast(
|
|
(event) =>
|
|
event.name === 'navigationStart' &&
|
|
event.cat.includes('blink.user_timing') &&
|
|
event.args?.data?.documentLoaderURL?.startsWith(pageUrl.split('?')[0])
|
|
)
|
|
if (!navStart) {
|
|
throw new Error(`Trace has no navigationStart for ${pageUrl}`)
|
|
}
|
|
const { pid, tid, ts: navTs } = navStart
|
|
// Redirects (e.g. trailingSlash) can make the final document URL differ
|
|
// from the requested URL; inline-script eval events carry the former.
|
|
const documentUrl = navStart.args?.data?.documentLoaderURL ?? pageUrl
|
|
|
|
const onMain = (event: TraceEvent) =>
|
|
event.pid === pid && event.tid === tid && event.ts >= navTs
|
|
const relMs = (ts: number) => (ts - navTs) / 1000
|
|
|
|
const firstInstant = (name: string): number | null => {
|
|
const event = sorted.find((e) => e.name === name && onMain(e))
|
|
return event ? relMs(event.ts) : null
|
|
}
|
|
const lastInstant = (name: string): number | null => {
|
|
const event = sorted.findLast((e) => e.name === name && onMain(e))
|
|
return event ? relMs(event.ts) : null
|
|
}
|
|
|
|
const hydratedEvent = sorted.find(
|
|
(e) => e.name === 'bench:hydrated' && onMain(e)
|
|
)
|
|
const hydratedTs = hydratedEvent?.ts ?? null
|
|
|
|
let evalChunksMs = 0
|
|
let evalInlineMs = 0
|
|
let evalInlineCount = 0
|
|
let compileMs = 0
|
|
let parseBackgroundMs = 0
|
|
let gcMs = 0
|
|
let longTasksToHydrated = 0
|
|
let blockingTimeToHydratedMs = 0
|
|
const chunkUrls = new Set<string>()
|
|
|
|
for (const event of sorted) {
|
|
if (event.ph !== 'X' || !event.dur || event.ts < navTs) continue
|
|
const durMs = event.dur / 1000
|
|
|
|
// Script streaming parses large external scripts on background
|
|
// threads of the same renderer process. Sum the nested *Parsing
|
|
// events (actual parse CPU), not the enclosing v8.parseOnBackground,
|
|
// whose wall time is mostly waiting on network bytes.
|
|
if (event.name === 'v8.parseOnBackgroundParsing' && event.pid === pid) {
|
|
parseBackgroundMs += durMs
|
|
continue
|
|
}
|
|
|
|
if (event.pid !== pid || event.tid !== tid) continue
|
|
|
|
if (event.name === 'EvaluateScript') {
|
|
const url = event.args?.data?.url ?? ''
|
|
if (url.includes('/_next/static/')) {
|
|
evalChunksMs += durMs
|
|
chunkUrls.add(url)
|
|
} else if (url === documentUrl) {
|
|
// Inline scripts carry the document URL. Evals with no URL are
|
|
// excluded: those are the harness's own waitForFunction/evaluate
|
|
// calls, not page work. This bucket mixes `self.__next_f.push`
|
|
// Flight scripts with Fizz's tiny `$RS`/`$RC` boundary-reveal
|
|
// scripts; the trace does not expose script contents, but Flight
|
|
// pushes dominate duration.
|
|
evalInlineMs += durMs
|
|
evalInlineCount++
|
|
}
|
|
} else if (event.name === 'v8.compile') {
|
|
compileMs += durMs
|
|
} else if (event.name === 'MinorGC' || event.name === 'MajorGC') {
|
|
// Only the top-level pause events: the 'v8' category also emits
|
|
// nested V8.GC_* sub-phase events for the same pauses, and summing
|
|
// those would multi-count GC time.
|
|
gcMs += durMs
|
|
} else if (event.name === 'RunTask' && hydratedTs !== null) {
|
|
// Count only the pre-hydration portion of the task: the task that
|
|
// commits hydration typically keeps running afterwards, and its
|
|
// tail is post-hydration work.
|
|
const clippedMs =
|
|
(Math.min(event.ts + event.dur, hydratedTs) - event.ts) / 1000
|
|
if (clippedMs > 50) {
|
|
longTasksToHydrated++
|
|
blockingTimeToHydratedMs += clippedMs - 50
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
fcpMs: firstInstant('firstContentfulPaint'),
|
|
lcpMs: lastInstant('largestContentfulPaint::Candidate'),
|
|
domContentLoadedMs: firstInstant('MarkDOMContent'),
|
|
loadMs: firstInstant('MarkLoad'),
|
|
hydratedMs: hydratedTs !== null ? relMs(hydratedTs) : null,
|
|
evalChunksMs,
|
|
evalChunkCount: chunkUrls.size,
|
|
evalInlineMs,
|
|
evalInlineCount,
|
|
compileMs,
|
|
parseBackgroundMs,
|
|
gcMs,
|
|
longTasksToHydrated: hydratedTs !== null ? longTasksToHydrated : null,
|
|
blockingTimeToHydratedMs:
|
|
hydratedTs !== null ? blockingTimeToHydratedMs : null,
|
|
jsTransferBytes: resourceInfo.transferBytes,
|
|
jsParsedBytes: resourceInfo.parsedBytes,
|
|
jsFileCount: resourceInfo.fileCount,
|
|
}
|
|
}
|
|
|
|
async function traceRoute(
|
|
browser: Browser,
|
|
options: CliOptions,
|
|
route: string
|
|
): Promise<RouteTraceResult | null> {
|
|
const url = `http://127.0.0.1:${options.port}${route}`
|
|
const samples: TraceSample[] = []
|
|
|
|
for (let i = 0; i < options.samples; i++) {
|
|
// Fresh context per sample: no HTTP cache or compile cache reuse, so
|
|
// every sample is a cold visit.
|
|
const context = await browser.newContext()
|
|
const page: Page = await context.newPage()
|
|
let tracing = false
|
|
try {
|
|
const cdp = await context.newCDPSession(page)
|
|
await cdp.send('Emulation.setCPUThrottlingRate', {
|
|
rate: options.cpuThrottle,
|
|
})
|
|
|
|
await browser.startTracing(page, {
|
|
screenshots: false,
|
|
categories: TRACE_CATEGORIES,
|
|
})
|
|
tracing = true
|
|
|
|
await page.goto(url, {
|
|
waitUntil: 'load',
|
|
timeout: options.timeoutMs,
|
|
})
|
|
await page
|
|
.waitForFunction(
|
|
() => performance.getEntriesByName('bench:hydrated').length > 0,
|
|
undefined,
|
|
{ timeout: options.timeoutMs }
|
|
)
|
|
.catch(() => {
|
|
console.warn(
|
|
`[client-trace] ${route}: bench:hydrated mark not observed (sample ${i + 1})`
|
|
)
|
|
})
|
|
await page.waitForTimeout(options.settleMs)
|
|
|
|
const resourceInfo = await page.evaluate(() => {
|
|
const entries = performance
|
|
.getEntriesByType('resource')
|
|
.filter(
|
|
(entry) =>
|
|
entry.name.includes('/_next/static/') &&
|
|
new URL(entry.name).pathname.endsWith('.js')
|
|
) as PerformanceResourceTiming[]
|
|
return {
|
|
transferBytes: entries.reduce((s, e) => s + e.transferSize, 0),
|
|
parsedBytes: entries.reduce((s, e) => s + e.decodedBodySize, 0),
|
|
fileCount: entries.length,
|
|
}
|
|
})
|
|
|
|
const traceBuffer = await browser.stopTracing()
|
|
tracing = false
|
|
if (i === 0) {
|
|
const slug = route.replace(/\W+/g, '-').replace(/^-|-$/g, '') || 'root'
|
|
await writeFile(
|
|
resolve(options.artifactDir, `client-trace-${slug}.json`),
|
|
traceBuffer
|
|
)
|
|
}
|
|
|
|
const parsed = JSON.parse(traceBuffer.toString('utf8'))
|
|
const events: TraceEvent[] = Array.isArray(parsed)
|
|
? parsed
|
|
: parsed.traceEvents
|
|
samples.push(analyzeTrace(events, url, resourceInfo))
|
|
} catch (error) {
|
|
// A failed sample (navigation timeout, CDP error) should cost one
|
|
// sample, not the whole run's collected results.
|
|
console.warn(
|
|
`[client-trace] ${route}: sample ${i + 1} failed: ${
|
|
error instanceof Error ? error.message : error
|
|
}`
|
|
)
|
|
} finally {
|
|
if (tracing) {
|
|
// Tracing survives context.close(); the next startTracing would
|
|
// fail if a trace is still active.
|
|
await browser.stopTracing().catch(() => undefined)
|
|
}
|
|
await context.close()
|
|
}
|
|
}
|
|
|
|
if (samples.length === 0) {
|
|
console.warn(`[client-trace] ${route}: all samples failed, skipping route`)
|
|
return null
|
|
}
|
|
return { route, samples, median: medianSample(samples) }
|
|
}
|
|
|
|
function formatMs(value: number | null): string {
|
|
return value === null ? 'n/a' : `${value.toFixed(0)}ms`
|
|
}
|
|
|
|
function formatKb(bytes: number): string {
|
|
return `${(bytes / 1024).toFixed(1)}KB`
|
|
}
|
|
|
|
function printResults(options: CliOptions, results: RouteTraceResult[]) {
|
|
console.log(
|
|
`\nCLIENT TRACE (samples=${options.samples}, cpuThrottle=${options.cpuThrottle}x, cold visit per sample)`
|
|
)
|
|
for (const { route, median: m } of results) {
|
|
console.log(`\n Route: ${route}`)
|
|
console.log(
|
|
` milestones FCP=${formatMs(m.fcpMs)} LCP=${formatMs(m.lcpMs)} DCL=${formatMs(m.domContentLoadedMs)} load=${formatMs(m.loadMs)} hydrated=${formatMs(m.hydratedMs)}`
|
|
)
|
|
console.log(
|
|
` main thread eval-chunks=${m.evalChunksMs.toFixed(1)}ms (${m.evalChunkCount} files) eval-inline=${m.evalInlineMs.toFixed(1)}ms (${m.evalInlineCount} scripts) compile=${m.compileMs.toFixed(1)}ms gc=${m.gcMs.toFixed(1)}ms`
|
|
)
|
|
console.log(
|
|
` off thread parse-background=${m.parseBackgroundMs.toFixed(1)}ms`
|
|
)
|
|
console.log(
|
|
` blocking longTasks(pre-hydration)=${m.longTasksToHydrated ?? 'n/a'} tbt=${m.blockingTimeToHydratedMs === null ? 'n/a' : `${m.blockingTimeToHydratedMs.toFixed(1)}ms`}`
|
|
)
|
|
console.log(
|
|
` js ${formatKb(m.jsTransferBytes)} transferred / ${formatKb(m.jsParsedBytes)} parsed (${m.jsFileCount} files)`
|
|
)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseCli()
|
|
|
|
try {
|
|
await access(NEXT_BIN)
|
|
} catch {
|
|
throw new Error(
|
|
`Missing ${NEXT_BIN}. Build Next.js first (pnpm --filter=next build).`
|
|
)
|
|
}
|
|
|
|
await mkdir(options.artifactDir, { recursive: true })
|
|
|
|
if (options.build) {
|
|
const generator = resolve(
|
|
options.appDir,
|
|
'scripts/generate-client-graph.mjs'
|
|
)
|
|
if (existsSync(generator)) {
|
|
await runCommand('node', [generator], options.appDir)
|
|
}
|
|
console.log('[client-trace] building app fixture...')
|
|
await runCommand('node', [NEXT_BIN, 'build'], options.appDir)
|
|
}
|
|
|
|
let server: ReturnType<typeof spawn> | null = null
|
|
let browser: Browser | null = null
|
|
try {
|
|
if (options.startServer) {
|
|
console.log('[client-trace] starting production server (next start)...')
|
|
await assertPortFree(options.port)
|
|
server = spawn(
|
|
'node',
|
|
[NEXT_BIN, 'start', '--port', String(options.port)],
|
|
{
|
|
cwd: options.appDir,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: 'production',
|
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
},
|
|
stdio: 'ignore',
|
|
}
|
|
)
|
|
}
|
|
await waitForServerReady(
|
|
`http://127.0.0.1:${options.port}${options.routes[0]}`,
|
|
options.timeoutMs,
|
|
() =>
|
|
server !== null &&
|
|
(server.exitCode !== null || server.signalCode !== null)
|
|
)
|
|
|
|
browser = await chromium.launch()
|
|
|
|
const results: RouteTraceResult[] = []
|
|
for (const route of options.routes) {
|
|
console.log(`[client-trace] route ${route}: ${options.samples} samples`)
|
|
const result = await traceRoute(browser, options, route)
|
|
if (result) results.push(result)
|
|
}
|
|
|
|
printResults(options, results)
|
|
|
|
const report = {
|
|
options,
|
|
results,
|
|
generatedAt: new Date().toISOString(),
|
|
node: process.version,
|
|
}
|
|
const reportPath = resolve(options.artifactDir, 'client-trace.json')
|
|
await writeFile(reportPath, JSON.stringify(report, null, 2))
|
|
console.log(`\nWrote JSON report: ${reportPath}`)
|
|
if (options.jsonOut) {
|
|
await writeFile(
|
|
resolve(process.cwd(), options.jsonOut),
|
|
JSON.stringify(report, null, 2)
|
|
)
|
|
}
|
|
} finally {
|
|
if (browser) await browser.close()
|
|
if (server) await gracefulKill(server)
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|