first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

192
bench/BENCHMARKING.md Normal file
View File

@@ -0,0 +1,192 @@
# Benchmarking Playbook (Render Pipeline / Node Streams)
This is the practical workflow for benchmarking and profiling render pipeline changes in this repo.
Primary tools:
- `pnpm bench:render-pipeline`
- `pnpm bench:render-pipeline:analyze`
## 1. Build-first baseline
Always rebuild `next` before benchmark runs when framework source changed.
```bash
pnpm --filter=next build
```
## 2. End-to-end benchmark (full app render path)
This measures the full request path (`renderToHTMLOrFlight`) through `bench/next-minimal-server`.
In `scenario=full` and `scenario=all`, `--capture-cpu` defaults to `true`.
Node streams only:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Web vs Node comparison:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=both \
--build-full=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
## 3. Route-focused stress runs
Use this when targeting streaming-heavy behavior only.
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--routes=/streaming/heavy,/streaming/chunkstorm,/streaming/wide \
--warmup-requests=10 \
--serial-requests=40 \
--load-requests=400 \
--load-concurrency=40 \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Default stress routes currently include:
- `/`
- `/streaming/light`
- `/streaming/medium`
- `/streaming/heavy`
- `/streaming/chunkstorm`
- `/streaming/wide`
- `/streaming/bulk`
## 4. Isolate helper-level costs (micro scenario)
Use this to quickly test helper-level changes before full runs.
```bash
pnpm bench:render-pipeline \
--scenario=micro \
--iterations=300 \
--warmup=30
```
Micro benchmark output includes cases for:
- `teeNodeReadable`
- `createBufferedTransformNode`
- `createInlinedDataNodeStream`
- `continueStaticPrerender` / `continueDynamicPrerender` / `continueDynamicHTMLResume`
Flight payload mode toggles:
```bash
# Binary-heavy flight chunks
pnpm bench:render-pipeline --scenario=micro --binary-flight=true
# UTF-8-heavy flight chunks
pnpm bench:render-pipeline --scenario=micro --binary-flight=false
```
Stress payload shape:
```bash
pnpm bench:render-pipeline \
--scenario=micro \
--iterations=300 \
--warmup=30 \
--flight-chunks=128 \
--flight-chunk-bytes=8192 \
--html-chunks=128 \
--html-chunk-bytes=32768
```
## 5. Capture CPU profiles and traces
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=node \
--build-full=true \
--capture-trace=true \
--capture-next-trace=true \
--json-out=bench/render-pipeline/artifacts/<run>/results.json \
--artifact-dir=bench/render-pipeline/artifacts/<run>
```
Artifacts are written under:
- `bench/render-pipeline/artifacts/<run>/node/node.cpuprofile`
- `bench/render-pipeline/artifacts/<run>/node/node-trace-*.json`
- `bench/render-pipeline/artifacts/<run>/node/next-runtime-trace.log`
- `bench/render-pipeline/artifacts/<run>/results.json`
## 6. Analyze hotspots
```bash
pnpm bench:render-pipeline:analyze \
--artifact-dir=bench/render-pipeline/artifacts/<run> \
--top=20
```
Filter only the Node-stream-relevant hotspots:
```bash
pnpm bench:render-pipeline:analyze --artifact-dir=bench/render-pipeline/artifacts/<run> --top=20 > /tmp/analyze.txt
rg "use-flight-response|encodeFlightDataChunkNode|node-stream-tee|flushPending|node-stream-helpers|htmlEscapeJsonString" /tmp/analyze.txt
```
## 7. Compare two runs quickly
```bash
node - <<'NODE'
const fs = require('fs')
const [baseRun, candRun] = process.argv.slice(2)
const load = (name) =>
JSON.parse(
fs.readFileSync(`bench/render-pipeline/artifacts/${name}/results.json`, 'utf8')
).fullResults[0].routeResults
const base = load(baseRun)
const cand = load(candRun)
for (const b of base) {
const c = cand.find((x) => x.route === b.route && x.phase === b.phase)
if (!c) continue
const throughputDelta =
((c.throughputRps - b.throughputRps) / b.throughputRps) * 100
const p95Delta = ((b.latency.p95 - c.latency.p95) / b.latency.p95) * 100
console.log(
`${b.route} ${b.phase} throughput ${throughputDelta >= 0 ? '+' : ''}${throughputDelta.toFixed(2)}% p95 ${p95Delta >= 0 ? '+' : ''}${p95Delta.toFixed(2)}%`
)
}
NODE investigation-10-boundary-data investigation-17-profile-current
```
## 8. Noise control rules
Use these rules to keep measurements trustworthy:
- Build first (`pnpm --filter=next build`) after framework source changes.
- Compare runs with identical route sets and request knobs.
- Repeat suspicious runs at least once (especially if one route regresses while others improve).
- Use dedicated artifact directories per run.
- Prefer relative deltas across multiple runs over one-off absolute numbers.
## 9. Suggested iteration loop
1. Change one thing.
2. Build.
3. Run `scenario=micro` for quick signal.
4. Run focused full stress (`heavy/chunkstorm/wide`) with CPU profile.
5. Analyze hotspots and compare deltas.
6. Keep only changes that hold up across repeat runs.

View File

@@ -0,0 +1,10 @@
import * as React from 'react'
export default function Root({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,5 @@
import * as React from 'react'
export default function page() {
return <div>hello</div>
}

View File

@@ -0,0 +1,5 @@
module.exports = {
experimental: {
appDir: true,
},
}

View File

@@ -0,0 +1,15 @@
{
"name": "bench-app-router-server",
"private": true,
"license": "MIT",
"dependencies": {
"webpack-bundle-analyzer": "^4.6.1",
"webpack-stats-plugin": "^1.1.0",
"next": "workspace:*",
"next-minimal-server": "workspace:*"
},
"scripts": {
"build-application": "next build",
"start": "next-minimal-server"
}
}

View File

@@ -0,0 +1,9 @@
import * as React from 'react'
export default function page() {
return <div> hello world </div>
}
export async function getServerSideProps() {
return {}
}

View File

@@ -0,0 +1,5 @@
export function GET() {
return Response.json({ name: 'John Doe' })
}
export const dynamic = 'force-dynamic'

View File

@@ -0,0 +1,12 @@
import React from 'react'
export default function Layout({ children }) {
return (
<html>
<head>
<title>My App</title>
</head>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,7 @@
import React from 'react'
export default function Page() {
return <h1>My Page</h1>
}
export const dynamic = 'force-dynamic'

View File

@@ -0,0 +1,20 @@
'use client'
import React from 'react'
export function StreamingClientBoundary({
chunkId,
payload,
fragments,
checksum,
}) {
return (
<section data-client-boundary={chunkId}>
<h3>client-{chunkId}</h3>
<p>checksum:{checksum}</p>
<p>payload-bytes:{payload.length}</p>
<p>fragment-count:{fragments.length}</p>
<p>{fragments[0] ?? ''}</p>
</section>
)
}

View File

@@ -0,0 +1,105 @@
import React, { Suspense } from 'react'
import { StreamingClientBoundary } from './client-boundary'
function sleep(ms) {
if (ms <= 0) return Promise.resolve()
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createPayload(title, payloadBytes) {
const prefix = `${title}:`
if (prefix.length >= payloadBytes) return prefix
return `${prefix}${'x'.repeat(payloadBytes - prefix.length)}`
}
function createClientPayload({ title, id, payloadBytes, fragmentCount }) {
const payload = createPayload(`${title}-client-${id}`, payloadBytes)
const safeFragmentCount = Math.max(1, fragmentCount)
const fragmentSize = Math.max(
16,
Math.floor(payload.length / safeFragmentCount)
)
const fragments = Array.from({ length: safeFragmentCount }, (_, index) => {
const start = Math.min(index * fragmentSize, payload.length)
const end = Math.min(start + fragmentSize, payload.length)
return payload.slice(start, end)
})
return {
chunkId: id,
payload,
fragments,
checksum: payload.length + id * 31 + safeFragmentCount,
}
}
async function StreamedChunk({
title,
id,
delayMs,
payload,
clientPayloadBytes,
clientPayloadFragments,
}) {
await sleep(delayMs)
const clientPayload = createClientPayload({
title,
id,
payloadBytes: clientPayloadBytes,
fragmentCount: clientPayloadFragments,
})
return (
<article data-chunk-id={id}>
<h2>chunk-{id}</h2>
<p>{payload}</p>
<StreamingClientBoundary {...clientPayload} />
</article>
)
}
export function StreamingStressPage({
title,
boundaryCount,
payloadBytes,
clientPayloadBytes = Math.max(128, Math.floor(payloadBytes / 2)),
clientPayloadFragments = 4,
maxDelayMs,
}) {
const payload = createPayload(title, payloadBytes)
const boundaries = Array.from({ length: boundaryCount }, (_, index) => index)
return (
<main>
<h1>{title}</h1>
<p>
boundaries={boundaryCount} payloadBytes={payloadBytes}{' '}
clientPayloadBytes=
{clientPayloadBytes} clientPayloadFragments={clientPayloadFragments}{' '}
maxDelayMs={maxDelayMs}
</p>
{boundaries.map((id) => {
const delayMs = maxDelayMs === 0 ? 0 : id % (maxDelayMs + 1)
return (
<Suspense
key={id}
fallback={<div data-fallback-id={id}>loading-{id}</div>}
>
<StreamedChunk
title={title}
id={id}
delayMs={delayMs}
payload={payload}
clientPayloadBytes={clientPayloadBytes}
clientPayloadFragments={clientPayloadFragments}
/>
</Suspense>
)
})}
</main>
)
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
export const dynamic = 'force-dynamic'
const ROWS = 2500
const PAYLOAD = 'x'.repeat(384)
const DATA = Array.from(
{ length: ROWS },
(_, index) => `row-${index}-${PAYLOAD}`
)
export default function Page() {
return (
<main>
<h1>stream-bulk</h1>
{DATA.map((line, index) => (
<p key={index}>{line}</p>
))}
</main>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-chunkstorm"
boundaryCount={960}
payloadBytes={192}
clientPayloadBytes={1024}
clientPayloadFragments={4}
maxDelayMs={3}
/>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-heavy"
boundaryCount={240}
payloadBytes={1536}
clientPayloadBytes={3072}
clientPayloadFragments={6}
maxDelayMs={8}
/>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-light"
boundaryCount={24}
payloadBytes={512}
clientPayloadBytes={384}
clientPayloadFragments={2}
maxDelayMs={2}
/>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-medium"
boundaryCount={96}
payloadBytes={1024}
clientPayloadBytes={1024}
clientPayloadFragments={4}
maxDelayMs={4}
/>
)
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import { StreamingStressPage } from '../_shared/stress-page'
export const dynamic = 'force-dynamic'
export default function Page() {
return (
<StreamingStressPage
title="stream-wide"
boundaryCount={120}
payloadBytes={8192}
clientPayloadBytes={16384}
clientPayloadFragments={8}
maxDelayMs={6}
/>
)
}

View File

@@ -0,0 +1,142 @@
#!/bin/bash
# Benchmark script for comparing web streams vs node streams performance.
# Uses the minimal server (bench/next-minimal-server) for lowest overhead.
# Warms up with 50 requests, then runs two phases:
# Phase 1: 10s at concurrency=1 (single-client latency)
# Phase 2: 10s at concurrency=100 (throughput under load)
# Reports throughput and latency percentiles for each phase.
#
# Usage:
# ./benchmark.sh [duration] [warmup_requests]
#
# Defaults: 10s duration per phase, 50 warmup requests
set -euo pipefail
DURATION=${1:-10}
WARMUP_REQS=${2:-50}
PORT=3199
NEXT_BIN="../../packages/next/dist/bin/next"
MINIMAL_SERVER="../next-minimal-server/bin/minimal-server.js"
if ! command -v npx &>/dev/null; then
echo "npx is required (for autocannon)"
exit 1
fi
cleanup() {
lsof -ti :"$PORT" 2>/dev/null | xargs kill -9 2>/dev/null || true
}
trap cleanup EXIT
start_server() {
cleanup
sleep 0.5
PORT=$PORT node "$MINIMAL_SERVER" &>/dev/null &
SERVER_PID=$!
# Wait for server to be ready
local retries=0
while ! curl -sf "http://localhost:$PORT" >/dev/null 2>&1; do
retries=$((retries + 1))
if [ "$retries" -gt 30 ]; then
echo "ERROR: Server failed to start after 15s"
exit 1
fi
sleep 0.5
done
}
stop_server() {
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
cleanup
sleep 1
}
warmup() {
echo " Warming up ($WARMUP_REQS requests)..."
for i in $(seq 1 "$WARMUP_REQS"); do
curl -sf "http://localhost:$PORT" >/dev/null 2>&1 || true
done
sleep 0.5
}
run_phase() {
local label="$1"
local connections="$2"
echo ""
echo " --- $label (${DURATION}s, c=$connections) ---"
local result
result=$(npx autocannon -d "$DURATION" -c "$connections" -j "http://localhost:$PORT" 2>/dev/null)
node -e "
const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const r = d.requests;
const l = d.latency;
console.log(' Throughput:');
console.log(' avg: ' + r.average + ' req/s');
console.log(' mean: ' + r.mean + ' req/s');
console.log(' total: ' + r.total + ' requests in ${DURATION}s');
console.log(' Latency:');
console.log(' avg: ' + l.average.toFixed(2) + ' ms');
console.log(' p50: ' + l.p50.toFixed(2) + ' ms');
console.log(' p90: ' + l.p90.toFixed(2) + ' ms');
console.log(' p99: ' + l.p99.toFixed(2) + ' ms');
console.log(' max: ' + l.max.toFixed(2) + ' ms');
" <<< "$result"
}
run_benchmark() {
local mode="$1"
echo ""
echo "============================================"
echo " $mode"
echo "============================================"
start_server
warmup
run_phase "Single client" 1
run_phase "Under load" 100
stop_server
}
echo "Benchmark: web streams vs node streams"
echo "======================================="
echo "Duration: ${DURATION}s per phase | Warmup: ${WARMUP_REQS} reqs"
echo "Server: minimal-server (minimalMode: true)"
# --- Web Streams (default) ---
cat > next.config.js <<'CONF'
module.exports = {}
CONF
echo ""
echo "Building (web streams)..."
node "$NEXT_BIN" build &>/dev/null
run_benchmark "Web Streams (default)"
# --- Node Streams ---
cat > next.config.js <<'CONF'
module.exports = {
experimental: {
useNodeStreams: true,
},
}
CONF
echo ""
echo "Building (node streams)..."
node "$NEXT_BIN" build &>/dev/null
run_benchmark "Node Streams (useNodeStreams: true)"
# Restore config
cat > next.config.js <<'CONF'
module.exports = {}
CONF
echo ""
echo "Done."

View File

@@ -0,0 +1 @@
module.exports = {}

View File

@@ -0,0 +1,3 @@
export default function handler(req, res) {
res.status(200).json({ name: 'John Doe' })
}

View File

@@ -0,0 +1,7 @@
export default () => 'Hello World'
export function getServerSideProps() {
return {
props: {},
}
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const getSequenceGenerator = require('random-seed')
const generate = require('@babel/generator').default
const t = require('@babel/types')
const MIN_COMPONENT_NAME_LEN = 18
const MAX_COMPONENT_NAME_LEN = 24
const MIN_CHILDREN = 4
const MAX_CHILDREN = 80
const arrayUntil = (len) => [...Array(len)].map((_, i) => i)
const generateFunctionalComponentModule = (componentName, children = []) => {
const body = [
generateImport('React', 'react'),
...children.map((childName) => generateImport(childName, `./${childName}`)),
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(componentName),
t.arrowFunctionExpression(
[],
t.parenthesizedExpression(
generateJSXElement(
'div',
children.map((childName) => generateJSXElement(childName))
)
)
)
),
]),
t.exportDefaultDeclaration(t.identifier(componentName)),
]
return t.program(body, [], 'module')
}
const generateJSXElement = (componentName, children = null) =>
t.JSXElement(
t.JSXOpeningElement(t.JSXIdentifier(componentName), [], !children),
children ? t.JSXClosingElement(t.JSXIdentifier(componentName)) : null,
children || [],
!children
)
const generateImport = (componentName, requireString) =>
t.importDeclaration(
[t.importDefaultSpecifier(t.identifier(componentName))],
t.stringLiteral(requireString)
)
const validFirstChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const validOtherChars = validFirstChars.toLowerCase()
function generateComponentName(seqGenerator, opts) {
const numOtherChars = seqGenerator.intBetween(opts.minLen, opts.maxLen)
const firstChar = validFirstChars[seqGenerator.range(validFirstChars.length)]
const otherChars = arrayUntil(numOtherChars).map(
() => validOtherChars[seqGenerator.range(validOtherChars.length)]
)
return `${firstChar}${otherChars.join('')}`
}
function* generateModules(name, remainingDepth, seqGenerator, opts) {
const filename = `${name}.${opts.extension}`
let ast
if (name === 'index') {
name = 'RootComponent'
}
if (remainingDepth === 0) {
ast = generateFunctionalComponentModule(name)
} else {
const numChildren = seqGenerator.intBetween(opts.minChild, opts.maxChild)
const children = arrayUntil(numChildren).map(() =>
generateComponentName(seqGenerator, opts)
)
ast = generateFunctionalComponentModule(name, children)
for (const child of children) {
yield* generateModules(child, remainingDepth - 1, seqGenerator, opts)
}
}
yield {
filename,
content: generate(ast).code,
}
}
function generateFuzzponents(outdir, seed, depth, opts) {
const seqGenerator = getSequenceGenerator(seed)
const filenames = new Set()
for (const { filename, content } of generateModules(
'index',
depth,
seqGenerator,
opts
)) {
if (filenames.has(filename)) {
throw new Error(
`Seed "${seed}" generates output with filename collisions.`
)
} else {
filenames.add(filename)
}
const fpath = path.join(outdir, filename)
fs.writeFileSync(fpath, `// ${filename}\n\n${content}`)
}
}
if (require.main === module) {
const { outdir, seed, depth, ...opts } = require('yargs')
.option('depth', {
alias: 'd',
demandOption: true,
describe: 'component hierarchy depth',
type: 'number',
})
.option('seed', {
alias: 's',
demandOption: true,
describe: 'prng seed',
type: 'number',
})
.option('outdir', {
alias: 'o',
demandOption: false,
default: process.cwd(),
describe: 'the directory where components should be written',
type: 'string',
normalize: true,
})
.option('minLen', {
demandOption: false,
default: MIN_COMPONENT_NAME_LEN,
describe: 'the smallest acceptable component name length',
type: 'number',
})
.option('maxLen', {
demandOption: false,
default: MAX_COMPONENT_NAME_LEN,
describe: 'the largest acceptable component name length',
type: 'number',
})
.option('minLen', {
demandOption: false,
default: MIN_COMPONENT_NAME_LEN,
describe: 'the smallest acceptable component name length',
type: 'number',
})
.option('maxLen', {
demandOption: false,
default: MAX_COMPONENT_NAME_LEN,
describe: 'the largest acceptable component name length',
type: 'number',
})
.option('minChild', {
demandOption: false,
default: MIN_CHILDREN,
describe: 'the smallest number of acceptable component children',
type: 'number',
})
.option('maxChild', {
demandOption: false,
default: MAX_CHILDREN,
describe: 'the largest number of acceptable component children',
type: 'number',
})
.option('extension', {
default: 'jsx',
describe: 'extension to use for generated components',
type: 'string',
}).argv
fs.mkdirSync(outdir, { recursive: true })
generateFuzzponents(outdir, seed, depth, opts)
}
module.exports = generateFuzzponents

View File

@@ -0,0 +1,12 @@
{
"name": "fuzzponent",
"bin": {
"fuzzponent": "./bin/fuzzponent.js"
},
"dependencies": {
"@babel/types": "7.18.0",
"@babel/generator": "7.18.0",
"random-seed": "0.3.0",
"yargs": "16.2.0"
}
}

View File

@@ -0,0 +1,39 @@
# Fuzzponent
Originally built by [Dale Bustad](https://github.com/divmain/fuzzponent) while at Vercel.
[Original repository](https://github.com/divmain/fuzzponent).
Generate a nested React component dependency graph, useful for benchmarking.
## Example
To create a dependency tree with `3020` files in the `components` directory:
```
fuzzponent --depth 2 --seed 206 --outdir components
```
You can then import the entrypoint of the dependency tree at `components/index.js`.
## Options
```
Options:
--help Show help [boolean]
--version Show version number [boolean]
-d, --depth component hierarchy depth [number] [required]
-s, --seed prng seed [number] [required]
-o, --outdir the directory where components should be written
[string] [default: "/Users/timneutkens/projects/next.js/bench/nested-deps"]
--minLen the smallest acceptable component name length
[number] [default: 18]
--maxLen the largest acceptable component name length
[number] [default: 24]
--minChild the smallest number of acceptable component children
[number] [default: 4]
--maxChild the largest number of acceptable component children
[number] [default: 80]
--extension extension to use for generated components
[string] [default: "jsx"]
```

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

40
bench/heavy-npm-deps/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,28 @@
import localFont from 'next/font/local'
import './globals.css'
const geistSans = localFont({
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
weight: '100 900',
})
const geistMono = localFont({
src: './fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
weight: '100 900',
})
export const metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
)
}

View File

@@ -0,0 +1,13 @@
import { LodashComponent } from '../components/lodash'
// import { MantineComponent } from "../components/mantine";
// import { MermaidComponent } from "../components/mermaid";
export default function Page() {
return (
<>
{/* <MantineComponent /> */}
{/* <MermaidComponent /> */}
<LodashComponent />
</>
)
}

View File

@@ -0,0 +1,12 @@
'use client'
import * as Lodash from 'lodash-es'
console.log(Lodash)
export function LodashComponent() {
return (
<>
<h1>Client Component</h1>
</>
)
}

View File

@@ -0,0 +1,12 @@
'use client'
import * as Mantine from '@mantine/core'
console.log(Mantine.Button.Group.classes)
export function MantineComponent() {
return (
<>
<h1>Client Component</h1>
</>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import * as Mermaid from 'mermaid'
console.log(Mermaid)
// console.log(Mantine.Button.Group.classes);
export function MermaidComponent() {
return (
<>
<h1>Client Component</h1>
</>
)
}

View File

@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
turbopackFileSystemCacheForDev: process.env.TURBO_CACHE === '1',
turbopackFileSystemCacheForBuild: process.env.TURBO_CACHE === '1',
},
}
export default nextConfig

View File

@@ -0,0 +1,23 @@
{
"name": "bench-heavy-npm-deps",
"version": "0.1.0",
"private": true,
"scripts": {
"dev-turbopack": "next dev --turbopack",
"dev-webpack": "next dev --webpack",
"build-turbopack": "next build --turbopack",
"build-webpack": "next build --webpack",
"start-turbopack": "next start",
"start-webpack": "next start",
"build-application": "next build",
"start-application": "next start"
},
"dependencies": {
"@mantine/core": "^7.10.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.383.0",
"mermaid": "^10.9.1",
"next": "workspace:*",
"tailwindcss": "3.2.7"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
}
module.exports = config

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 645 B

View File

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_868_525)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
</g>
<defs>
<clipPath id="clip0_868_525">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,10 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_977_547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_977_547">
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
</svg>

After

Width:  |  Height:  |  Size: 750 B

View File

@@ -0,0 +1,17 @@
const config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
},
},
},
plugins: [],
}
module.exports = config

4
bench/module-cost/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
commonjs/*
esm/*
CPU*
benchmark-results-*.json

View File

@@ -0,0 +1,12 @@
// Next.js route.js
import { measure } from '../../../lib/measure'
export async function GET() {
const result = await measure(
'app route commonjs',
() => import('../../../lib/commonjs.js')
)
return Response.json(result)
}

View File

@@ -0,0 +1,12 @@
// Next.js route.js
import { measure } from '../../../lib/measure'
export async function GET() {
const result = await measure(
'app route esm',
() => import('../../../lib/esm.js')
)
return Response.json(result)
}

View File

@@ -0,0 +1,29 @@
import { Client } from '../../components/client'
import { measure } from '../../lib/measure.js'
const commonjsAction = async () => {
'use server'
return await measure(
'app rsc commonjs',
() => import('../../lib/commonjs.js')
)
}
const esmAction = async () => {
'use server'
return await measure('app rsc esm', () => import('../../lib/esm.js'))
}
export default function Page() {
return (
<>
<h1>Measures the loading time of modules (app router)</h1>
<Client
prefix="/app"
commonjsAction={commonjsAction}
esmAction={esmAction}
/>
</>
)
}

View File

@@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<html lang="en">
<head></head>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,140 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { format, measure } from '../lib/measure'
function report(result, element, textarea) {
if (!globalThis.BENCHMARK_RESULTS) {
globalThis.BENCHMARK_RESULTS = []
}
globalThis.BENCHMARK_RESULTS.push(result)
const formattedResult = format(result)
element.textContent += `: ${formattedResult}`
textarea.current.value += `\n ${formattedResult}`
console.log(formattedResult)
element.disabled = true
}
async function measureClientButton(element, textarea, name, fn) {
if (element.textContent.includes('Loading time')) {
return
}
const result = await measure(name, fn)
report(result, element, textarea)
}
async function measureActionButton(element, textarea, action) {
if (element.textContent.includes('Loading time')) {
return
}
const result = await action()
report(result, element, textarea)
}
async function measureApiButton(element, textarea, url) {
if (element.textContent.includes('Loading time')) {
return
}
const result = await fetch(url).then((res) => res.json())
report(result, element, textarea)
}
export function Client({ prefix, commonjsAction, esmAction }) {
const [runtime, setRuntime] = useState('')
const textarea = useRef()
useEffect(() => {
setRuntime(
`${globalThis.TURBOPACK ? 'Turbopack' : 'Webpack'} (${process.env.NODE_ENV})`
)
}, [])
return (
<>
<h1>{runtime}</h1>
<p>
<button
type="button"
onClick={(e) =>
measureClientButton(
e.target,
textarea,
'client commonjs',
() => import('../lib/commonjs.js')
)
}
>
CommonJs client
</button>
</p>
<p>
<button
type="button"
onClick={(e) =>
measureClientButton(
e.target,
textarea,
'client esm',
() => import('../lib/esm.js')
)
}
>
ESM client
</button>
</p>
{commonjsAction && (
<p>
<button
type="button"
onClick={(e) =>
measureActionButton(e.target, textarea, commonjsAction)
}
>
CommonJs server action
</button>
</p>
)}
{esmAction && (
<p>
<button
type="button"
onClick={(e) => measureActionButton(e.target, textarea, esmAction)}
>
ESM server action
</button>
</p>
)}
<p>
<button
type="button"
onClick={(e) =>
measureApiButton(e.target, textarea, `${prefix}/commonjs`)
}
>
CommonJs API
</button>
</p>
<p>
<button
type="button"
onClick={(e) => measureApiButton(e.target, textarea, `${prefix}/esm`)}
>
ESM API
</button>
</p>
{
// holds all the timing data for easier copy paste
}
<textarea
readOnly={true}
ref={textarea}
value={runtime}
style={{ fieldSizing: 'content' }}
></textarea>
</>
)
}

View File

@@ -0,0 +1,3 @@
export function execute() {
return require('../commonjs/index.js') + 1
}

View File

@@ -0,0 +1,3 @@
export function execute() {
return require('../esm/index.js').default + 1
}

View File

@@ -0,0 +1,28 @@
export async function measure(name, fn) {
let module
let loadDuration
{
const start = performance.now()
module = await fn()
const end = performance.now()
loadDuration = end - start
}
let files
let executeDuration
{
const execute = module.execute
const start = performance.now()
files = execute()
const end = performance.now()
executeDuration = end - start
}
const result = { name, loadDuration, executeDuration, files }
return result
}
export function format(result) {
return `${result.name}: Load duration: ${result.loadDuration.toFixed(2)}ms, Execution duration: ${result.executeDuration.toFixed(2)}ms, Files: ${result.files}`
}

View File

@@ -0,0 +1,18 @@
const idx = process.execArgv.indexOf('--cpu-prof')
if (idx >= 0) process.execArgv.splice(idx, 1)
/** @type {import("next").NextConfig} */
module.exports = {
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
// With Scope Hoisting ESM require cost is 0 and that's not what we want to test
turbopackScopeHoisting: false,
},
webpack: (config) => {
// With Scope Hoisting ESM require cost is 0 and that's not what we want to test
config.optimization.concatenateModules = false
return config
},
}

View File

@@ -0,0 +1,17 @@
{
"name": "module-cost",
"scripts": {
"prepare-bench": "node scripts/prepare-bench.mjs",
"benchmark": "node scripts/benchmark-runner.mjs",
"dev-webpack": "next dev --webpack",
"dev-turbopack": "next dev --turbopack",
"build-webpack": "next build --webpack",
"build-turbopack": "next build --turbopack",
"start": "next start"
},
"devDependencies": {
"rimraf": "6.0.1",
"next": "workspace:*",
"playwright": "^1.40.0"
}
}

View File

@@ -0,0 +1,10 @@
import { measure } from '../../lib/measure'
export default async function handler(req, res) {
const result = await measure(
'pages api commonjs',
() => import('../../lib/commonjs.js')
)
res.status(200).json(result)
}

View File

@@ -0,0 +1,10 @@
import { measure } from '../../lib/measure'
export default async function handler(req, res) {
const result = await measure(
'pages api esm',
() => import('../../lib/esm.js')
)
res.status(200).json(result)
}

View File

@@ -0,0 +1,10 @@
import { Client } from '../components/client.js'
export default function Home() {
return (
<>
<h1>Measures the loading time of modules (pages router)</h1>
<Client prefix="/api" />
</>
)
}

View File

@@ -0,0 +1,255 @@
import { spawn } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import { chromium } from 'playwright'
/// To use:
/// - Install Playwright: `npx playwright install chromium`
/// - Install dependencies: `pnpm install`
/// - Build the application: `pnpm build-webpack` or pnpm build-turbopack`
/// - Run the benchmark: `pnpm benchmark`
class BenchmarkRunner {
constructor(options) {
this.name = options.name
this.samples = options.samples ?? 50
this.buttonClickDelay = options.buttonClickDelay ?? 500
this.results = []
}
async runBenchmark() {
for (let i = 1; i <= this.samples; i++) {
console.log(`\n--- Running sample ${i}/${this.samples} ---`)
const result = await this.runSingleSample()
this.results.push(...result)
}
this.saveResults()
console.log('\nBenchmark completed!')
}
async runSingleSample() {
let server
let browser
try {
// 1. Launch the server
server = await this.startServer()
// 2. Launch Chrome incognito
console.log('Launching browser...')
browser = await chromium.launch({
headless: true, // Set to true if you don't want to see the browser
args: ['--incognito'],
})
const context = await browser.newContext()
const page = await context.newPage()
// 3. Navigate to localhost:3000
await page.goto('http://localhost:3000', { waitUntil: 'load' })
// 4. Find and click all buttons
const buttons = await page.locator('button').all()
for (let j = 0; j < buttons.length; j++) {
await buttons[j].click()
await this.sleep(this.buttonClickDelay)
}
// 5. Capture data from textbox
console.log('Capturing data from the page...')
const textboxData = await this.capturePageData(page)
console.log('Captured data from the page:', textboxData)
// 6. Close browser
console.log('Closing browser...')
await browser.close()
browser = null
// 7. Shut down server
console.log('Shutting down server...')
await this.stopServer(server)
server = null
return textboxData
} catch (error) {
// Cleanup in case of error
if (browser) {
try {
await browser.close()
} catch (e) {
console.error('Error closing browser:', e.message)
}
}
if (server) {
try {
await this.stopServer(server)
} catch (e) {
console.error('Error stopping server:', e.message)
}
}
throw error
}
}
async startServer() {
return new Promise((resolve, reject) => {
const server = spawn('pnpm', ['start'], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: true,
})
let serverReady = false
server.stdout.on('data', (data) => {
const output = data.toString()
console.log('Server:', output.trim())
// Look for common Next.js ready indicators
if (
output.includes('Ready') ||
output.includes('started server') ||
output.includes('Local:')
) {
if (!serverReady) {
serverReady = true
resolve(server)
}
}
})
server.stderr.on('data', (data) => {
console.error('Server Error:', data.toString().trim())
})
server.on('error', (error) => {
reject(new Error(`Failed to start server: ${error.message}`))
})
server.on('close', (code) => {
if (!serverReady) {
reject(
new Error(`Server exited with code ${code} before becoming ready`)
)
}
})
// Timeout after 30 seconds
setTimeout(() => {
if (!serverReady) {
server.kill()
reject(new Error('Server startup timeout'))
}
}, 30000)
})
}
async stopServer(server) {
return new Promise((resolve) => {
if (!server || server.killed) {
resolve()
return
}
server.on('close', () => {
resolve()
})
// Try graceful shutdown first
server.kill('SIGTERM')
// Force kill after 5 seconds
setTimeout(() => {
if (!server.killed) {
server.kill('SIGKILL')
}
resolve()
}, 5000)
})
}
async capturePageData(page) {
return await page.evaluate(() => globalThis.BENCHMARK_RESULTS)
}
async sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
saveResults() {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `benchmark-results-${this.name}-${timestamp}.json`
writeFileSync(
filename,
JSON.stringify(summarizeDurations(this.results), null, 2)
)
console.log(`Results saved to ${filename}`)
}
}
const summarizeDurations = (data) => {
if (!Array.isArray(data) || data.length === 0) {
throw new Error('No data to summarize')
}
const byName = new Map()
for (const item of data) {
const name = item.name
if (!byName.has(name)) {
byName.set(name, [])
}
byName.get(name).push(item)
}
const results = []
for (const [name, data] of byName) {
const loadDurations = data
.map((item) => item.loadDuration)
.sort((a, b) => a - b)
const executeDurations = data
.map((item) => item.executeDuration)
.sort((a, b) => a - b)
const getSummary = (durations) => {
const sum = durations.reduce((acc, val) => acc + val, 0)
const average = sum / durations.length
const middle = Math.floor(durations.length / 2)
const median =
durations.length % 2 === 0
? (durations[middle - 1] + durations[middle]) / 2
: durations[middle]
const percentile75Index = Math.floor(durations.length * 0.75)
const percentile75 = durations[percentile75Index]
return {
average,
median,
percentile75,
}
}
results.push({
name,
totalSamples: data.length,
loadDuration: getSummary(loadDurations),
executeDuration: getSummary(executeDurations),
})
}
return results
}
// CLI usage
const args = process.argv.slice(2)
const samples = args.length > 0 ? Number.parseInt(args[0]) : undefined
const name = args.length > 1 ? args[1] : undefined
const runner = new BenchmarkRunner({
name,
samples,
})
runner.runBenchmark().catch(console.error)

View File

@@ -0,0 +1,74 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const commonjsDir = path.join(__dirname, '../commonjs')
const esmDir = path.join(__dirname, '../esm')
async function main() {
await fs.rm(commonjsDir, { recursive: true, force: true })
await fs.rm(esmDir, { recursive: true, force: true })
// Ensure directories exist
await fs.mkdir(commonjsDir, { recursive: true })
await fs.mkdir(esmDir, { recursive: true })
async function createFiles(dir, prefix, depth, type) {
const fileName = `${prefix}.js`
let content
if (depth === 0) {
switch (type) {
case 'commonjs':
content = `module.exports = 1;`
break
case 'esm':
content = `export default 1;`
break
default:
throw new Error(`Unknown type: ${type}`)
}
} else {
const inner = []
content = ''
for (let i = 0; i < 6; i++) {
const subPrefix = `${prefix}_${i}`
await createFiles(dir, subPrefix, depth - 1, type)
const subFileName = `${subPrefix}.js`
switch (type) {
case 'commonjs':
content += `const ${subPrefix} = require('./${subFileName}');\n`
break
case 'esm':
content += `import ${subPrefix} from './${subFileName}';\n`
break
default:
throw new Error(`Unknown type: ${type}`)
}
inner.push(subPrefix)
}
switch (type) {
case 'commonjs':
content += `\nmodule.exports = 1 + ${inner.join(' + ')};`
break
case 'esm':
content += `\nexport default 1 + ${inner.join(' + ')};`
break
default:
throw new Error(`Unknown type: ${type}`)
}
}
const filePath = path.join(dir, fileName)
await fs.writeFile(filePath, content, 'utf8')
}
await createFiles(commonjsDir, 'index', 5, 'commonjs')
await createFiles(esmDir, 'index', 5, 'esm')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,3 @@
components/*
app/*
CPU*

View File

@@ -0,0 +1,264 @@
import { execSync, spawn } from 'child_process'
import { join } from 'path'
import { fileURLToPath } from 'url'
import fetch from 'node-fetch'
import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
promises as fs,
} from 'fs'
import prettyMs from 'pretty-ms'
import treeKill from 'tree-kill'
const ROOT_DIR = join(fileURLToPath(import.meta.url), '..', '..', '..')
const CWD = join(ROOT_DIR, 'bench', 'nested-deps-app-router-many-pages')
const NEXT_BIN = join(ROOT_DIR, 'packages', 'next', 'dist', 'bin', 'next')
const [, , command = 'all'] = process.argv
async function killApp(instance) {
await new Promise((resolve, reject) => {
treeKill(instance.pid, (err) => {
if (err) {
if (
process.platform === 'win32' &&
typeof err.message === 'string' &&
(err.message.includes(`no running instance of the task`) ||
err.message.includes(`not found`))
) {
// Windows throws an error if the process is already stopped
//
// Command failed: taskkill /pid 6924 /T /F
// ERROR: The process with PID 6924 (child process of PID 6736) could not be terminated.
// Reason: There is no running instance of the task.
return resolve()
}
return reject(err)
}
resolve()
})
})
}
class File {
constructor(path) {
this.path = path
this.originalContent = existsSync(this.path)
? readFileSync(this.path, 'utf8')
: null
}
write(content) {
if (!this.originalContent) {
this.originalContent = content
}
writeFileSync(this.path, content, 'utf8')
}
replace(pattern, newValue) {
const currentContent = readFileSync(this.path, 'utf8')
if (pattern instanceof RegExp) {
if (!pattern.test(currentContent)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`
)
}
} else if (typeof pattern === 'string') {
if (!currentContent.includes(pattern)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`
)
}
} else {
throw new Error(`Unknown replacement attempt type: ${pattern}`)
}
const newContent = currentContent.replace(pattern, newValue)
this.write(newContent)
}
prepend(line) {
const currentContent = readFileSync(this.path, 'utf8')
this.write(line + '\n' + currentContent)
}
delete() {
unlinkSync(this.path)
}
restore() {
this.write(this.originalContent)
}
}
function runNextCommandDev(argv, opts = {}) {
const env = {
...process.env,
NODE_ENV: undefined,
__NEXT_TEST_MODE: 'true',
FORCE_COLOR: 3,
...opts.env,
}
const nodeArgs = opts.nodeArgs || []
return new Promise((resolve, reject) => {
const instance = spawn(NEXT_BIN, [...nodeArgs, ...argv], {
cwd: CWD,
env,
})
let didResolve = false
function handleStdout(data) {
const message = data.toString()
const bootupMarkers = {
dev: /Ready in .*/i,
start: /started server/i,
}
if (
(opts.bootupMarker && opts.bootupMarker.test(message)) ||
bootupMarkers[opts.nextStart ? 'start' : 'dev'].test(message)
) {
if (!didResolve) {
didResolve = true
resolve(instance)
instance.removeListener('data', handleStdout)
}
}
if (typeof opts.onStdout === 'function') {
opts.onStdout(message)
}
if (opts.stdout !== false) {
process.stdout.write(message)
}
}
function handleStderr(data) {
const message = data.toString()
if (typeof opts.onStderr === 'function') {
opts.onStderr(message)
}
if (opts.stderr !== false) {
process.stderr.write(message)
}
}
instance.stdout.on('data', handleStdout)
instance.stderr.on('data', handleStderr)
instance.on('close', () => {
instance.stdout.removeListener('data', handleStdout)
instance.stderr.removeListener('data', handleStderr)
if (!didResolve) {
didResolve = true
resolve()
}
})
instance.on('error', (err) => {
reject(err)
})
})
}
function waitFor(millis) {
return new Promise((resolve) => setTimeout(resolve, millis))
}
for (const testCase of [
'client-components-only',
'server-and-client-components',
'server-components-only',
]) {
await fs.rm('.next', { recursive: true }).catch(() => {})
const file = new File(join(CWD, `app/page77/${testCase}/page.js`))
const results = []
try {
if (command === 'dev' || command === 'all') {
const instance = await runNextCommandDev(['dev', '--port', '3000'])
function waitForCompiled() {
return new Promise((resolve) => {
function waitForOnData(data) {
const message = data.toString()
const compiledRegex =
/Compiled (?:.+ )?in (\d*[.]?\d+)\s*(m?s)(?: \((\d+) modules\))?/gm
const matched = compiledRegex.exec(message)
if (matched) {
resolve({
'time (ms)':
(matched[2] === 's' ? 1000 : 1) * Number(matched[1]),
modules: Number(matched[3]),
})
instance.stdout.removeListener('data', waitForOnData)
}
}
instance.stdout.on('data', waitForOnData)
})
}
const [res, initial] = await Promise.all([
fetch(`http://localhost:3000/page77/${testCase}`),
waitForCompiled(),
])
if (res.status !== 200) {
throw new Error(`Fetching /page77/${testCase} failed`)
}
results.push(initial)
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
console.table(results)
await killApp(instance)
}
if (command === 'build' || command === 'all') {
// ignore error
await fs.rm('.next', { recursive: true, force: true }).catch(() => {})
execSync(`node ${NEXT_BIN} build ./bench/nested-deps-app-router`, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: {
...process.env,
TRACE_TARGET: 'jaeger',
},
})
const traceString = await fs.readFile(join(CWD, '.next', 'trace'), 'utf8')
const traces = traceString
.split('\n')
.filter((line) => line)
.map((line) => JSON.parse(line))
const { duration } = traces
.pop()
.find(({ name }) => name === 'next-build')
console.info('next build duration: ', prettyMs(duration / 1000))
}
} finally {
file.restore()
}
}

View File

@@ -0,0 +1,33 @@
import * as fs from 'fs'
import * as path from 'path'
// get dirname from import.meta.url
const dirname = path.dirname(new URL(import.meta.url).pathname)
const appDir = path.resolve(dirname, 'app')
const templateDir = path.resolve(dirname, 'template')
fs.mkdirSync(appDir, { recursive: true })
fs.copyFileSync(
path.join(templateDir, 'root-layout.js'),
path.join(appDir, 'layout.js')
)
for (let i = 0; i < 1000; i++) {
const pageDir = path.join(appDir, 'page' + i)
fs.mkdirSync(pageDir, { recursive: true })
const files = [
'client-components-only/page.js',
'server-and-client-components/client-component.js',
'server-and-client-components/page.js',
'server-components-only/page.js',
'layout.js',
]
for (const file of files) {
const source = path.join(templateDir, file)
const dest = path.join(pageDir, file)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(source, dest)
}
}

View File

@@ -0,0 +1,8 @@
const idx = process.execArgv.indexOf('--cpu-prof')
if (idx >= 0) process.execArgv.splice(idx, 1)
module.exports = {
eslint: {
ignoreDuringBuilds: true,
},
}

View File

@@ -0,0 +1,19 @@
{
"name": "nested-deps-app-router-many-pages",
"scripts": {
"prepare-bench": "rimraf components app && fuzzponent -d 2 -s 206 -o components && node ./create-pages.mjs",
"dev-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next dev",
"build-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next build",
"start": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next start",
"dev-nocache": "rimraf .next && pnpm dev-application",
"dev-cpuprofile-nocache": "rimraf .next && cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 node --cpu-prof ../../node_modules/next/dist/bin/next",
"build-nocache": "rimraf .next && pnpm build-application"
},
"devDependencies": {
"fuzzponent": "workspace:*",
"cross-env": "^7.0.3",
"pretty-ms": "^7.0.1",
"rimraf": "^3.0.2",
"next": "workspace:*"
}
}

View File

@@ -0,0 +1,13 @@
'use client'
import React from 'react'
import Comp from '../../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,5 @@
import React from 'react'
export default function Layout({ children }) {
return <main>{children}</main>
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import React from 'react'
import Comp from '../../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import Comp from '../../../components/index.jsx'
import ClientComponent from './client-component'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
<ClientComponent />
</>
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import Comp from '../../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,2 @@
components/*
CPU*

View File

@@ -0,0 +1,13 @@
'use client'
import React from 'react'
import Comp from '../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import React from 'react'
import Comp from '../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import Comp from '../../components/index.jsx'
import ClientComponent from './client-component'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
<ClientComponent />
</>
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import Comp from '../../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,264 @@
import { execSync, spawn } from 'child_process'
import { join } from 'path'
import { fileURLToPath } from 'url'
import fetch from 'node-fetch'
import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
promises as fs,
} from 'fs'
import prettyMs from 'pretty-ms'
import treeKill from 'tree-kill'
const ROOT_DIR = join(fileURLToPath(import.meta.url), '..', '..', '..')
const CWD = join(ROOT_DIR, 'bench', 'nested-deps-app-router')
const NEXT_BIN = join(ROOT_DIR, 'packages', 'next', 'dist', 'bin', 'next')
const [, , command = 'all'] = process.argv
async function killApp(instance) {
await new Promise((resolve, reject) => {
treeKill(instance.pid, (err) => {
if (err) {
if (
process.platform === 'win32' &&
typeof err.message === 'string' &&
(err.message.includes(`no running instance of the task`) ||
err.message.includes(`not found`))
) {
// Windows throws an error if the process is already stopped
//
// Command failed: taskkill /pid 6924 /T /F
// ERROR: The process with PID 6924 (child process of PID 6736) could not be terminated.
// Reason: There is no running instance of the task.
return resolve()
}
return reject(err)
}
resolve()
})
})
}
class File {
constructor(path) {
this.path = path
this.originalContent = existsSync(this.path)
? readFileSync(this.path, 'utf8')
: null
}
write(content) {
if (!this.originalContent) {
this.originalContent = content
}
writeFileSync(this.path, content, 'utf8')
}
replace(pattern, newValue) {
const currentContent = readFileSync(this.path, 'utf8')
if (pattern instanceof RegExp) {
if (!pattern.test(currentContent)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`
)
}
} else if (typeof pattern === 'string') {
if (!currentContent.includes(pattern)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`
)
}
} else {
throw new Error(`Unknown replacement attempt type: ${pattern}`)
}
const newContent = currentContent.replace(pattern, newValue)
this.write(newContent)
}
prepend(line) {
const currentContent = readFileSync(this.path, 'utf8')
this.write(line + '\n' + currentContent)
}
delete() {
unlinkSync(this.path)
}
restore() {
this.write(this.originalContent)
}
}
function runNextCommandDev(argv, opts = {}) {
const env = {
...process.env,
NODE_ENV: undefined,
__NEXT_TEST_MODE: 'true',
FORCE_COLOR: 3,
...opts.env,
}
const nodeArgs = opts.nodeArgs || []
return new Promise((resolve, reject) => {
const instance = spawn(NEXT_BIN, [...nodeArgs, ...argv], {
cwd: CWD,
env,
})
let didResolve = false
function handleStdout(data) {
const message = data.toString()
const bootupMarkers = {
dev: /Ready in .*/i,
start: /started server/i,
}
if (
(opts.bootupMarker && opts.bootupMarker.test(message)) ||
bootupMarkers[opts.nextStart ? 'start' : 'dev'].test(message)
) {
if (!didResolve) {
didResolve = true
resolve(instance)
instance.removeListener('data', handleStdout)
}
}
if (typeof opts.onStdout === 'function') {
opts.onStdout(message)
}
if (opts.stdout !== false) {
process.stdout.write(message)
}
}
function handleStderr(data) {
const message = data.toString()
if (typeof opts.onStderr === 'function') {
opts.onStderr(message)
}
if (opts.stderr !== false) {
process.stderr.write(message)
}
}
instance.stdout.on('data', handleStdout)
instance.stderr.on('data', handleStderr)
instance.on('close', () => {
instance.stdout.removeListener('data', handleStdout)
instance.stderr.removeListener('data', handleStderr)
if (!didResolve) {
didResolve = true
resolve()
}
})
instance.on('error', (err) => {
reject(err)
})
})
}
function waitFor(millis) {
return new Promise((resolve) => setTimeout(resolve, millis))
}
for (const testCase of [
'client-components-only',
'server-and-client-components',
'server-components-only',
]) {
await fs.rm('.next', { recursive: true }).catch(() => {})
const file = new File(join(CWD, `app/${testCase}/page.js`))
const results = []
try {
if (command === 'dev' || command === 'all') {
const instance = await runNextCommandDev(['dev', '--port', '3000'])
function waitForCompiled() {
return new Promise((resolve) => {
function waitForOnData(data) {
const message = data.toString()
const compiledRegex =
/Compiled (?:.+ )?in (\d*[.]?\d+)\s*(m?s)(?: \((\d+) modules\))?/gm
const matched = compiledRegex.exec(message)
if (matched) {
resolve({
'time (ms)':
(matched[2] === 's' ? 1000 : 1) * Number(matched[1]),
modules: Number(matched[3]),
})
instance.stdout.removeListener('data', waitForOnData)
}
}
instance.stdout.on('data', waitForOnData)
})
}
const [res, initial] = await Promise.all([
fetch(`http://localhost:3000/${testCase}`),
waitForCompiled(),
])
if (res.status !== 200) {
throw new Error(`Fetching /${testCase} failed`)
}
results.push(initial)
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
console.table(results)
await killApp(instance)
}
if (command === 'build' || command === 'all') {
// ignore error
await fs.rm('.next', { recursive: true, force: true }).catch(() => {})
execSync(`node ${NEXT_BIN} build ./bench/nested-deps-app-router`, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: {
...process.env,
TRACE_TARGET: 'jaeger',
},
})
const traceString = await fs.readFile(join(CWD, '.next', 'trace'), 'utf8')
const traces = traceString
.split('\n')
.filter((line) => line)
.map((line) => JSON.parse(line))
const { duration } = traces
.pop()
.find(({ name }) => name === 'next-build')
console.info('next build duration: ', prettyMs(duration / 1000))
}
} finally {
file.restore()
}
}

View File

@@ -0,0 +1,8 @@
const idx = process.execArgv.indexOf('--cpu-prof')
if (idx >= 0) process.execArgv.splice(idx, 1)
module.exports = {
eslint: {
ignoreDuringBuilds: true,
},
}

View File

@@ -0,0 +1,19 @@
{
"name": "bench-nested-deps-app-router",
"scripts": {
"prepare-bench": "rimraf components && fuzzponent -d 2 -s 206 -o components",
"dev-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next dev",
"build-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next build",
"start": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next start",
"dev-nocache": "rimraf .next && pnpm dev-application",
"dev-cpuprofile-nocache": "rimraf .next && cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 node --cpu-prof ../../node_modules/next/dist/bin/next",
"build-nocache": "rimraf .next && pnpm build-application"
},
"devDependencies": {
"fuzzponent": "workspace:*",
"cross-env": "^7.0.3",
"pretty-ms": "^7.0.1",
"rimraf": "^3.0.2",
"next": "workspace:*"
}
}

2
bench/nested-deps/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
components/*
CPU*

253
bench/nested-deps/bench.mjs Normal file
View File

@@ -0,0 +1,253 @@
import { execSync, spawn } from 'child_process'
import { join } from 'path'
import { fileURLToPath } from 'url'
import fetch from 'node-fetch'
import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
promises as fs,
} from 'fs'
import prettyMs from 'pretty-ms'
import treeKill from 'tree-kill'
const ROOT_DIR = join(fileURLToPath(import.meta.url), '..', '..', '..')
const CWD = join(ROOT_DIR, 'bench', 'nested-deps')
const NEXT_BIN = join(ROOT_DIR, 'packages', 'next', 'dist', 'bin', 'next')
const [, , command = 'all'] = process.argv
async function killApp(instance) {
await new Promise((resolve, reject) => {
treeKill(instance.pid, (err) => {
if (err) {
if (
process.platform === 'win32' &&
typeof err.message === 'string' &&
(err.message.includes(`no running instance of the task`) ||
err.message.includes(`not found`))
) {
// Windows throws an error if the process is already stopped
//
// Command failed: taskkill /pid 6924 /T /F
// ERROR: The process with PID 6924 (child process of PID 6736) could not be terminated.
// Reason: There is no running instance of the task.
return resolve()
}
return reject(err)
}
resolve()
})
})
}
class File {
constructor(path) {
this.path = path
this.originalContent = existsSync(this.path)
? readFileSync(this.path, 'utf8')
: null
}
write(content) {
if (!this.originalContent) {
this.originalContent = content
}
writeFileSync(this.path, content, 'utf8')
}
replace(pattern, newValue) {
const currentContent = readFileSync(this.path, 'utf8')
if (pattern instanceof RegExp) {
if (!pattern.test(currentContent)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`
)
}
} else if (typeof pattern === 'string') {
if (!currentContent.includes(pattern)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`
)
}
} else {
throw new Error(`Unknown replacement attempt type: ${pattern}`)
}
const newContent = currentContent.replace(pattern, newValue)
this.write(newContent)
}
prepend(line) {
const currentContent = readFileSync(this.path, 'utf8')
this.write(line + '\n' + currentContent)
}
delete() {
unlinkSync(this.path)
}
restore() {
this.write(this.originalContent)
}
}
function runNextCommandDev(argv, opts = {}) {
const env = {
...process.env,
NODE_ENV: undefined,
__NEXT_TEST_MODE: 'true',
FORCE_COLOR: 3,
...opts.env,
}
const nodeArgs = opts.nodeArgs || []
return new Promise((resolve, reject) => {
const instance = spawn(NEXT_BIN, [...nodeArgs, ...argv], {
cwd: CWD,
env,
})
let didResolve = false
function handleStdout(data) {
const message = data.toString()
const bootupMarkers = {
dev: /Ready in .*/i,
start: /started server/i,
}
if (
(opts.bootupMarker && opts.bootupMarker.test(message)) ||
bootupMarkers[opts.nextStart ? 'start' : 'dev'].test(message)
) {
if (!didResolve) {
didResolve = true
resolve(instance)
instance.removeListener('data', handleStdout)
}
}
if (typeof opts.onStdout === 'function') {
opts.onStdout(message)
}
if (opts.stdout !== false) {
process.stdout.write(message)
}
}
function handleStderr(data) {
const message = data.toString()
if (typeof opts.onStderr === 'function') {
opts.onStderr(message)
}
if (opts.stderr !== false) {
process.stderr.write(message)
}
}
instance.stdout.on('data', handleStdout)
instance.stderr.on('data', handleStderr)
instance.on('close', () => {
instance.stdout.removeListener('data', handleStdout)
instance.stderr.removeListener('data', handleStderr)
if (!didResolve) {
didResolve = true
resolve()
}
})
instance.on('error', (err) => {
reject(err)
})
})
}
function waitFor(millis) {
return new Promise((resolve) => setTimeout(resolve, millis))
}
await fs.rm('.next', { recursive: true }).catch(() => {})
const file = new File(join(CWD, 'pages/index.jsx'))
const results = []
try {
if (command === 'dev' || command === 'all') {
const instance = await runNextCommandDev(['dev', '--port', '3000'])
function waitForCompiled() {
return new Promise((resolve) => {
function waitForOnData(data) {
const message = data.toString()
const compiledRegex =
/Compiled (?:.+ )?in (\d*[.]?\d+)\s*(m?s)(?: \((\d+) modules\))?/gm
const matched = compiledRegex.exec(message)
if (matched) {
resolve({
'time (ms)': (matched[2] === 's' ? 1000 : 1) * Number(matched[1]),
modules: Number(matched[3]),
})
instance.stdout.removeListener('data', waitForOnData)
}
}
instance.stdout.on('data', waitForOnData)
})
}
const [res, initial] = await Promise.all([
fetch('http://localhost:3000/'),
waitForCompiled(),
])
if (res.status !== 200) {
throw new Error('Fetching / failed')
}
results.push(initial)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
await waitFor(1000)
file.replace('Hello', 'Hello!')
results.push(await waitForCompiled())
console.table(results)
await killApp(instance)
}
if (command === 'build' || command === 'all') {
// ignore error
await fs.rm('.next', { recursive: true, force: true }).catch(() => {})
execSync(`node ${NEXT_BIN} build ./bench/nested-deps`, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: {
...process.env,
TRACE_TARGET: 'jaeger',
},
})
const traceString = await fs.readFile(join(CWD, '.next', 'trace'), 'utf8')
const traces = traceString
.split('\n')
.filter((line) => line)
.map((line) => JSON.parse(line))
const { duration } = traces.pop().find(({ name }) => name === 'next-build')
console.info('next build duration: ', prettyMs(duration / 1000))
}
} finally {
file.restore()
}

View File

@@ -0,0 +1,8 @@
const idx = process.execArgv.indexOf('--cpu-prof')
if (idx >= 0) process.execArgv.splice(idx, 1)
module.exports = {
eslint: {
ignoreDuringBuilds: true,
},
}

View File

@@ -0,0 +1,19 @@
{
"name": "bench-nested-deps",
"scripts": {
"prepare-bench": "rimraf components && fuzzponent -d 2 -s 206 -o components",
"dev-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next dev",
"build-application": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next build",
"start": "cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 next start",
"dev-nocache": "rimraf .next && pnpm dev-application",
"dev-cpuprofile-nocache": "rimraf .next && cross-env NEXT_PRIVATE_LOCAL_WEBPACK=1 node --cpu-prof ../../node_modules/next/dist/bin/next",
"build-nocache": "rimraf .next && pnpm build-application"
},
"devDependencies": {
"fuzzponent": "workspace:*",
"cross-env": "^7.0.3",
"pretty-ms": "^7.0.1",
"rimraf": "^3.0.2",
"next": "workspace:*"
}
}

View File

@@ -0,0 +1,10 @@
import Comp from '../components/index.jsx'
export default function Home() {
return (
<>
<h1>Hello!</h1>
<Comp />
</>
)
}

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
process.env.NODE_ENV = 'production'
require('../../../test/lib/react-channel-require-hook')
console.time('next-cold-start')
const NextServer = require('next/dist/server/next-server').default
const path = require('path')
const appDir = process.cwd()
const distDir = '.next'
const compiledConfig = require(
path.join(appDir, distDir, 'required-server-files.json')
).config
process.chdir(appDir)
const nextServer = new NextServer({
conf: compiledConfig,
dir: appDir,
distDir,
minimalMode: true,
customServer: false,
})
const requestHandler = nextServer.getRequestHandler()
const port = parseInt(process.env.PORT, 10) || 3000
const server = require('http').createServer((req, res) => {
return requestHandler(req, res)
})
server.listen(port, () => {
console.timeEnd('next-cold-start')
console.log('Listening on port ' + port)
})
let shuttingDown = false
function shutdown() {
if (shuttingDown) return
shuttingDown = true
// Allow Node to exit cleanly so --cpu-prof/--heap-prof outputs are flushed.
server.close(() => {
process.exit(0)
})
// Fallback in case active keep-alive connections prevent close callback.
setTimeout(() => {
process.exit(1)
}, 5000).unref()
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)

View File

@@ -0,0 +1,8 @@
{
"name": "next-minimal-server",
"description": "Minimal server for Next.js for benchmarking/perf analysis purposes.",
"bin": "./bin/minimal-server.js",
"peerDependencies": {
"next": "workspace:*"
}
}

View File

@@ -0,0 +1,59 @@
import { join } from 'path'
import { ensureDir, outputFile, remove } from 'fs-extra'
import recursiveCopyNpm from 'recursive-copy'
import { recursiveCopy as recursiveCopyCustom } from 'next/dist/lib/recursive-copy'
const fixturesDir = join(__dirname, 'fixtures')
const srcDir = join(fixturesDir, 'src')
const destDir = join(fixturesDir, 'dest')
const createSrcFolder = async () => {
await ensureDir(srcDir)
const files = new Array(100)
.fill(undefined)
.map((_, i) =>
join(srcDir, `folder${i % 5}`, `folder${i + (1 % 5)}`, `file${i}`)
)
await Promise.all(files.map((file) => outputFile(file, 'hello')))
}
async function run(fn) {
async function test() {
const start = process.hrtime()
await fn(srcDir, destDir)
const timer = process.hrtime(start)
const ms = (timer[0] * 1e9 + timer[1]) / 1e6
return ms
}
const ts = []
for (let i = 0; i < 10; i++) {
const t = await test()
await remove(destDir)
ts.push(t)
}
const sum = ts.reduce((a, b) => a + b)
const nb = ts.length
const avg = sum / nb
console.log({ sum, nb, avg })
}
async function main() {
await createSrcFolder()
console.log('test recursive-copy npm module')
await run(recursiveCopyNpm)
console.log('test recursive-copy custom implementation')
await run(recursiveCopyCustom)
await remove(fixturesDir)
}
main()

1
bench/recursive-delete/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
fixtures-*

View File

@@ -0,0 +1,7 @@
# Recursive Delete Benchmark
```bash
pnpm bench
```
Run `pnpm bench --help` for options.

View File

@@ -0,0 +1,27 @@
import { rm as rmPromises } from 'fs/promises'
import { rm as rmCallback, rmSync } from 'fs'
import { promisify } from 'util'
const rmCallbackPromise = promisify(rmCallback)
const targetDir = process.argv[2]
const method = process.argv[3] // 'promises', 'callback', or 'sync'
async function test() {
const time = process.hrtime()
if (method === 'promises') {
await rmPromises(targetDir, { recursive: true, force: true })
} else if (method === 'callback') {
await rmCallbackPromise(targetDir, { recursive: true, force: true })
} else if (method === 'sync') {
rmSync(targetDir, { recursive: true, force: true })
}
const hrtime = process.hrtime(time)
const nanoseconds = hrtime[0] * 1e9 + hrtime[1]
const milliseconds = nanoseconds / 1e6
console.log(milliseconds)
}
test()

View File

View File

@@ -0,0 +1,12 @@
{
"name": "bench-recursive-delete",
"type": "module",
"scripts": {
"bench": "bash run.sh"
},
"devDependencies": {
"fuzzponent": "workspace:*",
"next": "workspace:*",
"rimraf": "6.0.1"
}
}

View File

@@ -0,0 +1,15 @@
import { recursiveDeleteSyncWithAsyncRetries } from 'next/dist/lib/recursive-delete.js'
const targetDir = process.argv[2]
async function test() {
const time = process.hrtime()
await recursiveDeleteSyncWithAsyncRetries(targetDir)
const hrtime = process.hrtime(time)
const nanoseconds = hrtime[0] * 1e9 + hrtime[1]
const milliseconds = nanoseconds / 1e6
console.log(milliseconds)
}
test()

View File

@@ -0,0 +1,21 @@
import { manual, manualSync } from 'rimraf'
const targetDir = process.argv[2]
const method = process.argv[3]
async function test() {
const time = process.hrtime()
if (method === 'sync') {
manualSync(targetDir)
} else {
await manual(targetDir)
}
const hrtime = process.hrtime(time)
const nanoseconds = hrtime[0] * 1e9 + hrtime[1]
const milliseconds = nanoseconds / 1e6
console.log(milliseconds)
}
test()

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ITERATIONS=5
show_help() {
echo "Usage: $(basename "$0") [-i|--iterations N] [-h|--help]"
exit "${1:-0}"
}
if ! OPTS=$(getopt -o i:h --long iterations:,help -n "$(basename "$0")" -- "$@"); then
show_help 1
fi
eval set -- "$OPTS"
while true; do
case "$1" in
-i|--iterations)
ITERATIONS="$2"
shift 2
;;
-h|--help)
show_help
;;
*)
break
;;
esac
done
cleanup() {
for i in $(seq 1 "$ITERATIONS"); do
rm -rf "fixtures-$i"
done
}
trap cleanup EXIT
cleanup
run_benchmark() {
local name=$1
local script=$2
shift 2
echo "-----------"
for i in $(seq 1 "$ITERATIONS"); do
local fixture="fixtures-$i"
mkdir "$fixture"
cd "fixtures-$i"
fuzzponent -d 2 -s 20
cd ..
echo "$name $i"
node "$script" "$fixture" "$@"
if [[ -d "$fixture" ]]; then rmdir "$fixture"; fi
done
}
run_benchmark "rimraf (async)" "rimraf.js" "async"
run_benchmark "rimraf (sync)" "rimraf.js" "sync"
run_benchmark "recursive delete" "recursive-delete.js"
run_benchmark "nodejs rm (promises)" "nodejs-rm.js" "promises"
run_benchmark "nodejs rm (callback)" "nodejs-rm.js" "callback"
run_benchmark "nodejs rm (sync)" "nodejs-rm.js" "sync"

1
bench/render-pipeline/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
artifacts/

View File

@@ -0,0 +1,105 @@
# Render Pipeline Benchmark
This benchmark targets the full App Router render path (`renderToHTMLOrFlight`) via real HTTP requests through `bench/next-minimal-server`.
It supports:
- `web` vs `node` streams mode comparison
- route-based stress suites for streaming SSR
- CPU/heap profiling for the server process
- Node trace events and Next internal trace artifact capture
## Quick start
Run end-to-end benchmark (default stress routes):
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=both
```
For `scenario=full` and `scenario=all`, CPU profiles are captured by default.
Disable with `--capture-cpu=false` if you want lower-overhead runs.
Skip rebuild for faster iteration (after you already built once):
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=node --build-full=false
```
When `--stream-mode=both`, the runner forces `--build-full=true` so web/node
comparisons do not accidentally reuse stale build output.
Output JSON report:
```bash
pnpm bench:render-pipeline --scenario=full --stream-mode=both --json-out=/tmp/render-pipeline.json
```
## Profiling and traces
Capture CPU profiles + Node trace events + Next trace logs:
```bash
pnpm bench:render-pipeline \
--scenario=full \
--stream-mode=both \
--capture-trace=true \
--capture-next-trace=true
```
Artifacts are written to:
```text
bench/render-pipeline/artifacts/<timestamp>/
```
Per mode (`web` and `node`) this includes:
- `<mode>.cpuprofile` (if `--capture-cpu=true`)
- `<mode>.heapprofile` (if `--capture-heap=true`)
- `<mode>-trace-*.json` (if `--capture-trace=true`)
- `next-trace-build.log` and `next-runtime-trace.log` (if `--capture-next-trace=true`)
Open `.cpuprofile` files in Chrome DevTools Performance panel.
Analyze results and CPU hotspots from artifacts:
```bash
pnpm bench:render-pipeline:analyze --artifact-dir=bench/render-pipeline/artifacts/<timestamp>
```
Omit `--artifact-dir` to analyze the latest run automatically.
## Stress routes
Default routes:
- `/`
- `/streaming/light`
- `/streaming/medium`
- `/streaming/heavy`
- `/streaming/chunkstorm`
- `/streaming/wide`
- `/streaming/bulk`
The `streaming/*` pages now include a client boundary per Suspense chunk, so benchmark runs also stress Server-to-Client payload serialization in Flight data.
Override with:
```bash
pnpm bench:render-pipeline --scenario=full --routes=/,/streaming/heavy
```
## Common tuning flags
- `--warmup-requests=30`
- `--serial-requests=120`
- `--load-requests=1200`
- `--load-concurrency=80`
- `--timeout-ms=30000`
- `--port=3199`
## Optional micro benchmarks
The runner also supports helper-only micro benchmarks:
```bash
pnpm bench:render-pipeline --scenario=micro
```

View File

@@ -0,0 +1,399 @@
// This script must be run with tsx
import { constants } from 'node:fs'
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { SourceMap } from 'node:module'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const DEFAULT_ARTIFACTS_ROOT = resolve(
REPO_ROOT,
'bench/render-pipeline/artifacts'
)
type FullRoutePhaseResult = {
mode: 'web' | 'node'
route: string
phase: 'single-client' | 'under-load'
requests: number
concurrency: number
throughputRps: number
latency: {
min: number
median: number
mean: number
p95: number
max: number
}
}
type BenchmarkJson = {
fullResults?: Array<{
mode: 'web' | 'node'
routeResults: FullRoutePhaseResult[]
}>
}
type ProfileAnalysis = {
totalUs: number
runtimeUs: number
runtimeFile: string | null
topModules: Array<{ name: string; us: number }>
topRuntimeSources: Array<{ name: string; us: number }>
topRuntimeSymbols: Array<{ name: string; us: number }>
}
function usage() {
console.log(`Usage: pnpm bench:render-pipeline:analyze [options]
Options:
--artifact-dir=<path> Artifact run directory, or parent artifacts directory.
Default: latest run under bench/render-pipeline/artifacts
--top=<number> Number of top hotspots to show per section (default: 15)
`)
}
function parseArgs() {
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 [rawKey, rawValue] = rawArg.slice(2).split('=')
args.set(rawKey, rawValue ?? 'true')
}
const topRaw = args.get('top')
const top = topRaw ? Number(topRaw) : 15
if (!Number.isFinite(top) || top < 1) {
throw new Error(`Invalid --top value: ${topRaw}`)
}
return {
artifactDirArg: args.get('artifact-dir'),
top: Math.floor(top),
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK)
return true
} catch {
return false
}
}
async function resolveArtifactRunDir(artifactDirArg?: string): Promise<string> {
const requested = resolve(REPO_ROOT, artifactDirArg ?? DEFAULT_ARTIFACTS_ROOT)
const requestedResults = resolve(requested, 'results.json')
if (await exists(requestedResults)) {
return requested
}
const entries = await readdir(requested, { withFileTypes: true })
const dirs = entries.filter((entry) => entry.isDirectory())
const runs: Array<{ dir: string; mtimeMs: number }> = []
for (const dirent of dirs) {
const dir = resolve(requested, dirent.name)
const resultsPath = resolve(dir, 'results.json')
if (!(await exists(resultsPath))) continue
const stats = await stat(resultsPath)
runs.push({ dir, mtimeMs: stats.mtimeMs })
}
if (runs.length === 0) {
throw new Error(
`No artifact run found in ${requested}. Expected a results.json file.`
)
}
runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
return runs[0].dir
}
function toPercent(part: number, total: number): string {
if (total <= 0) return '0.00%'
return `${((part / total) * 100).toFixed(2)}%`
}
function toMs(us: number): string {
return `${(us / 1000).toFixed(1)}ms`
}
function sortTop(
entries: Iterable<[string, number]>,
limit: number
): Array<{ name: string; us: number }> {
return [...entries]
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([name, us]) => ({ name, us }))
}
function mapModuleFromUrl(url: string): string {
if (!url || url === '(no-url)') return '(no-url)'
if (url.startsWith('node:')) return url
const appPageMatch = url.match(/app-page-turbo[\w-]*\.runtime\.prod\.js/)
if (appPageMatch) return appPageMatch[0]
if (url.includes('/.next/server/chunks/')) return '.next/server/chunks/*'
if (url.includes('/next/dist/')) return 'next/dist/*'
if (url.includes('/node_modules/')) return 'node_modules/*'
return url
}
function detectRuntimeFile(
urlsByUs: Array<{ url: string; us: number }>
): string | null {
for (const entry of urlsByUs) {
const match = entry.url.match(/app-page-turbo[\w-]*\.runtime\.prod\.js/)
if (match) return match[0]
}
return null
}
async function analyzeProfile(
profilePath: string,
top: number
): Promise<ProfileAnalysis | null> {
if (!(await exists(profilePath))) return null
const rawProfile = await readFile(profilePath, 'utf8')
const profile = JSON.parse(rawProfile) as {
nodes: Array<{
id: number
callFrame: {
functionName: string
url: string
lineNumber: number
columnNumber: number
}
}>
samples: number[]
timeDeltas: number[]
}
const idToNode = new Map(profile.nodes.map((node) => [node.id, node]))
const urlTotals = new Map<string, number>()
const moduleTotals = new Map<string, number>()
let totalUs = 0
for (let i = 0; i < profile.samples.length; i++) {
const sampleId = profile.samples[i]
const deltaUs = profile.timeDeltas[i] ?? 0
totalUs += deltaUs
const node = idToNode.get(sampleId)
if (!node) continue
const url = node.callFrame.url || '(no-url)'
urlTotals.set(url, (urlTotals.get(url) ?? 0) + deltaUs)
const moduleName = mapModuleFromUrl(url)
moduleTotals.set(moduleName, (moduleTotals.get(moduleName) ?? 0) + deltaUs)
}
const topUrls = sortTop(urlTotals.entries(), 30).map((entry) => ({
url: entry.name,
us: entry.us,
}))
const runtimeFile = detectRuntimeFile(topUrls)
let runtimeUs = 0
const runtimeSources = new Map<string, number>()
const runtimeSymbols = new Map<string, number>()
let sourceMap: SourceMap | null = null
if (runtimeFile) {
const mapPath = resolve(
REPO_ROOT,
`packages/next/dist/compiled/next-server/${runtimeFile}.map`
)
if (await exists(mapPath)) {
sourceMap = new SourceMap(JSON.parse(await readFile(mapPath, 'utf8')))
}
}
if (runtimeFile) {
for (let i = 0; i < profile.samples.length; i++) {
const sampleId = profile.samples[i]
const deltaUs = profile.timeDeltas[i] ?? 0
const node = idToNode.get(sampleId)
if (!node) continue
const { callFrame } = node
if (!callFrame.url.includes(runtimeFile)) continue
runtimeUs += deltaUs
const generatedLine = callFrame.lineNumber ?? 0
const generatedColumn = callFrame.columnNumber ?? 0
let sourceName = callFrame.url
let symbolName = callFrame.functionName || '(anonymous)'
let sourceLine = generatedLine
let sourceColumn = generatedColumn
if (sourceMap) {
const entry = sourceMap.findEntry(generatedLine, generatedColumn) as {
originalSource?: string
originalLine?: number
originalColumn?: number
name?: string
}
if (entry.originalSource) sourceName = entry.originalSource
if (entry.name) symbolName = entry.name
if (entry.originalLine !== undefined) sourceLine = entry.originalLine
if (entry.originalColumn !== undefined)
sourceColumn = entry.originalColumn
}
runtimeSources.set(
sourceName,
(runtimeSources.get(sourceName) ?? 0) + deltaUs
)
const symbolKey = `${symbolName} @ ${sourceName}:${sourceLine}:${sourceColumn}`
runtimeSymbols.set(
symbolKey,
(runtimeSymbols.get(symbolKey) ?? 0) + deltaUs
)
}
}
return {
totalUs,
runtimeUs,
runtimeFile,
topModules: sortTop(moduleTotals.entries(), top),
topRuntimeSources: sortTop(runtimeSources.entries(), top),
topRuntimeSymbols: sortTop(runtimeSymbols.entries(), top),
}
}
function printProfileAnalysis(
mode: 'web' | 'node',
analysis: ProfileAnalysis,
top: number
) {
console.log(`\n[${mode}]`)
console.log(` sampled: ${toMs(analysis.totalUs)}`)
if (analysis.runtimeFile) {
console.log(
` runtime: ${analysis.runtimeFile} (${toMs(analysis.runtimeUs)}, ${toPercent(analysis.runtimeUs, analysis.totalUs)})`
)
} else {
console.log(' runtime: not detected')
}
console.log(` top ${top} modules:`)
for (const entry of analysis.topModules) {
console.log(
` ${toPercent(entry.us, analysis.totalUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
if (analysis.topRuntimeSources.length > 0) {
console.log(` top ${top} runtime sources:`)
for (const entry of analysis.topRuntimeSources) {
console.log(
` ${toPercent(entry.us, analysis.runtimeUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
}
if (analysis.topRuntimeSymbols.length > 0) {
console.log(` top ${top} runtime symbols:`)
for (const entry of analysis.topRuntimeSymbols) {
console.log(
` ${toPercent(entry.us, analysis.runtimeUs).padStart(7)} ${toMs(entry.us).padStart(9)} ${entry.name}`
)
}
}
}
function printComparison(results: BenchmarkJson) {
const fullResults = results.fullResults
if (!fullResults || fullResults.length < 2) return
const web = fullResults.find((entry) => entry.mode === 'web')
const node = fullResults.find((entry) => entry.mode === 'node')
if (!web || !node) return
const webByKey = new Map(
web.routeResults.map((item) => [`${item.route}|${item.phase}`, item])
)
console.log('\n[comparison node vs web]')
console.log(
' route'.padEnd(20) +
'phase'.padEnd(16) +
'RPS delta'.padEnd(14) +
'P95 delta'
)
for (const nodeEntry of node.routeResults) {
const key = `${nodeEntry.route}|${nodeEntry.phase}`
const webEntry = webByKey.get(key)
if (!webEntry) continue
const rpsDelta =
((nodeEntry.throughputRps - webEntry.throughputRps) /
webEntry.throughputRps) *
100
const p95Delta =
((webEntry.latency.p95 - nodeEntry.latency.p95) / webEntry.latency.p95) *
100
const line =
` ${nodeEntry.route}`.padEnd(20) +
`${nodeEntry.phase}`.padEnd(16) +
`${rpsDelta >= 0 ? '+' : ''}${rpsDelta.toFixed(2)}%`.padEnd(14) +
`${p95Delta >= 0 ? '+' : ''}${p95Delta.toFixed(2)}%`
console.log(line)
}
}
async function main() {
const { artifactDirArg, top } = parseArgs()
const runDir = await resolveArtifactRunDir(artifactDirArg)
console.log(`Analyzing render pipeline artifacts:`)
console.log(` ${runDir}`)
const resultsPath = resolve(runDir, 'results.json')
const resultsRaw = await readFile(resultsPath, 'utf8')
const resultsJson = JSON.parse(resultsRaw) as BenchmarkJson
printComparison(resultsJson)
const webProfile = resolve(runDir, 'web/web.cpuprofile')
const nodeProfile = resolve(runDir, 'node/node.cpuprofile')
const [webAnalysis, nodeAnalysis] = await Promise.all([
analyzeProfile(webProfile, top),
analyzeProfile(nodeProfile, top),
])
if (!webAnalysis && !nodeAnalysis) {
console.log('\nNo CPU profiles found in this artifact run.')
console.log(
'This analyzer reads only <mode>/<mode>.cpuprofile artifacts (not trace-event JSON or next-runtime-trace.log).'
)
console.log(
'Run benchmark with --capture-cpu=true, e.g. pnpm bench:render-pipeline --scenario=full --stream-mode=node --capture-cpu=true'
)
return
}
if (webAnalysis) printProfileAnalysis('web', webAnalysis, top)
if (nodeAnalysis) printProfileAnalysis('node', nodeAnalysis, top)
console.log('\nDone.')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More