cloudflare/vinext

Public

mirrored from https://github.com/cloudflare/vinextAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.0.9

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

benchmarks/run.mjs

616lines · modecode

1#!/usr/bin/env node
2/**
3 * Benchmark harness: compares Next.js 16 (Turbopack) vs vinext (Vite 7/Rollup) vs vinext (Vite 8/Rolldown)
4 *
5 * Metrics:
6 * 1. Production build time (hyperfine)
7 * 2. Production bundle size (total JS+CSS, gzipped)
8 * 3. Dev server cold start (time to first successful HTTP 200)
9 * 4. SSR throughput & TTFB (autocannon against production server)
10 * 5. Memory usage (peak RSS during build and dev)
11 *
12 * Prerequisites: hyperfine, autocannon (npm i -g autocannon)
13 * Usage: node benchmarks/run.mjs [--runs N] [--dev-runs N] [--skip-build] [--skip-dev] [--skip-ssr] [--skip-rolldown]
14 */
15
16import { execSync, spawn } from "node:child_process";
17import { existsSync, readdirSync, statSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
18import { join, dirname } from "node:path";
19import { fileURLToPath } from "node:url";
20import { gzipSync } from "node:zlib";
21
22const __dirname = dirname(fileURLToPath(import.meta.url));
23const RESULTS_DIR = join(__dirname, "results");
24mkdirSync(RESULTS_DIR, { recursive: true });
25
26// Disable Next.js telemetry to avoid non-deterministic network latency in benchmarks.
27process.env.NEXT_TELEMETRY_DISABLED = "1";
28
29// ─── CLI args ──────────────────────────────────────────────────────────────────
30const args = process.argv.slice(2);
31const RUNS = parseInt(args.find((a) => a.startsWith("--runs="))?.split("=")[1] ?? "5", 10);
32const DEV_RUNS = parseInt(args.find((a) => a.startsWith("--dev-runs="))?.split("=")[1] ?? "10", 10);
33const SKIP_BUILD = args.includes("--skip-build");
34const SKIP_DEV = args.includes("--skip-dev");
35const SKIP_SSR = args.includes("--skip-ssr");
36const SKIP_ROLLDOWN = args.includes("--skip-rolldown");
37
38// ─── Helpers ───────────────────────────────────────────────────────────────────
39function exec(cmd, opts = {}) {
40 console.log(` $ ${cmd}`);
41 return execSync(cmd, { encoding: "utf-8", stdio: "pipe", ...opts });
42}
43
44function getGitHash() {
45 try {
46 return execSync("git rev-parse --short HEAD", { encoding: "utf-8", cwd: join(__dirname, "..") }).trim();
47 } catch {
48 return "unknown";
49 }
50}
51
52/**
53 * Calculate total size of JS and CSS files in a directory (recursively).
54 * Returns { raw: bytes, gzip: bytes, files: number }
55 */
56function bundleSize(dir) {
57 let raw = 0;
58 let gzip = 0;
59 let files = 0;
60
61 function walk(d) {
62 if (!existsSync(d)) return;
63 for (const entry of readdirSync(d)) {
64 const full = join(d, entry);
65 const stat = statSync(full);
66 if (stat.isDirectory()) {
67 walk(full);
68 } else if (/\.(js|css|mjs)$/.test(entry)) {
69 const content = readFileSync(full);
70 raw += content.length;
71 gzip += gzipSync(content).length;
72 files++;
73 }
74 }
75 }
76
77 walk(dir);
78 return { raw, gzip, files };
79}
80
81/**
82 * Wait for a URL to return HTTP 200. Returns time in ms.
83 */
84async function waitForServer(url, timeoutMs = 60000) {
85 const start = performance.now();
86 const deadline = start + timeoutMs;
87
88 while (performance.now() < deadline) {
89 try {
90 const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
91 if (res.ok) return performance.now() - start;
92 } catch {
93 // not ready yet
94 }
95 await new Promise((r) => setTimeout(r, 200));
96 }
97 throw new Error(`Server at ${url} did not respond within ${timeoutMs}ms`);
98}
99
100/**
101 * Sum RSS (in KB) for an entire process group.
102 * The process was spawned with detached:true so proc.pid is the PGID.
103 * On Linux, `pgrep -g` enumerates PIDs in the process group reliably.
104 * On macOS, `pgrep -g` matches by GID (not PGID), so instead we list all
105 * processes with `ps -axo pid=,pgid=,rss=` and sum RSS for rows whose PGID
106 * matches the leader PID.
107 */
108function getGroupRssKb(pid) {
109 try {
110 let out;
111 if (process.platform === "linux") {
112 const pidsRaw = execSync(`pgrep -g ${pid}`, { encoding: "utf-8" }).trim();
113 if (!pidsRaw) return 0;
114 const pidList = pidsRaw.split("\n").join(",");
115 out = execSync(`ps -o rss= -p ${pidList}`, { encoding: "utf-8" });
116 return out
117 .trim()
118 .split("\n")
119 .reduce((sum, line) => sum + (parseInt(line.trim(), 10) || 0), 0);
120 } else {
121 // macOS: enumerate all processes and sum RSS for those in our PGID
122 out = execSync(`ps -axo pid=,pgid=,rss=`, { encoding: "utf-8" });
123 const pgid = String(pid);
124 return out
125 .trim()
126 .split("\n")
127 .reduce((sum, line) => {
128 const parts = line.trim().split(/\s+/);
129 if (parts.length >= 3 && parts[1] === pgid) {
130 return sum + (parseInt(parts[2], 10) || 0);
131 }
132 return sum;
133 }, 0);
134 }
135 } catch {
136 return 0;
137 }
138}
139
140/**
141 * Start a process and measure cold start time + peak RSS.
142 * Returns { coldStartMs, peakRssKb, process }
143 */
144async function startAndMeasure(cmd, args, cwd, url) {
145 let peakRssKb = 0;
146
147 const proc = spawn(cmd, args, {
148 cwd,
149 stdio: ["pipe", "pipe", "pipe"],
150 detached: true, // Own process group so we can kill the tree safely
151 env: { ...process.env, PORT: new URL(url).port },
152 });
153
154 // Collect stderr/stdout for debugging
155 let output = "";
156 proc.stdout?.on("data", (d) => (output += d.toString()));
157 proc.stderr?.on("data", (d) => (output += d.toString()));
158
159 // Poll RSS every 200ms (sum across process group on Linux)
160 const rssInterval = setInterval(() => {
161 const rss = getGroupRssKb(proc.pid);
162 if (rss > peakRssKb) peakRssKb = rss;
163 }, 200);
164
165 try {
166 const coldStartMs = await waitForServer(url, 60000);
167 clearInterval(rssInterval);
168
169 // Final RSS
170 const rss = getGroupRssKb(proc.pid);
171 if (rss > peakRssKb) peakRssKb = rss;
172
173 return { coldStartMs, peakRssKb, process: proc, output };
174 } catch (err) {
175 clearInterval(rssInterval);
176 kill(proc);
177 console.error("Server output:", output);
178 throw err;
179 }
180}
181
182function kill(proc) {
183 if (!proc || proc.killed) return;
184 // The process was spawned with detached:true so it has its own process group.
185 // Kill the entire group with -PID (negative) which is safe because it won't
186 // include our own script.
187 try {
188 process.kill(-proc.pid, "SIGKILL");
189 } catch {}
190}
191
192// Fisher-Yates shuffle — used to randomize runner order and eliminate positional bias.
193function shuffle(arr) {
194 const a = [...arr];
195 for (let i = a.length - 1; i > 0; i--) {
196 const j = Math.floor(Math.random() * (i + 1));
197 [a[i], a[j]] = [a[j], a[i]];
198 }
199 return a;
200}
201
202function formatBytes(b) {
203 if (b < 1024) return `${b} B`;
204 if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
205 return `${(b / (1024 * 1024)).toFixed(2)} MB`;
206}
207
208function formatMs(ms) {
209 if (ms < 1000) return `${Math.round(ms)} ms`;
210 return `${(ms / 1000).toFixed(2)} s`;
211}
212
213// ─── Main ──────────────────────────────────────────────────────────────────────
214async function main() {
215 const results = {
216 timestamp: new Date().toISOString(),
217 gitHash: getGitHash(),
218 buildRuns: RUNS,
219 devRuns: DEV_RUNS,
220 system: {
221 platform: process.platform,
222 arch: process.arch,
223 nodeVersion: process.version,
224 cpus: (await import("node:os")).cpus().length,
225 },
226 nextjs: {},
227 vinext: {},
228 vinextRolldown: {},
229 };
230
231 const nextjsDir = join(__dirname, "nextjs");
232 const vinextDir = join(__dirname, "vinext");
233 const vinextRolldownDir = join(__dirname, "vinext-rolldown");
234 const hasRolldown = !SKIP_ROLLDOWN && existsSync(join(vinextRolldownDir, "package.json"));
235
236 // Detect actual installed versions for reproducibility
237 try {
238 const njsPkg = JSON.parse(readFileSync(join(nextjsDir, "node_modules", "next", "package.json"), "utf-8"));
239 results.system.nextjsVersion = njsPkg.version;
240 } catch { /* deps not installed yet */ }
241 try {
242 const vitePkg = JSON.parse(readFileSync(join(vinextDir, "node_modules", "vite", "package.json"), "utf-8"));
243 results.system.viteVersion = vitePkg.version;
244 } catch { /* deps not installed yet */ }
245 if (hasRolldown) {
246 try {
247 const rdPkg = JSON.parse(readFileSync(join(vinextRolldownDir, "node_modules", "vite", "package.json"), "utf-8"));
248 results.system.viteRolldownVersion = rdPkg.version;
249 } catch { /* deps not installed yet */ }
250 }
251
252 // ─── 1. Production Build Time ──────────────────────────────────────────────
253 if (!SKIP_BUILD) {
254 console.log("\n=== Production Build Time ===\n");
255
256 // Clean previous builds
257 exec("rm -rf .next", { cwd: nextjsDir });
258 exec("rm -rf dist", { cwd: vinextDir });
259 if (hasRolldown) exec("rm -rf dist", { cwd: vinextRolldownDir });
260
261 // Ensure plugin is built
262 console.log(" Building vinext plugin...");
263 exec("./node_modules/.bin/tsc -p packages/vinext/tsconfig.json", { cwd: join(__dirname, "..") });
264
265 // Warmup run for all (not measured)
266 console.log(" Warmup: Next.js build...");
267 exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 });
268 exec("rm -rf .next", { cwd: nextjsDir });
269
270 console.log(" Warmup: vinext (Rollup) build...");
271 exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 });
272 exec("rm -rf dist", { cwd: vinextDir });
273
274 if (hasRolldown) {
275 console.log(" Warmup: vinext (Rolldown) build...");
276 exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 });
277 exec("rm -rf dist", { cwd: vinextRolldownDir });
278 }
279
280 // Measured runs with hyperfine (single invocation with --shuffle for fair ordering)
281 console.log(`\n Running ${RUNS} build iterations with hyperfine (randomized order)...\n`);
282
283 function parseHyperfineResult(r) {
284 return {
285 mean: r.mean * 1000,
286 stddev: r.stddev * 1000,
287 min: r.min * 1000,
288 max: r.max * 1000,
289 };
290 }
291
292 try {
293 // Build a single hyperfine command with all projects and --shuffle so
294 // runs are interleaved randomly, eliminating positional bias from
295 // warmed filesystem caches, CPU thermal state, or residual process state.
296 const cmds = [
297 `--command-name nextjs 'rm -rf ${nextjsDir}/.next && ./node_modules/.bin/next build --turbopack'`,
298 `--command-name vinext 'rm -rf ${vinextDir}/dist && ./node_modules/.bin/vite build --root ${vinextDir}'`,
299 ];
300 if (hasRolldown) {
301 cmds.push(
302 `--command-name rolldown 'rm -rf ${vinextRolldownDir}/dist && ./node_modules/.bin/vite build --root ${vinextRolldownDir}'`
303 );
304 }
305
306 console.log(" Timing all builds (shuffled)...");
307 const hfJson = exec(
308 `hyperfine --runs ${RUNS} --shuffle ${cmds.join(" ")} --export-json /dev/stdout 2>/dev/null`,
309 { cwd: __dirname, timeout: 600000 }
310 );
311 const hf = JSON.parse(hfJson);
312
313 // Map results by command name
314 for (const r of hf.results) {
315 if (r.command.includes("next build")) {
316 results.nextjs.buildTime = parseHyperfineResult(r);
317 } else if (r.command.includes(vinextDir)) {
318 results.vinext.buildTime = parseHyperfineResult(r);
319 } else if (hasRolldown && r.command.includes(vinextRolldownDir)) {
320 results.vinextRolldown.buildTime = parseHyperfineResult(r);
321 }
322 }
323 results.buildMethodology = "hyperfine --shuffle (randomized)";
324 } catch {
325 // Fallback: manual timing with randomized runner order to eliminate positional bias
326 console.log(" hyperfine failed, falling back to manual timing...");
327 const buildTimes = { nextjs: [], vinext: [], rolldown: [] };
328
329 const buildRunners = [
330 {
331 key: "nextjs",
332 run: () => {
333 exec("rm -rf .next", { cwd: nextjsDir });
334 const start = performance.now();
335 exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 });
336 buildTimes.nextjs.push(performance.now() - start);
337 },
338 },
339 {
340 key: "vinext",
341 run: () => {
342 exec("rm -rf dist", { cwd: vinextDir });
343 const start = performance.now();
344 exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 });
345 buildTimes.vinext.push(performance.now() - start);
346 },
347 },
348 ...(hasRolldown
349 ? [{
350 key: "rolldown",
351 run: () => {
352 exec("rm -rf dist", { cwd: vinextRolldownDir });
353 const start = performance.now();
354 exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 });
355 buildTimes.rolldown.push(performance.now() - start);
356 },
357 }]
358 : []),
359 ];
360
361 const buildRunOrders = [];
362 for (let i = 0; i < RUNS; i++) {
363 const order = shuffle(buildRunners);
364 buildRunOrders.push(order.map((r) => r.key));
365 console.log(` Run ${i + 1}/${RUNS} (order: ${order.map((r) => r.key).join(" → ")})...`);
366 for (const runner of order) runner.run();
367 }
368
369 const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
370 const stddev = (arr) => {
371 if (arr.length < 2) return 0;
372 const m = avg(arr);
373 return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1));
374 };
375
376 results.nextjs.buildTime = {
377 mean: avg(buildTimes.nextjs),
378 stddev: stddev(buildTimes.nextjs),
379 min: Math.min(...buildTimes.nextjs),
380 max: Math.max(...buildTimes.nextjs),
381 };
382 results.vinext.buildTime = {
383 mean: avg(buildTimes.vinext),
384 stddev: stddev(buildTimes.vinext),
385 min: Math.min(...buildTimes.vinext),
386 max: Math.max(...buildTimes.vinext),
387 };
388 if (hasRolldown && buildTimes.rolldown.length) {
389 results.vinextRolldown.buildTime = {
390 mean: avg(buildTimes.rolldown),
391 stddev: stddev(buildTimes.rolldown),
392 min: Math.min(...buildTimes.rolldown),
393 max: Math.max(...buildTimes.rolldown),
394 };
395 }
396 results.buildMethodology = "manual timing (randomized)";
397 results.buildRunOrders = buildRunOrders;
398 }
399
400 // ─── 2. Bundle Size ────────────────────────────────────────────────────────
401 // Measures client-side JS/CSS/MJS files only. Both directories (.next/static
402 // and dist/client) contain only browser assets, not server bundles.
403 // TODO: If the benchmark app ever uses CSS Modules or global CSS imports,
404 // verify that both frameworks extract CSS to the measured directories.
405 // Currently the app uses inline styles so CSS extraction is not a factor.
406 console.log("\n=== Production Bundle Size ===\n");
407
408 // Rebuild both to get fresh output
409 exec("rm -rf .next", { cwd: nextjsDir });
410 exec("./node_modules/.bin/next build --turbopack", { cwd: nextjsDir, timeout: 120000 });
411
412 exec("rm -rf dist", { cwd: vinextDir });
413 exec("./node_modules/.bin/vite build", { cwd: vinextDir, timeout: 120000 });
414
415 // Next.js: client bundles are in .next/static
416 const njsSize = bundleSize(join(nextjsDir, ".next", "static"));
417 results.nextjs.bundleSize = njsSize;
418 console.log(` Next.js: ${njsSize.files} files, ${formatBytes(njsSize.raw)} raw, ${formatBytes(njsSize.gzip)} gzip`);
419
420 // vinext (Rollup): client bundles are in dist/client
421 const ncSize = bundleSize(join(vinextDir, "dist", "client"));
422 results.vinext.bundleSize = ncSize;
423 console.log(` vinext (Rollup): ${ncSize.files} files, ${formatBytes(ncSize.raw)} raw, ${formatBytes(ncSize.gzip)} gzip`);
424
425 // vinext (Rolldown): client bundles are in dist/client
426 if (hasRolldown) {
427 exec("rm -rf dist", { cwd: vinextRolldownDir });
428 exec("./node_modules/.bin/vite build", { cwd: vinextRolldownDir, timeout: 120000 });
429 const rdSize = bundleSize(join(vinextRolldownDir, "dist", "client"));
430 results.vinextRolldown.bundleSize = rdSize;
431 console.log(` vinext (Rolldown): ${rdSize.files} files, ${formatBytes(rdSize.raw)} raw, ${formatBytes(rdSize.gzip)} gzip`);
432 }
433 }
434
435 // ─── 3. Dev Server Cold Start ──────────────────────────────────────────────
436 if (!SKIP_DEV) {
437 console.log("\n=== Dev Server Cold Start ===\n");
438
439 const devResults = { nextjs: [], vinext: [], rolldown: [] };
440
441 // Define the runners; order is randomized each iteration to eliminate
442 // positional bias (first-after-kill penalties, OS cache warming, etc.)
443 const runners = [
444 {
445 key: "nextjs",
446 label: "Next.js",
447 run: async () => {
448 exec("rm -rf .next", { cwd: nextjsDir });
449 return startAndMeasure("./node_modules/.bin/next", ["dev", "--turbopack", "-p", "4100"], nextjsDir, "http://localhost:4100");
450 },
451 },
452 {
453 key: "vinext",
454 label: "vinext (Rollup)",
455 run: async () => {
456 exec("rm -rf node_modules/.vite", { cwd: vinextDir });
457 return startAndMeasure("./node_modules/.bin/vite", ["--port", "4101"], vinextDir, "http://localhost:4101");
458 },
459 },
460 ...(hasRolldown
461 ? [
462 {
463 key: "rolldown",
464 label: "vinext (Rolldown)",
465 run: async () => {
466 exec("rm -rf node_modules/.vite", { cwd: vinextRolldownDir });
467 return startAndMeasure("./node_modules/.bin/vite", ["--port", "4102"], vinextRolldownDir, "http://localhost:4102");
468 },
469 },
470 ]
471 : []),
472 ];
473
474 const runOrders = [];
475
476 for (let i = 0; i < DEV_RUNS; i++) {
477 const order = shuffle(runners);
478 const orderLabels = order.map((r) => r.label);
479 runOrders.push(orderLabels);
480 console.log(` Run ${i + 1}/${DEV_RUNS} (order: ${orderLabels.join(" → ")})...`);
481
482 for (const runner of order) {
483 console.log(` Starting ${runner.label} dev server...`);
484 const result = await runner.run();
485 devResults[runner.key].push({ coldStartMs: result.coldStartMs, peakRssKb: result.peakRssKb });
486 console.log(` ${runner.label}: ${formatMs(result.coldStartMs)}, ${Math.round(result.peakRssKb / 1024)} MB RSS`);
487 kill(result.process);
488 await new Promise((r) => setTimeout(r, 2000)); // cooldown
489 }
490 }
491
492 const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
493
494 results.nextjs.devColdStart = {
495 meanMs: avg(devResults.nextjs.map((r) => r.coldStartMs)),
496 meanRssKb: avg(devResults.nextjs.map((r) => r.peakRssKb)),
497 runs: devResults.nextjs,
498 };
499 results.vinext.devColdStart = {
500 meanMs: avg(devResults.vinext.map((r) => r.coldStartMs)),
501 meanRssKb: avg(devResults.vinext.map((r) => r.peakRssKb)),
502 runs: devResults.vinext,
503 };
504 if (hasRolldown && devResults.rolldown.length) {
505 results.vinextRolldown.devColdStart = {
506 meanMs: avg(devResults.rolldown.map((r) => r.coldStartMs)),
507 meanRssKb: avg(devResults.rolldown.map((r) => r.peakRssKb)),
508 runs: devResults.rolldown,
509 };
510 }
511 results.devRunOrders = runOrders;
512 }
513
514 // ─── 4. SSR Throughput & TTFB ──────────────────────────────────────────────
515 // TODO: Requires wiring up vinext production server (startProdServer)
516 // to serve dynamic SSR pages, not just static preview. Skipped for now.
517 if (!SKIP_SSR) {
518 console.log("\n=== SSR Throughput & TTFB ===\n");
519 console.log(" Skipped — vinext production server not yet wired for benchmark app.");
520 console.log(" Next.js `next start` does SSR; vinext `vite preview` only serves static.");
521 console.log(" This will be added once the prod server integration is complete.\n");
522 }
523
524 // ─── Output Results ────────────────────────────────────────────────────────
525 console.log("\n=== Results ===\n");
526
527 // Save JSON
528 const jsonFile = join(RESULTS_DIR, `bench-${results.gitHash}-${Date.now()}.json`);
529 writeFileSync(jsonFile, JSON.stringify(results, null, 2));
530 console.log(` JSON: ${jsonFile}\n`);
531
532 // Generate markdown summary
533 let md = `# Benchmark Results\n\n`;
534 md += `- **Date**: ${results.timestamp}\n`;
535 md += `- **Git**: ${results.gitHash}\n`;
536 md += `- **Node**: ${results.system.nodeVersion}\n`;
537 md += `- **CPUs**: ${results.system.cpus}\n`;
538 md += `- **Build runs**: ${results.buildRuns}\n`;
539 md += `- **Dev cold start runs**: ${results.devRuns}\n`;
540 if (results.system.nextjsVersion) md += `- **Next.js**: ${results.system.nextjsVersion}\n`;
541 if (results.system.viteVersion) md += `- **Vite (Rollup)**: ${results.system.viteVersion}\n`;
542 if (results.system.viteRolldownVersion) md += `- **Vite (Rolldown)**: ${results.system.viteRolldownVersion}\n`;
543 md += "\n";
544 md += `> **Note:** TypeScript type checking is disabled for the Next.js build (\`typescript.ignoreBuildErrors: true\`) so that build timings measure bundler/compilation speed only. Vite does not type-check during build.\n\n`;
545 md += `> **Methodology:** Build and dev cold start runs are executed in randomized order to eliminate positional bias from filesystem caches, CPU thermal state, and residual process state.\n\n`;
546
547 const hasRolldownResults = results.vinextRolldown && Object.keys(results.vinextRolldown).length > 0;
548
549 // Format a speed ratio comparison: "2.1x faster" or "1.3x slower"
550 function fmtSpeedup(baseline, value) {
551 if (!baseline || !value) return "N/A";
552 const ratio = baseline / value;
553 if (ratio > 1) return `**${ratio.toFixed(1)}x faster**`;
554 if (ratio < 1) return `**${(1 / ratio).toFixed(1)}x slower**`;
555 return "same";
556 }
557 // Format a size reduction: "56% smaller" or "12% larger"
558 function fmtSizeReduction(baseline, value) {
559 if (!baseline || !value) return "N/A";
560 const pct = Math.round((1 - value / baseline) * 100);
561 if (pct > 0) return `**${pct}% smaller**`;
562 if (pct < 0) return `**${Math.abs(pct)}% larger**`;
563 return "same";
564 }
565
566 if (results.nextjs.buildTime && results.vinext.buildTime) {
567 md += `## Production Build Time\n\n`;
568 md += `| Framework | Mean | StdDev | Min | Max | vs Next.js |\n`;
569 md += `|-----------|------|--------|-----|-----|------------|\n`;
570 md += `| Next.js 16 (Turbopack) | ${formatMs(results.nextjs.buildTime.mean)} | ±${formatMs(results.nextjs.buildTime.stddev)} | ${formatMs(results.nextjs.buildTime.min)} | ${formatMs(results.nextjs.buildTime.max)} | baseline |\n`;
571 md += `| vinext (Vite 7 / Rollup) | ${formatMs(results.vinext.buildTime.mean)} | ±${formatMs(results.vinext.buildTime.stddev)} | ${formatMs(results.vinext.buildTime.min)} | ${formatMs(results.vinext.buildTime.max)} | ${fmtSpeedup(results.nextjs.buildTime.mean, results.vinext.buildTime.mean)} |\n`;
572
573 if (hasRolldownResults && results.vinextRolldown.buildTime) {
574 md += `| vinext (Vite 8 / Rolldown) | ${formatMs(results.vinextRolldown.buildTime.mean)} | ±${formatMs(results.vinextRolldown.buildTime.stddev)} | ${formatMs(results.vinextRolldown.buildTime.min)} | ${formatMs(results.vinextRolldown.buildTime.max)} | ${fmtSpeedup(results.nextjs.buildTime.mean, results.vinextRolldown.buildTime.mean)} |\n`;
575 }
576 md += "\n";
577 }
578
579 if (results.nextjs.bundleSize && results.vinext.bundleSize) {
580 md += `## Production Bundle Size (Client)\n\n`;
581 md += `| Framework | Files | Raw | Gzipped | vs Next.js (gzip) |\n`;
582 md += `|-----------|-------|-----|----------|--------------------|\n`;
583 md += `| Next.js 16 | ${results.nextjs.bundleSize.files} | ${formatBytes(results.nextjs.bundleSize.raw)} | ${formatBytes(results.nextjs.bundleSize.gzip)} | baseline |\n`;
584 md += `| vinext (Rollup) | ${results.vinext.bundleSize.files} | ${formatBytes(results.vinext.bundleSize.raw)} | ${formatBytes(results.vinext.bundleSize.gzip)} | ${fmtSizeReduction(results.nextjs.bundleSize.gzip, results.vinext.bundleSize.gzip)} |\n`;
585
586 if (hasRolldownResults && results.vinextRolldown.bundleSize) {
587 md += `| vinext (Rolldown) | ${results.vinextRolldown.bundleSize.files} | ${formatBytes(results.vinextRolldown.bundleSize.raw)} | ${formatBytes(results.vinextRolldown.bundleSize.gzip)} | ${fmtSizeReduction(results.nextjs.bundleSize.gzip, results.vinextRolldown.bundleSize.gzip)} |\n`;
588 }
589 md += "\n";
590 }
591
592 if (results.nextjs.devColdStart && results.vinext.devColdStart) {
593 md += `## Dev Server Cold Start\n\n`;
594 md += `| Framework | Mean Cold Start | Mean Peak RSS | vs Next.js |\n`;
595 md += `|-----------|----------------|----------------|------------|\n`;
596 md += `| Next.js 16 (Turbopack) | ${formatMs(results.nextjs.devColdStart.meanMs)} | ${Math.round(results.nextjs.devColdStart.meanRssKb / 1024)} MB | baseline |\n`;
597 md += `| vinext (Vite 7 / Rollup) | ${formatMs(results.vinext.devColdStart.meanMs)} | ${Math.round(results.vinext.devColdStart.meanRssKb / 1024)} MB | ${fmtSpeedup(results.nextjs.devColdStart.meanMs, results.vinext.devColdStart.meanMs)} |\n`;
598
599 if (hasRolldownResults && results.vinextRolldown.devColdStart) {
600 md += `| vinext (Vite 8 / Rolldown) | ${formatMs(results.vinextRolldown.devColdStart.meanMs)} | ${Math.round(results.vinextRolldown.devColdStart.meanRssKb / 1024)} MB | ${fmtSpeedup(results.nextjs.devColdStart.meanMs, results.vinextRolldown.devColdStart.meanMs)} |\n`;
601 }
602 md += "\n";
603 }
604
605 // SSR throughput section will be added once prod server is wired up
606
607 const mdFile = join(RESULTS_DIR, `bench-${results.gitHash}-${Date.now()}.md`);
608 writeFileSync(mdFile, md);
609 console.log(` Markdown: ${mdFile}\n`);
610 console.log(md);
611}
612
613main().catch((err) => {
614 console.error("Benchmark failed:", err);
615 process.exit(1);
616});
617