chore: add dev boot profiling and benchmark scripts (#87940)

## Summary

Development scripts for profiling and benchmarking Next.js dev server boot time.

### Benchmarking Scripts
- `benchmark-boot-time.sh` - Wall-clock benchmarking with two metrics:
  - **Listen time**: When TCP port accepts connections
  - **Ready time**: When first HTTP request succeeds
- `benchmark-next-dev-boot.js` - Multi-iteration benchmarking with statistics (median, p95, stddev)

### Profiling Scripts
- `profile-next-dev-boot.js` - CPU profiling infrastructure using V8 inspector
- `analyze-profile.js` - Analyze CPU profiles to identify hot modules by CPU time

### Analysis Scripts
- `analyze-dev-server-bundle.js` - Bundle analyzer for dev server (generates treemap report)
- `trace-cli-startup.js` - Module loading trace to identify slow imports

## Usage

```bash
# Benchmark dev server boot time (5 runs by default)
./scripts/benchmark-boot-time.sh

# Multi-iteration benchmark with stats
node scripts/benchmark-next-dev-boot.js --iterations 10

# Generate CPU profile
node scripts/profile-next-dev-boot.js

# Analyze profile output
node scripts/analyze-profile.js .next/cpu-profiles/*.cpuprofile

# Analyze dev server bundle
node scripts/analyze-dev-server-bundle.js --open
```

## Test Plan
- [x] Run `./scripts/benchmark-boot-time.sh` locally
- [x] Verify scripts execute without errors
This commit is contained in:
Jimmy Lai
2026-01-06 12:18:01 +01:00
committed by GitHub
parent 502a3d1f96
commit a39366938a
6 changed files with 1077 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Dev Server Bundle Analyzer
*
* Generates a bundle analyzer report for the dev server bundle.
*
* Usage:
* node scripts/analyze-bundle.js [options]
*
* Options:
* --open Open the report in browser (default: false)
* --verbose Show detailed module reasons
* --json Also output stats.json file
* --list-modules List all bundled modules to console
* --list-externals List all externalized modules
*/
const { execSync } = require('child_process')
const path = require('path')
const fs = require('fs')
// Parse arguments
const args = process.argv.slice(2)
const hasFlag = (name) => args.includes(`--${name}`)
const openBrowser = hasFlag('open')
const verbose = hasFlag('verbose')
const outputJson = hasFlag('json')
const listModules = hasFlag('list-modules')
const listExternals = hasFlag('list-externals')
const nextDir = path.join(__dirname, '..', 'packages', 'next')
const bundlePath = path.join(
nextDir,
'dist/compiled/dev-server/start-server.js'
)
const reportPath = path.join(
nextDir,
'dist/compiled/dev-server/bundle-report.html'
)
console.log('\x1b[34m=== Dev Server Bundle Analyzer ===\x1b[0m')
console.log('')
// Build with analyzer
console.log('Building bundle with analyzer...')
const env = {
...process.env,
ANALYZE: '1',
...(verbose ? { ANALYZE_REASONS: '1' } : {}),
}
try {
execSync('npx taskr next_bundle_dev_server', {
cwd: nextDir,
stdio: verbose ? 'inherit' : 'pipe',
env,
})
} catch (err) {
console.error('\x1b[31mBuild failed\x1b[0m')
process.exit(1)
}
// Get bundle stats
const stats = fs.statSync(bundlePath)
const sizeKB = Math.round(stats.size / 1024)
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2)
console.log('')
console.log('\x1b[32mBundle Stats:\x1b[0m')
console.log(` Size: ${sizeKB} KB (${sizeMB} MB)`)
console.log(` Path: ${bundlePath}`)
console.log(` Report: ${reportPath}`)
console.log('')
// List bundled modules
if (listModules) {
console.log('\x1b[33mBundled Modules:\x1b[0m')
const content = fs.readFileSync(bundlePath, 'utf-8')
const moduleMatches = content.match(/"\.\/dist\/[^"]+/g) || []
const modules = [...new Set(moduleMatches)]
.map((m) => m.replace(/^"/, ''))
.filter((m) => !m.includes(' recursive'))
.sort()
modules.forEach((m) => console.log(` ${m}`))
console.log(`\n Total: ${modules.length} modules`)
console.log('')
}
// List externalized modules
if (listExternals) {
console.log('\x1b[33mExternalized Modules:\x1b[0m')
const content = fs.readFileSync(bundlePath, 'utf-8')
// Find external requires
const externalMatches =
content.match(
/require\("(next\/dist\/[^"]+|@next\/[^"]+|styled-jsx[^"]*)"\)/g
) || []
const externals = [...new Set(externalMatches)]
.map((m) => m.match(/require\("([^"]+)"\)/)[1])
.sort()
externals.forEach((m) => console.log(` ${m}`))
console.log(`\n Total: ${externals.length} external requires`)
console.log('')
}
// Output JSON stats
if (outputJson) {
const statsJsonPath = path.join(
nextDir,
'dist/compiled/dev-server/stats.json'
)
console.log(`Stats JSON: ${statsJsonPath}`)
console.log('(Run with ANALYZE_REASONS=1 for detailed stats)')
}
// Open in browser
if (openBrowser) {
console.log('Opening report in browser...')
const opener =
process.platform === 'darwin'
? 'open'
: process.platform === 'win32'
? 'start'
: 'xdg-open'
try {
execSync(`${opener} "${reportPath}"`, { stdio: 'ignore' })
} catch {
console.log(`Could not open browser. Open manually: ${reportPath}`)
}
}
console.log('\x1b[32mDone!\x1b[0m')
console.log('')
console.log('Tips:')
console.log(' - Open the HTML report to see interactive treemap')
console.log(' - Use --list-modules to see all bundled modules')
console.log(' - Use --list-externals to see external requires')
console.log(' - Use --verbose for detailed build output')
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Analyze a CPU profile to identify hot modules
*/
const fs = require('fs')
const profilePath = process.argv[2]
if (!profilePath) {
console.error('Usage: node analyze-profile.js <profile.cpuprofile>')
process.exit(1)
}
const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'))
// Extract nodes with their hit counts
const nodes = profile.nodes || []
// Group by file/module
const moduleHits = {}
nodes.forEach((node) => {
const fn = node.callFrame
if (fn && fn.url) {
const url = fn.url
// Extract module name from path
let moduleName = url
if (url.includes('next/dist/')) {
moduleName = url.split('next/dist/')[1]
} else if (url.includes('node_modules/')) {
moduleName = 'node_modules/' + url.split('node_modules/').pop()
}
if (!moduleHits[moduleName]) {
moduleHits[moduleName] = { hits: 0 }
}
moduleHits[moduleName].hits += node.hitCount || 0
}
})
// Sort by hits
const sorted = Object.entries(moduleHits)
.filter(([_, v]) => v.hits > 0)
.sort((a, b) => b[1].hits - a[1].hits)
.slice(0, 40)
console.log('Top 40 modules by CPU time:')
console.log('='.repeat(70))
sorted.forEach(([name, data], i) => {
console.log(`${String(i + 1).padStart(2)}. ${name} (${data.hits} hits)`)
})
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
#
# Benchmark dev server boot time (wall-clock)
#
# Measures TWO metrics:
# 1. listen_time: When server starts accepting TCP connections
# 2. ready_time: When server responds to first HTTP request
#
# The delta between these shows how much initialization is deferred after "Ready".
#
# Usage:
# ./scripts/benchmark-boot-time.sh [runs] [test-dir]
#
# Examples:
# ./scripts/benchmark-boot-time.sh # 5 runs, uses /tmp/next-boot-test
# ./scripts/benchmark-boot-time.sh 3 # 3 runs
# ./scripts/benchmark-boot-time.sh 5 ./my-app # 5 runs on existing app
set -e
RUNS=${1:-5}
TEST_DIR=${2:-/tmp/next-boot-test}
NEXT_BIN="$(dirname "$0")/../packages/next/dist/bin/next"
PORT=3456
echo "=== Dev Server Boot Time Benchmark ==="
echo "Runs: $RUNS"
echo "Test dir: $TEST_DIR"
echo "Next.js: $NEXT_BIN"
echo ""
echo "Metrics:"
echo " listen_time: TCP port accepting connections"
echo " ready_time: First HTTP request succeeds"
echo " delta: ready_time - listen_time (deferred init)"
echo ""
# Create test app if it doesn't exist
if [ ! -f "$TEST_DIR/package.json" ]; then
echo "Creating test app..."
mkdir -p "$TEST_DIR/app"
cat > "$TEST_DIR/package.json" << 'EOF'
{
"name": "boot-test",
"private": true,
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
EOF
cat > "$TEST_DIR/app/layout.tsx" << 'EOF'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>
}
EOF
cat > "$TEST_DIR/app/page.tsx" << 'EOF'
export default function Home() { return <h1>Hello</h1> }
EOF
(cd "$TEST_DIR" && npm install --silent)
# Link local next
(cd "$TEST_DIR" && npm link "$(dirname "$NEXT_BIN")/.." 2>/dev/null || true)
fi
# Kill any existing next dev on our port
pkill -f "next dev.*$PORT" 2>/dev/null || true
sleep 0.5
# Returns: listen_time,ready_time (comma-separated)
benchmark_run() {
local label=$1
local clean_next=$2
if [ "$clean_next" = "true" ]; then
rm -rf "$TEST_DIR/.next"
fi
# Measure wall-clock time from command start
local start_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
local pid=$!
local timeout=600 # 30s at 50ms intervals
local listen_time=""
local ready_time=""
# Phase 1: Wait for port to be listening (nc -z)
for i in $(seq 1 $timeout); do
if nc -z localhost $PORT 2>/dev/null; then
listen_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
break
fi
sleep 0.05
done
# Phase 2: Wait for HTTP response (curl)
if [ -n "$listen_time" ]; then
for i in $(seq 1 $timeout); do
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
ready_time=$(python3 -c 'import time; print(int(time.time() * 1000))')
break
fi
sleep 0.05
done
fi
# Kill the server
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
if [ -n "$listen_time" ] && [ -n "$ready_time" ]; then
local listen_delta=$((listen_time - start_time))
local ready_delta=$((ready_time - start_time))
echo "$listen_delta,$ready_delta"
else
echo "TIMEOUT,TIMEOUT"
fi
}
run_benchmark_series() {
local series_name=$1
local clean_next=$2
echo "--- $series_name ---"
echo "Run | Listen | Ready | Delta"
echo "----|--------|-------|------"
local listen_times=""
local ready_times=""
local deltas=""
for i in $(seq 1 $RUNS); do
RESULT=$(benchmark_run "$series_name-$i" "$clean_next")
LISTEN=$(echo "$RESULT" | cut -d',' -f1)
READY=$(echo "$RESULT" | cut -d',' -f2)
if [ "$LISTEN" != "TIMEOUT" ] && [ "$READY" != "TIMEOUT" ]; then
DELTA=$((READY - LISTEN))
printf "%3d | %5dms | %5dms | %5dms\n" "$i" "$LISTEN" "$READY" "$DELTA"
listen_times="$listen_times $LISTEN"
ready_times="$ready_times $READY"
deltas="$deltas $DELTA"
else
printf "%3d | TIMEOUT | TIMEOUT | -\n" "$i"
fi
done
# Calculate averages
local listen_avg=$(echo $listen_times | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
local ready_avg=$(echo $ready_times | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
local delta_avg=$(echo $deltas | tr ' ' '\n' | grep -v '^$' | awk '{sum+=$1; count++} END {if(count>0) printf "%.0f", sum/count; else print "N/A"}')
echo ""
echo "Average: listen=${listen_avg}ms, ready=${ready_avg}ms, delta=${delta_avg}ms"
echo ""
# Export for summary
export "${series_name}_LISTEN_AVG=$listen_avg"
export "${series_name}_READY_AVG=$ready_avg"
export "${series_name}_DELTA_AVG=$delta_avg"
}
# Run cold start benchmarks
run_benchmark_series "COLD" true
# Warmup for bytecode cache
echo "--- Warming up bytecode cache (12s) ---"
"$NEXT_BIN" dev --turbopack --port $PORT "$TEST_DIR" > /dev/null 2>&1 &
WARMUP_PID=$!
for i in $(seq 1 200); do
if curl -s "http://localhost:$PORT" > /dev/null 2>&1; then
break
fi
sleep 0.05
done
sleep 12
kill $WARMUP_PID 2>/dev/null || true
wait $WARMUP_PID 2>/dev/null || true
echo ""
# Run warm start benchmarks
run_benchmark_series "WARM" false
# Summary
echo "=============================================="
echo " SUMMARY"
echo "=============================================="
echo ""
echo "Cold Start ($RUNS runs):"
echo " Port listening: ${COLD_LISTEN_AVG}ms"
echo " First request: ${COLD_READY_AVG}ms"
echo " Deferred init: ${COLD_DELTA_AVG}ms"
echo ""
echo "Warm Start ($RUNS runs):"
echo " Port listening: ${WARM_LISTEN_AVG}ms"
echo " First request: ${WARM_READY_AVG}ms"
echo " Deferred init: ${WARM_DELTA_AVG}ms"
echo ""
if [ "$COLD_READY_AVG" != "N/A" ] && [ "$WARM_READY_AVG" != "N/A" ]; then
CACHE_BENEFIT=$((COLD_READY_AVG - WARM_READY_AVG))
echo "Cache benefit: ${CACHE_BENEFIT}ms (cold - warm ready)"
fi
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* Dev Server Boot Time Benchmark
*
* Usage:
* node scripts/benchmark-boot.js [options]
*
* Options:
* --iterations=N Number of iterations (default: 5)
* --test-dir=PATH Test project directory (default: /private/tmp/next-boot-test)
* --bundled Use bundled dev server (default)
* --unbundled Use unbundled dev server
* --compare Run both bundled and unbundled for comparison
* --turbopack Use Turbopack (default)
* --webpack Use Webpack
*/
const { spawn, execSync } = require('child_process')
const path = require('path')
const fs = require('fs')
// Parse arguments
const args = process.argv.slice(2)
const getArg = (name, defaultValue) => {
const arg = args.find((a) => a.startsWith(`--${name}=`))
return arg ? arg.split('=')[1] : defaultValue
}
const hasFlag = (name) => args.includes(`--${name}`)
const iterations = parseInt(getArg('iterations', '5'), 10)
const testDir = getArg('test-dir', '/private/tmp/next-boot-test')
const compare = hasFlag('compare')
const useWebpack = hasFlag('webpack')
const bundlerFlag = useWebpack ? '--webpack' : '--turbopack'
const nextDir = path.join(__dirname, '..', 'packages', 'next')
const nextBin = path.join(nextDir, 'dist/bin/next')
const cliSource = path.join(nextDir, 'src/cli/next-dev.ts')
console.log('\x1b[34m=== Next.js Dev Server Boot Benchmark ===\x1b[0m')
console.log(`Iterations: ${iterations}`)
console.log(`Test directory: ${testDir}`)
console.log(`Bundler: ${useWebpack ? 'Webpack' : 'Turbopack'}`)
console.log('')
// Verify test directory exists
if (!fs.existsSync(testDir)) {
console.error(
`\x1b[31mError: Test directory does not exist: ${testDir}\x1b[0m`
)
console.log('Create a test project first:')
console.log(` mkdir -p ${testDir} && cd ${testDir}`)
console.log(' pnpm init && pnpm add next@canary react react-dom')
console.log(
' mkdir -p app && echo "export default function Page() { return <h1>Hello</h1> }" > app/page.tsx'
)
process.exit(1)
}
// Kill existing next dev processes
function killNextDev() {
try {
execSync('pkill -f "next dev"', { stdio: 'ignore' })
} catch {}
}
// Run a single benchmark iteration
function runIteration() {
return new Promise((resolve, reject) => {
// Clean .next directory
const nextCache = path.join(testDir, '.next')
if (fs.existsSync(nextCache)) {
fs.rmSync(nextCache, { recursive: true, force: true })
}
const startTime = Date.now()
let resolved = false
const child = spawn(nextBin, ['dev', bundlerFlag], {
cwd: testDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, FORCE_COLOR: '0' },
})
let output = ''
const onData = (data) => {
output += data.toString()
// Look for "Ready in Xms" pattern
const match = output.match(/Ready in (\d+)ms/)
if (match && !resolved) {
resolved = true
const reportedTime = parseInt(match[1], 10)
const actualTime = Date.now() - startTime
child.kill('SIGTERM')
resolve({ reportedTime, actualTime })
}
}
child.stdout.on('data', onData)
child.stderr.on('data', onData)
child.on('error', (err) => {
if (!resolved) {
resolved = true
reject(err)
}
})
// Timeout after 60 seconds
setTimeout(() => {
if (!resolved) {
resolved = true
child.kill('SIGKILL')
reject(new Error('Timeout waiting for server to start'))
}
}, 60000)
})
}
// Run benchmark with multiple iterations
async function runBenchmark(name) {
console.log(`\x1b[33mRunning ${name}...\x1b[0m`)
const reportedTimes = []
const actualTimes = []
for (let i = 1; i <= iterations; i++) {
try {
killNextDev()
await new Promise((r) => setTimeout(r, 500))
const { reportedTime, actualTime } = await runIteration()
reportedTimes.push(reportedTime)
actualTimes.push(actualTime)
console.log(
` Run ${i}: ${reportedTime}ms (reported) / ${actualTime}ms (actual)`
)
} catch (err) {
console.log(` Run ${i}: Failed - ${err.message}`)
}
}
killNextDev()
if (reportedTimes.length === 0) {
console.log('\x1b[31mNo successful runs\x1b[0m')
return null
}
// Calculate statistics
const calcStats = (times) => {
const sum = times.reduce((a, b) => a + b, 0)
const avg = Math.round(sum / times.length)
const min = Math.min(...times)
const max = Math.max(...times)
const sorted = [...times].sort((a, b) => a - b)
const median = sorted[Math.floor(sorted.length / 2)]
return { avg, min, max, median, count: times.length }
}
const reported = calcStats(reportedTimes)
const actual = calcStats(actualTimes)
console.log(`\x1b[32mResults for ${name}:\x1b[0m`)
console.log(` Reported time (Next.js internal):`)
console.log(
` Avg: ${reported.avg}ms | Min: ${reported.min}ms | Max: ${reported.max}ms | Median: ${reported.median}ms`
)
console.log(` Actual time (CLI to ready):`)
console.log(
` Avg: ${actual.avg}ms | Min: ${actual.min}ms | Max: ${actual.max}ms | Median: ${actual.median}ms`
)
console.log('')
return { reported, actual }
}
// Switch between bundled/unbundled
function setBundled(useBundled) {
const content = fs.readFileSync(cliSource, 'utf-8')
const bundledPath = `require.resolve(
'../compiled/dev-server/start-server'
)`
const unbundledPath = `require.resolve('../server/lib/start-server')`
let newContent
if (useBundled) {
newContent = content.replace(
/const startServerPath = require\.resolve\(['"]\.\.\/server\/lib\/start-server['"]\)/,
`const startServerPath = ${bundledPath}`
)
} else {
newContent = content.replace(
/const startServerPath = require\.resolve\(\s*['"]\.\.\/compiled\/dev-server\/start-server['"]\s*\)/,
`const startServerPath = ${unbundledPath}`
)
}
if (newContent !== content) {
fs.writeFileSync(cliSource, newContent)
// Rebuild CLI
console.log(`Rebuilding CLI (${useBundled ? 'bundled' : 'unbundled'})...`)
execSync('npx taskr cli', { cwd: nextDir, stdio: 'ignore' })
}
}
// Main
async function main() {
killNextDev()
if (compare) {
// Run both bundled and unbundled
setBundled(true)
const bundledResults = await runBenchmark('Bundled dev server')
setBundled(false)
const unbundledResults = await runBenchmark('Unbundled dev server')
// Restore to bundled
setBundled(true)
// Print comparison
console.log('\x1b[34m=== Comparison ===\x1b[0m')
if (bundledResults && unbundledResults) {
const reportedDiff =
bundledResults.reported.avg - unbundledResults.reported.avg
const actualDiff = bundledResults.actual.avg - unbundledResults.actual.avg
console.log(
`Reported time difference: ${reportedDiff > 0 ? '+' : ''}${reportedDiff}ms (${reportedDiff > 0 ? 'bundled slower' : 'bundled faster'})`
)
console.log(
`Actual time difference: ${actualDiff > 0 ? '+' : ''}${actualDiff}ms (${actualDiff > 0 ? 'bundled slower' : 'bundled faster'})`
)
}
} else {
await runBenchmark('Dev server')
}
console.log('\x1b[32mDone!\x1b[0m')
}
main().catch(console.error)
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env node
/**
* Next.js CPU Profile Script
*
* Generates CPU profiles for Next.js startup and dev server boot.
*
* Usage:
* node scripts/profile-next-dev-boot.js [options]
*
* Options:
* --test-dir=PATH Test project directory (default: /private/tmp/next-boot-test)
* --output-dir=PATH Output directory for profiles (default: ./profiles)
* --turbopack Use Turbopack (default)
* --webpack Use Webpack
* --duration=MS How long to profile after ready (default: 1000)
* --cli Profile just the CLI entry point (runs next --help)
*
* Output files:
* - dev-turbopack-YYYY-MM-DDTHH-MM-SS.cpuprofile
* - cli-turbopack-YYYY-MM-DDTHH-MM-SS.cpuprofile
*
* The profile can be loaded in:
* - Chrome DevTools (Performance tab -> Load profile)
* - VS Code (JavaScript Profile Visualizer extension)
* - https://www.speedscope.app/
*
* Note: Currently profiles the parent process only. For child process profiling,
* additional Next.js changes are needed (see future PRs).
*/
const { spawn, execSync } = require('child_process')
const path = require('path')
const fs = require('fs')
// Parse arguments
const args = process.argv.slice(2)
const getArg = (name, defaultValue) => {
const arg = args.find((a) => a.startsWith(`--${name}=`))
return arg ? arg.split('=')[1] : defaultValue
}
const hasFlag = (name) => args.includes(`--${name}`)
const testDir = getArg('test-dir', '/private/tmp/next-boot-test')
const baseOutputDir =
getArg('output-dir', null) || path.join(process.cwd(), 'profiles')
const useWebpack = hasFlag('webpack')
const duration = parseInt(getArg('duration', '1000'), 10)
const profileCli = hasFlag('cli')
const bundlerFlag = useWebpack ? '--webpack' : '--turbopack'
// Generate meaningful profile names
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const bundlerName = useWebpack ? 'webpack' : 'turbopack'
const profileType = profileCli ? 'cli' : 'dev'
const outputDir = baseOutputDir
const profileName = `${profileType}-${bundlerName}-${timestamp}`
const nextDir = path.join(__dirname, '..', 'packages', 'next')
const nextBin = path.join(nextDir, 'dist/bin/next')
if (profileCli) {
console.log('\x1b[34m=== Next.js CLI Entry Point Profile ===\x1b[0m')
} else {
console.log('\x1b[34m=== Next.js Dev Server CPU Profile ===\x1b[0m')
console.log(`Test directory: ${testDir}`)
console.log(`Bundler: ${useWebpack ? 'Webpack' : 'Turbopack'}`)
}
console.log(`Output directory: ${outputDir}`)
console.log('')
// Verify test directory (only for dev server profiling)
if (!profileCli && !fs.existsSync(testDir)) {
console.error(
`\x1b[31mError: Test directory does not exist: ${testDir}\x1b[0m`
)
process.exit(1)
}
// Create output directory
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
// Kill existing processes
function killNextDev() {
try {
execSync('pkill -f "next dev"', { stdio: 'ignore' })
} catch {}
}
async function runProfile() {
killNextDev()
await new Promise((r) => setTimeout(r, 500))
// Clean .next directory
const nextCache = path.join(testDir, '.next')
if (fs.existsSync(nextCache)) {
fs.rmSync(nextCache, { recursive: true, force: true })
}
console.log('Starting dev server with CPU profiling...')
console.log('(Profile will be saved after server is ready)')
console.log('')
return new Promise((resolve, reject) => {
let resolved = false
// Profile the parent process with --cpu-prof
const spawnArgs = [
process.execPath,
[
'--cpu-prof',
`--cpu-prof-dir=${outputDir}`,
`--cpu-prof-name=${profileName}`,
nextBin,
'dev',
bundlerFlag,
],
]
const child = spawn(spawnArgs[0], spawnArgs[1], {
cwd: testDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, FORCE_COLOR: '0' },
})
let output = ''
const onData = (data) => {
const text = data.toString()
output += text
process.stdout.write(text)
// Wait for "Ready in Xms"
if (output.includes('Ready in') && !resolved) {
resolved = true
console.log('')
console.log(
`\x1b[33mServer ready, profiling for ${duration}ms more...\x1b[0m`
)
// Wait a bit then stop
setTimeout(() => {
console.log('Stopping server and saving profile...')
child.kill('SIGINT')
}, duration)
}
}
child.stdout.on('data', onData)
child.stderr.on('data', onData)
child.on('close', (code) => {
killNextDev()
// Wait a moment for profile files to be written
setTimeout(() => {
// Find and rename profiles matching our name pattern
// --cpu-prof-name creates files without extension
const files = fs.readdirSync(outputDir)
const rawFiles = files.filter(
(f) => f.startsWith(profileName) && !f.endsWith('.cpuprofile')
)
// Rename raw files to have .cpuprofile extension
rawFiles.forEach((f) => {
const oldPath = path.join(outputDir, f)
const newPath = path.join(outputDir, `${f}.cpuprofile`)
fs.renameSync(oldPath, newPath)
})
// Now find all .cpuprofile files
const profileFiles = fs
.readdirSync(outputDir)
.filter((f) => f.startsWith(profileName) && f.endsWith('.cpuprofile'))
const profiles = profileFiles
.map((f) => ({
name: f,
path: path.join(outputDir, f),
size: fs.statSync(path.join(outputDir, f)).size,
}))
.filter((p) => p.size > 0)
.sort((a, b) => b.size - a.size)
if (profiles.length > 0) {
console.log('')
console.log(`\x1b[32mProfile(s) saved:\x1b[0m`)
profiles.forEach((p, i) => {
const sizeKB = Math.round(p.size / 1024)
console.log(` ${i + 1}. ${p.path} (${sizeKB} KB)`)
})
console.log('')
console.log('To view the profile:')
console.log(' 1. Open Chrome DevTools -> Performance tab')
console.log(' 2. Click "Load profile" and select the file')
console.log(' 3. Or use https://www.speedscope.app/')
console.log('')
console.log(
'\x1b[33mTip:\x1b[0m The largest profile is usually the child process (server worker)'
)
resolve(profiles[0].path)
} else {
console.log('')
console.log(
'\x1b[33mNo profiles found. Trying alternative method...\x1b[0m'
)
console.log('')
console.log(
'To profile the child process, modify next-dev.ts to add profiling flags.'
)
console.log(
'Or use: node --cpu-prof --cpu-prof-dir=./profiles ./dist/bin/next dev'
)
reject(new Error('Profile file not found'))
}
}, 500)
})
child.on('error', reject)
// Timeout
setTimeout(() => {
if (!resolved) {
child.kill('SIGKILL')
reject(new Error('Timeout waiting for server'))
}
}, 120000)
})
}
async function runCliProfile() {
console.log('Profiling CLI entry point (next --help)...')
console.log('')
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
'--cpu-prof',
`--cpu-prof-dir=${outputDir}`,
`--cpu-prof-name=${profileName}`,
nextBin,
'--help',
],
{
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, FORCE_COLOR: '0' },
}
)
child.stdout.on('data', () => {})
child.stderr.on('data', () => {})
child.on('close', (code) => {
// Wait for profile to be written
setTimeout(() => {
// --cpu-prof-name creates files without extension, rename to .cpuprofile
const rawFile = path.join(outputDir, profileName)
const finalFile = path.join(outputDir, `${profileName}.cpuprofile`)
if (fs.existsSync(rawFile)) {
fs.renameSync(rawFile, finalFile)
const size = fs.statSync(finalFile).size
console.log(`\x1b[32mProfile saved:\x1b[0m`)
console.log(` ${finalFile} (${Math.round(size / 1024)} KB)`)
console.log('')
console.log('To view the profile:')
console.log(' 1. Open Chrome DevTools -> Performance tab')
console.log(' 2. Click "Load profile" and select the file')
console.log(' 3. Or use https://www.speedscope.app/')
console.log('')
console.log(
'\x1b[33mTip:\x1b[0m Look for heavy modules loaded at startup'
)
resolve(finalFile)
} else if (fs.existsSync(finalFile)) {
const size = fs.statSync(finalFile).size
console.log(`\x1b[32mProfile saved:\x1b[0m`)
console.log(` ${finalFile} (${Math.round(size / 1024)} KB)`)
resolve(finalFile)
} else {
reject(new Error('Profile file not found'))
}
}, 500)
})
child.on('error', reject)
})
}
// Main execution
const main = profileCli ? runCliProfile : runProfile
main().catch((err) => {
console.error('\x1b[31mError:\x1b[0m', err.message)
if (!profileCli) killNextDev()
process.exit(1)
})
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* CLI Startup Tracer
*
* Uses the V8 Inspector API to trace module loading at CLI startup.
* This helps identify which modules are being loaded eagerly.
*
* Usage:
* node scripts/trace-cli-startup.js [--command=dev|build|--help]
*/
const inspector = require('inspector')
const fs = require('fs')
const path = require('path')
const args = process.argv.slice(2)
const getArg = (name, defaultValue) => {
const arg = args.find((a) => a.startsWith(`--${name}=`))
return arg ? arg.split('=')[1] : defaultValue
}
const command = getArg('command', '--help')
const outputDir = path.join(process.cwd(), 'profiles')
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
console.log('\x1b[34m=== Next.js CLI Startup Trace ===\x1b[0m')
console.log(`Command: next ${command}`)
console.log(`Output directory: ${outputDir}`)
console.log('')
// Start CPU profiling
const session = new inspector.Session()
session.connect()
// Track module loading via require hook
const Module = require('module')
const originalRequire = Module.prototype.require
const loadedModules = []
const moduleLoadTimes = []
Module.prototype.require = function (id) {
const start = process.hrtime.bigint()
const result = originalRequire.apply(this, arguments)
const end = process.hrtime.bigint()
const durationMs = Number(end - start) / 1e6
// Get the resolved path
let resolvedPath = id
try {
resolvedPath = require.resolve(id, { paths: [this.path || process.cwd()] })
} catch {}
// Filter to show only Next.js-related modules
if (resolvedPath.includes('next/dist/') || resolvedPath.includes('@next/')) {
const shortPath = resolvedPath.includes('next/dist/')
? resolvedPath.split('next/dist/')[1]
: resolvedPath
if (!loadedModules.includes(shortPath)) {
loadedModules.push(shortPath)
moduleLoadTimes.push({ module: shortPath, time: durationMs })
}
}
return result
}
// Save original process.exit and intercept to prevent CLI from exiting mid-profile
const originalExit = process.exit
process.exit = () => {
// Don't actually exit during profiling - we want to capture the full profile
}
// Start profiling
session.post('Profiler.enable', () => {
session.post('Profiler.start', () => {
console.log('Starting CLI with profiling...')
console.log('')
const startTime = process.hrtime.bigint()
// Load the CLI
try {
process.argv = [process.argv[0], 'next', command]
require('../packages/next/dist/bin/next')
} catch (e) {
// Expected - CLI might throw
}
const endTime = process.hrtime.bigint()
const totalMs = Number(endTime - startTime) / 1e6
// Stop profiling and save
session.post('Profiler.stop', (err, { profile }) => {
if (err) {
console.error('Error stopping profiler:', err)
} else {
const profilePath = path.join(
outputDir,
`cli-startup-${Date.now()}.cpuprofile`
)
fs.writeFileSync(profilePath, JSON.stringify(profile))
console.log(`\x1b[32mProfile saved:\x1b[0m ${profilePath}`)
}
// Print results
console.log('')
console.log(`\x1b[32mTotal startup time:\x1b[0m ${totalMs.toFixed(2)}ms`)
console.log('')
console.log(`\x1b[33mModules loaded (${loadedModules.length}):\x1b[0m`)
console.log('='.repeat(70))
// Sort by load time
moduleLoadTimes.sort((a, b) => b.time - a.time)
moduleLoadTimes.slice(0, 30).forEach((m, i) => {
const timeStr =
m.time > 1
? `${m.time.toFixed(1)}ms`
: `${(m.time * 1000).toFixed(0)}μs`
console.log(`${String(i + 1).padStart(2)}. ${m.module} (${timeStr})`)
})
console.log('')
console.log(`\x1b[33mAll loaded modules:\x1b[0m`)
console.log(loadedModules.join('\n'))
// Restore original require
Module.prototype.require = originalRequire
session.disconnect()
// Exit cleanly now that profiling is complete
originalExit(0)
})
})
})