|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Memory benchmark for a running Next.js dev server. |
| 4 | + * |
| 5 | + * Discovers all routes via the MCP get_routes endpoint, then compiles each |
| 6 | + * one via the MCP compile_route endpoint — no HTTP requests to the application |
| 7 | + * are made. Samples RSS/VSZ/physical-footprint throughout and writes a CSV. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * node scripts/benchmark-memory.js <base-url> [output-file] [--pause[=N]] |
| 11 | + * |
| 12 | + * Options: |
| 13 | + * --pause[=N] Wait 4 seconds after every N compilations (default: 10) |
| 14 | + * |
| 15 | + * Examples: |
| 16 | + * node scripts/benchmark-memory.js http://localhost:3000 |
| 17 | + * node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv |
| 18 | + * node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv --pause |
| 19 | + * node scripts/benchmark-memory.js http://localhost:3000 /tmp/run-a.csv --pause=25 |
| 20 | + * |
| 21 | + * Output CSV columns: |
| 22 | + * timestamp, elapsed_ms, rss_kb, vsz_kb, footprint_kb, event |
| 23 | + * |
| 24 | + * 'event' is blank for periodic samples, or "compile:<page>" recorded |
| 25 | + * immediately after each route is compiled. |
| 26 | + * |
| 27 | + * Requires: Next.js dev server with experimental.mcpServer enabled. |
| 28 | + */ |
| 29 | + |
| 30 | +const { spawnSync } = require('child_process') |
| 31 | +const fs = require('fs') |
| 32 | +const http = require('http') |
| 33 | +const https = require('https') |
| 34 | +const { URL } = require('url') |
| 35 | + |
| 36 | +// --------------------------------------------------------------------------- |
| 37 | +// Config |
| 38 | +// --------------------------------------------------------------------------- |
| 39 | + |
| 40 | +const MAX_ROUTES = 50 |
| 41 | +const SETTLE_MS = 20_000 |
| 42 | +const SAMPLE_INTERVAL_MS = 250 |
| 43 | +const PAUSE_MS = 10_000 |
| 44 | + |
| 45 | +const positional = process.argv.slice(2).filter((a) => !a.startsWith('--')) |
| 46 | +const flagArgs = process.argv.slice(2).filter((a) => a.startsWith('--')) |
| 47 | +const [baseUrlArg, outFileArg] = positional |
| 48 | + |
| 49 | +if (!baseUrlArg) { |
| 50 | + console.error( |
| 51 | + 'Usage: node scripts/benchmark-memory.js <base-url> [output-file] [--pause[=N]]' |
| 52 | + ) |
| 53 | + process.exit(1) |
| 54 | +} |
| 55 | + |
| 56 | +const BASE_URL = baseUrlArg.replace(/\/$/, '') |
| 57 | +const OUTFILE = |
| 58 | + outFileArg || |
| 59 | + `/tmp/next-memory-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.csv` |
| 60 | + |
| 61 | +// --pause enables pausing, optionally with =N to set the pause interval |
| 62 | +// (pause every N compilations). Default interval is 10. |
| 63 | +const DEFAULT_PAUSE_EVERY = 10 |
| 64 | +const pauseFlag = flagArgs.find( |
| 65 | + (a) => a === '--pause' || a.startsWith('--pause=') |
| 66 | +) |
| 67 | +const PAUSE_EVERY = pauseFlag |
| 68 | + ? pauseFlag.includes('=') |
| 69 | + ? parseInt(pauseFlag.slice('--pause='.length), 10) |
| 70 | + : DEFAULT_PAUSE_EVERY |
| 71 | + : 0 |
| 72 | +if (pauseFlag && (!Number.isFinite(PAUSE_EVERY) || PAUSE_EVERY <= 0)) { |
| 73 | + console.error(`Invalid --pause value: ${pauseFlag}`) |
| 74 | + process.exit(1) |
| 75 | +} |
| 76 | + |
| 77 | +// --------------------------------------------------------------------------- |
| 78 | +// Find next-server PID |
| 79 | +// --------------------------------------------------------------------------- |
| 80 | + |
| 81 | +function findNextServerPid() { |
| 82 | + const result = spawnSync('pgrep', ['-f', 'next-server'], { encoding: 'utf8' }) |
| 83 | + const pids = result.stdout.trim().split('\n').filter(Boolean) |
| 84 | + if (pids.length === 0) { |
| 85 | + console.error( |
| 86 | + 'Error: no next-server process found. Start the dev server first.' |
| 87 | + ) |
| 88 | + process.exit(1) |
| 89 | + } |
| 90 | + if (pids.length > 1) { |
| 91 | + console.error( |
| 92 | + `Error: multiple next-server processes found (pids: ${pids.join(', ')}).\nStop all but one and retry.` |
| 93 | + ) |
| 94 | + process.exit(1) |
| 95 | + } |
| 96 | + return parseInt(pids[0], 10) |
| 97 | +} |
| 98 | + |
| 99 | +// --------------------------------------------------------------------------- |
| 100 | +// RSS/VSZ sampling (macOS: ps returns KB) |
| 101 | +// --------------------------------------------------------------------------- |
| 102 | + |
| 103 | +function sampleMemory(pid) { |
| 104 | + const result = spawnSync('ps', ['-o', 'rss=,vsz=', '-p', String(pid)], { |
| 105 | + encoding: 'utf8', |
| 106 | + }) |
| 107 | + if (result.status !== 0 || !result.stdout.trim()) return null |
| 108 | + const [rss, vsz] = result.stdout.trim().split(/\s+/).map(Number) |
| 109 | + return { rss, vsz } |
| 110 | +} |
| 111 | + |
| 112 | +const PHYSICAL_FOOTPRINT_ONESHOT = ` |
| 113 | +import sys, struct, ctypes, ctypes.util |
| 114 | +libc = ctypes.CDLL(ctypes.util.find_library('c')) |
| 115 | +buf = ctypes.create_string_buffer(256) |
| 116 | +ret = libc.proc_pid_rusage(int(sys.argv[1]), 4, buf) |
| 117 | +if ret != 0: |
| 118 | + sys.exit(1) |
| 119 | +print(struct.unpack_from('<Q', buf, 72)[0] // 1024) |
| 120 | +` |
| 121 | + |
| 122 | +function makeFootprintSampler(pid) { |
| 123 | + return { |
| 124 | + sample() { |
| 125 | + const result = spawnSync( |
| 126 | + 'python3', |
| 127 | + ['-c', PHYSICAL_FOOTPRINT_ONESHOT, String(pid)], |
| 128 | + { encoding: 'utf8' } |
| 129 | + ) |
| 130 | + if (result.status !== 0) return null |
| 131 | + return Number(result.stdout.trim()) |
| 132 | + }, |
| 133 | + close() {}, |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +// --------------------------------------------------------------------------- |
| 138 | +// HTTP fetch helper (no external deps) |
| 139 | +// --------------------------------------------------------------------------- |
| 140 | + |
| 141 | +function fetchUrl(url, timeoutMs = 10_000, options = {}) { |
| 142 | + return new Promise((resolve, reject) => { |
| 143 | + const parsed = new URL(url) |
| 144 | + const lib = parsed.protocol === 'https:' ? https : http |
| 145 | + const reqOptions = { |
| 146 | + method: options.method ?? 'GET', |
| 147 | + headers: { |
| 148 | + 'User-Agent': 'next-memory-benchmark', |
| 149 | + ...options.headers, |
| 150 | + }, |
| 151 | + } |
| 152 | + const req = lib.request(url, reqOptions, (res) => { |
| 153 | + let body = '' |
| 154 | + res.on('data', (chunk) => (body += chunk)) |
| 155 | + res.on('end', () => resolve({ status: res.statusCode, body })) |
| 156 | + }) |
| 157 | + req.setTimeout(timeoutMs, () => { |
| 158 | + req.destroy() |
| 159 | + reject(new Error(`Timeout fetching ${url}`)) |
| 160 | + }) |
| 161 | + req.on('error', reject) |
| 162 | + if (options.body) req.write(options.body) |
| 163 | + req.end() |
| 164 | + }) |
| 165 | +} |
| 166 | + |
| 167 | +// --------------------------------------------------------------------------- |
| 168 | +// MCP helpers |
| 169 | +// --------------------------------------------------------------------------- |
| 170 | + |
| 171 | +// Call a single MCP tool. Returns { isError, body } where body is the parsed |
| 172 | +// JSON content from the tool's response. |
| 173 | +async function callMcpTool(toolName, args, timeoutMs = 5_000) { |
| 174 | + const url = `${BASE_URL}/_next/mcp` |
| 175 | + const body = JSON.stringify({ |
| 176 | + jsonrpc: '2.0', |
| 177 | + method: 'tools/call', |
| 178 | + params: { name: toolName, arguments: args }, |
| 179 | + id: 1, |
| 180 | + }) |
| 181 | + |
| 182 | + const res = await fetchUrl(url, timeoutMs, { |
| 183 | + method: 'POST', |
| 184 | + headers: { |
| 185 | + 'Content-Type': 'application/json', |
| 186 | + Accept: 'application/json, text/event-stream', |
| 187 | + }, |
| 188 | + body, |
| 189 | + }) |
| 190 | + |
| 191 | + // Response is SSE: "event: message\ndata: {...}\n\n" |
| 192 | + const dataLine = res.body.split('\n').find((l) => l.startsWith('data:')) |
| 193 | + if (!dataLine) |
| 194 | + throw new Error(`MCP: no data line in response for ${toolName}`) |
| 195 | + |
| 196 | + const envelope = JSON.parse(dataLine.slice('data:'.length).trim()) |
| 197 | + if (envelope.error) |
| 198 | + throw new Error(`MCP error: ${JSON.stringify(envelope.error)}`) |
| 199 | + if (!envelope.result?.content?.[0]?.text) |
| 200 | + throw new Error(`MCP: unexpected response shape for ${toolName}`) |
| 201 | + |
| 202 | + return { |
| 203 | + isError: !!envelope.result.isError, |
| 204 | + body: JSON.parse(envelope.result.content[0].text), |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +// --------------------------------------------------------------------------- |
| 209 | +// Route discovery and compilation via MCP |
| 210 | +// --------------------------------------------------------------------------- |
| 211 | + |
| 212 | +async function discoverRoutes() { |
| 213 | + const { isError, body } = await callMcpTool('get_routes', {}) |
| 214 | + if (isError) { |
| 215 | + throw new Error(`get_routes failed: ${JSON.stringify(body)}`) |
| 216 | + } |
| 217 | + const allRoutes = [ |
| 218 | + ...new Set([...(body.appRouter ?? []), ...(body.pagesRouter ?? [])]), |
| 219 | + ] |
| 220 | + return { |
| 221 | + allRoutes, |
| 222 | + appCount: (body.appRouter ?? []).length, |
| 223 | + pagesCount: (body.pagesRouter ?? []).length, |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +// Compile all routes via MCP compile_route. Calls onCompiled(routeSpecifier) |
| 228 | +// after each successful compilation, where routeSpecifier is the resolved |
| 229 | +// route as returned by the server. |
| 230 | +async function compileRoutes(routes, onCompiled) { |
| 231 | + let compiledSincePause = 0 |
| 232 | + for (const routeSpecifier of routes) { |
| 233 | + const { isError, body } = await callMcpTool( |
| 234 | + 'compile_route', |
| 235 | + { routeSpecifier }, |
| 236 | + // Some compilation can be very slow |
| 237 | + 120_000 |
| 238 | + ) |
| 239 | + if (!isError) { |
| 240 | + onCompiled(body.routeSpecifier ?? routeSpecifier) |
| 241 | + compiledSincePause++ |
| 242 | + if (body.issues?.length) { |
| 243 | + console.warn( |
| 244 | + `\n ${routeSpecifier} compiled with ${body.issues.length} issue(s)` |
| 245 | + ) |
| 246 | + } |
| 247 | + } else if (body.notFound) { |
| 248 | + console.warn(`\n skipped (not found): ${routeSpecifier}`) |
| 249 | + } else { |
| 250 | + console.warn( |
| 251 | + `\n compile_route error for ${routeSpecifier}: ${body.error}` |
| 252 | + ) |
| 253 | + } |
| 254 | + if (PAUSE_EVERY && compiledSincePause >= PAUSE_EVERY) { |
| 255 | + compiledSincePause = 0 |
| 256 | + await new Promise((r) => setTimeout(r, PAUSE_MS)) |
| 257 | + } |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +// --------------------------------------------------------------------------- |
| 262 | +// Main |
| 263 | +// --------------------------------------------------------------------------- |
| 264 | + |
| 265 | +async function main() { |
| 266 | + const pid = findNextServerPid() |
| 267 | + console.log(`Found next-server PID: ${pid}`) |
| 268 | + console.log(`Base URL: ${BASE_URL}`) |
| 269 | + console.log(`Output: ${OUTFILE}`) |
| 270 | + if (PAUSE_EVERY) |
| 271 | + console.log( |
| 272 | + `Pause mode: ${PAUSE_MS / 1000}s after every ${PAUSE_EVERY} compilations` |
| 273 | + ) |
| 274 | + console.log('') |
| 275 | + |
| 276 | + const footprintSampler = makeFootprintSampler(pid) |
| 277 | + const startTime = Date.now() |
| 278 | + const rows = [ |
| 279 | + ['timestamp', 'elapsed_ms', 'rss_kb', 'vsz_kb', 'footprint_kb', 'event'], |
| 280 | + ] |
| 281 | + |
| 282 | + let routesCompiled = 0 |
| 283 | + |
| 284 | + function now() { |
| 285 | + return Date.now() - startTime |
| 286 | + } |
| 287 | + |
| 288 | + function recordSample(event = '') { |
| 289 | + const mem = sampleMemory(pid) |
| 290 | + if (!mem) return null |
| 291 | + const footprint = footprintSampler.sample() ?? '' |
| 292 | + rows.push([ |
| 293 | + new Date().toISOString(), |
| 294 | + now(), |
| 295 | + mem.rss, |
| 296 | + mem.vsz, |
| 297 | + footprint, |
| 298 | + event, |
| 299 | + ]) |
| 300 | + return { ...mem, footprint } |
| 301 | + } |
| 302 | + |
| 303 | + // Periodic sampler |
| 304 | + const sampler = setInterval(() => { |
| 305 | + const mem = recordSample() |
| 306 | + if (!mem) { |
| 307 | + console.error('\nProcess exited during benchmark.') |
| 308 | + clearInterval(sampler) |
| 309 | + return |
| 310 | + } |
| 311 | + const elapsed = (now() / 1000).toFixed(1) |
| 312 | + const rssMb = (mem.rss / 1024).toFixed(1) |
| 313 | + const footprintMb = mem.footprint ? (mem.footprint / 1024).toFixed(1) : '?' |
| 314 | + process.stdout.write( |
| 315 | + `\r[${elapsed}s] RSS=${rssMb}MB footprint=${footprintMb}MB compiled=${routesCompiled} ` |
| 316 | + ) |
| 317 | + }, SAMPLE_INTERVAL_MS) |
| 318 | + |
| 319 | + // Discover routes via MCP get_routes |
| 320 | + let allRoutes, appCount, pagesCount |
| 321 | + try { |
| 322 | + ;({ allRoutes, appCount, pagesCount } = await discoverRoutes()) |
| 323 | + } catch (err) { |
| 324 | + clearInterval(sampler) |
| 325 | + console.error(`\nFailed to discover routes via MCP: ${err.message}`) |
| 326 | + console.error( |
| 327 | + 'Make sure the dev server is running with experimental.mcpServer enabled.' |
| 328 | + ) |
| 329 | + process.exit(1) |
| 330 | + } |
| 331 | + |
| 332 | + console.log( |
| 333 | + `Discovered ${appCount} app router + ${pagesCount} pages router routes via MCP.` |
| 334 | + ) |
| 335 | + |
| 336 | + // Compile each route via MCP compile_route |
| 337 | + const routesToCompile = allRoutes.slice(0, MAX_ROUTES) |
| 338 | + try { |
| 339 | + await compileRoutes(routesToCompile, (page) => { |
| 340 | + routesCompiled++ |
| 341 | + recordSample(`compile:${page}`) |
| 342 | + }) |
| 343 | + } catch (err) { |
| 344 | + clearInterval(sampler) |
| 345 | + console.error(`\ncompile_route failed: ${err.message}`) |
| 346 | + process.exit(1) |
| 347 | + } |
| 348 | + |
| 349 | + console.log(`\nCompiled ${routesCompiled} routes.`) |
| 350 | + console.log(`Settling for ${SETTLE_MS / 1000}s...`) |
| 351 | + await new Promise((r) => setTimeout(r, SETTLE_MS)) |
| 352 | + |
| 353 | + clearInterval(sampler) |
| 354 | + footprintSampler.close() |
| 355 | + |
| 356 | + // Write CSV |
| 357 | + const csv = rows.map((r) => r.join(',')).join('\n') + '\n' |
| 358 | + fs.writeFileSync(OUTFILE, csv) |
| 359 | + |
| 360 | + // Summary |
| 361 | + const dataRows = rows.slice(1).filter((r) => r[2]) |
| 362 | + const rssValues = dataRows.map((r) => Number(r[2])) |
| 363 | + const vszValues = dataRows.map((r) => Number(r[3])) |
| 364 | + const footprintValues = dataRows.map((r) => Number(r[4])).filter(Boolean) |
| 365 | + |
| 366 | + const minRss = Math.min(...rssValues) |
| 367 | + const maxRss = Math.max(...rssValues) |
| 368 | + const finalRss = rssValues[rssValues.length - 1] |
| 369 | + const minVsz = Math.min(...vszValues) |
| 370 | + const maxVsz = Math.max(...vszValues) |
| 371 | + const finalVsz = vszValues[vszValues.length - 1] |
| 372 | + |
| 373 | + const mb = (kb) => (kb / 1024).toFixed(0) + 'MB' |
| 374 | + |
| 375 | + console.log('\n=== Memory Summary ===') |
| 376 | + console.log( |
| 377 | + ` RSS: min=${mb(minRss)} max=${mb(maxRss)} final=${mb(finalRss)}` |
| 378 | + ) |
| 379 | + if (footprintValues.length > 0) { |
| 380 | + const minFp = Math.min(...footprintValues) |
| 381 | + const maxFp = Math.max(...footprintValues) |
| 382 | + const finalFp = footprintValues[footprintValues.length - 1] |
| 383 | + console.log( |
| 384 | + ` Footprint: min=${mb(minFp)} max=${mb(maxFp)} final=${mb(finalFp)}` |
| 385 | + ) |
| 386 | + } |
| 387 | + console.log( |
| 388 | + ` VSZ: min=${mb(minVsz)} max=${mb(maxVsz)} final=${mb(finalVsz)}` |
| 389 | + ) |
| 390 | + console.log(` Samples: ${dataRows.length}`) |
| 391 | + console.log(`\nFull data: ${OUTFILE}`) |
| 392 | +} |
| 393 | + |
| 394 | +main().catch((err) => { |
| 395 | + console.error(err) |
| 396 | + process.exit(1) |
| 397 | +}) |
0 commit comments