mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e46e2dbb05
* feat(web): add performance benchmark dashboard * fix(web): clean up benchmark dashboard exports * fix(web): support benchmark runs without baselines * fix(perf): support targeted main baseline runs * fix(web): revalidate benchmark commit baselines * feat(perf): improve vinext trace profiles * fix(web): neutralize insignificant benchmark diffs * fix(perf): replace existing benchmark runs * fix(perf): expose vinext trace frames * feat(perf): retain complete vinext trace detail * feat(perf): filter flame graph categories * fix(web): compact flame graph rows * fix(perf): replace runs by commit identity * fix(benchmarks): preserve complete performance traces * fix(benchmarks): use commit time for performance runs * fix(benchmarks): store performance profiles in R2 * fix(benchmarks): stream performance profiles to R2 * fix(benchmarks): decode raw performance profiles * fix(benchmarks): show inclusive filtered profile time * fix(benchmarks): resize filtered flame graphs * fix(benchmarks): render narrow flame frames * perf(benchmarks): bound flame graph detail * ci(perf): split benchmark publication privileges * fix(perf): harden benchmark publication * fix(perf): initialize runner paths in job * fix(perf): install through vite plus * fix(perf): disable pnpm hooks during install * fix(perf): select trusted harness ref * fix(perf): resolve vite plus for isolated user * fix(perf): grant isolated tool access * fix(perf): share benchmark cache cleanup * fix(perf): harden trace publication * chore(perf): format migration snapshots * feat(web): restore benchmarks navigation
587 lines
18 KiB
JavaScript
587 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate the shared 33-route benchmark app.
|
|
* Shared between the Next.js and vinext benchmark projects.
|
|
*/
|
|
import { mkdirSync, writeFileSync, rmSync, readdirSync, statSync, cpSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
|
|
// Seeded PRNG (mulberry32) for deterministic source generation across runs.
|
|
function mulberry32(seed) {
|
|
let s = seed | 0;
|
|
return () => {
|
|
s = (s + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
const random = mulberry32(42);
|
|
|
|
const repositoryRoot =
|
|
process.env.VINEXT_PERF_TARGET_ROOT ?? dirname(dirname(new URL(import.meta.url).pathname));
|
|
const benchmarkRoot = join(repositoryRoot, "benchmarks");
|
|
const APP = join(benchmarkRoot, "app");
|
|
|
|
// Clean and recreate
|
|
rmSync(APP, { recursive: true, force: true });
|
|
|
|
function write(rel, content) {
|
|
const p = join(APP, rel);
|
|
mkdirSync(dirname(p), { recursive: true });
|
|
writeFileSync(p, content.trimStart() + "\n");
|
|
}
|
|
|
|
// ─── Root layout ───────────────────────────────────────────────────────────────
|
|
write(
|
|
"layout.tsx",
|
|
`
|
|
// Force all pages to be dynamically rendered (no static pre-rendering).
|
|
// Without this, Next.js detects most pages as static and pre-renders them at
|
|
// build time — work that vinext doesn't do. This benchmark is designed to
|
|
// compare build/compilation speed, not static generation, so we opt out of
|
|
// pre-rendering to keep the comparison apples-to-apples.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export const metadata = {
|
|
title: { default: "Benchmark App", template: "%s | Benchmark" },
|
|
description: "A realistic benchmark app for comparing Next.js and vinext",
|
|
};
|
|
|
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<html lang="en">
|
|
<body>
|
|
<nav style={{ padding: "1rem", borderBottom: "1px solid #eee", display: "flex", gap: "1rem" }}>
|
|
<a href="/">Home</a>
|
|
<a href="/products">Products</a>
|
|
<a href="/blog">Blog</a>
|
|
<a href="/dashboard">Dashboard</a>
|
|
<a href="/about">About</a>
|
|
<a href="/docs">Docs</a>
|
|
<a href="/settings">Settings</a>
|
|
</nav>
|
|
<main style={{ padding: "1rem" }}>{children}</main>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Home page ─────────────────────────────────────────────────────────────────
|
|
write(
|
|
"page.tsx",
|
|
`
|
|
export default function Home() {
|
|
const now = new Date().toISOString();
|
|
return (
|
|
<div>
|
|
<h1>Benchmark App</h1>
|
|
<p>Server-rendered at {now}</p>
|
|
<p>This is a realistic benchmark app with 33 routes, nested layouts, dynamic routes, and client components.</p>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Shared client components ──────────────────────────────────────────────────
|
|
write(
|
|
"_components/counter.tsx",
|
|
`
|
|
"use client";
|
|
import { useState } from "react";
|
|
|
|
export function Counter({ label = "Count" }: { label?: string }) {
|
|
const [count, setCount] = useState(0);
|
|
return (
|
|
<div style={{ padding: "0.5rem", border: "1px solid #ddd", borderRadius: "4px", display: "inline-block" }}>
|
|
<span>{label}: {count}</span>
|
|
<button onClick={() => setCount(c => c + 1)} style={{ marginLeft: "0.5rem" }}>+</button>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"_components/timer.tsx",
|
|
`
|
|
"use client";
|
|
import { useState, useEffect } from "react";
|
|
|
|
export function Timer() {
|
|
const [elapsed, setElapsed] = useState(0);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setElapsed(e => e + 1), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
return <span>Uptime: {elapsed}s</span>;
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"_components/search.tsx",
|
|
`
|
|
"use client";
|
|
import { useState } from "react";
|
|
|
|
export function Search({ placeholder = "Search..." }: { placeholder?: string }) {
|
|
const [query, setQuery] = useState("");
|
|
return (
|
|
<div style={{ marginBottom: "1rem" }}>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder={placeholder}
|
|
style={{ padding: "0.5rem", border: "1px solid #ddd", borderRadius: "4px", width: "300px" }}
|
|
/>
|
|
{query && <p style={{ fontSize: "0.8rem", color: "#666" }}>Searching for: {query}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── About ─────────────────────────────────────────────────────────────────────
|
|
write(
|
|
"about/page.tsx",
|
|
`
|
|
export const metadata = { title: "About" };
|
|
export default function AboutPage() {
|
|
return (
|
|
<div>
|
|
<h1>About</h1>
|
|
<p>This is a benchmark application for comparing Next.js and vinext (Vite) performance.</p>
|
|
<p>It includes 33 routes with nested layouts, dynamic routes, server components, client components, and metadata.</p>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Products section (dynamic + layout) ───────────────────────────────────────
|
|
write(
|
|
"products/layout.tsx",
|
|
`
|
|
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div style={{ padding: "0.5rem", background: "#f0f0f0", marginBottom: "1rem" }}>
|
|
<strong>Products</strong> — <a href="/products">All</a>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// Pre-compute product prices at generation time with seeded PRNG for reproducibility.
|
|
const productPrices = Array.from({ length: 20 }, () =>
|
|
(Math.round(random() * 10000) / 100).toFixed(2),
|
|
);
|
|
|
|
write(
|
|
"products/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
export const metadata = { title: "Products" };
|
|
|
|
const products = [
|
|
${productPrices.map((price, i) => ` { id: ${i + 1}, name: "Product ${i + 1}", price: ${price} },`).join("\n")}
|
|
];
|
|
|
|
export default function ProductsPage() {
|
|
return (
|
|
<div>
|
|
<h1>Products</h1>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: "1rem" }}>
|
|
{products.map(p => (
|
|
<Link key={p.id} href={\`/products/\${p.id}\`} style={{ padding: "1rem", border: "1px solid #ddd", textDecoration: "none", color: "inherit" }}>
|
|
<h3>{p.name}</h3>
|
|
<p>\${p.price}</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"products/[id]/page.tsx",
|
|
`
|
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params;
|
|
return { title: \`Product \${id}\` };
|
|
}
|
|
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params;
|
|
return (
|
|
<div>
|
|
<h1>Product {id}</h1>
|
|
<p>This is the detail page for product {id}. Rendered at {new Date().toISOString()}</p>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Blog section (25 posts) ───────────────────────────────────────────────────
|
|
write(
|
|
"blog/layout.tsx",
|
|
`
|
|
import { Search } from "../_components/search";
|
|
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div style={{ padding: "0.5rem", background: "#f5f5ff", marginBottom: "1rem" }}>
|
|
<strong>Blog</strong> — <a href="/blog">All Posts</a>
|
|
</div>
|
|
<Search placeholder="Search posts..." />
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"blog/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
export const metadata = { title: "Blog" };
|
|
const posts = Array.from({ length: 25 }, (_, i) => ({
|
|
slug: \`post-\${i + 1}\`,
|
|
title: \`Blog Post \${i + 1}: \${["React Patterns", "Server Components", "Caching", "Deployment", "Performance"][i % 5]}\`,
|
|
date: new Date(2025, 0, i + 1).toLocaleDateString(),
|
|
}));
|
|
export default function BlogPage() {
|
|
return (
|
|
<div>
|
|
<h1>Blog</h1>
|
|
{posts.map(post => (
|
|
<article key={post.slug} style={{ marginBottom: "1rem", paddingBottom: "1rem", borderBottom: "1px solid #eee" }}>
|
|
<Link href={\`/blog/\${post.slug}\`}><h2>{post.title}</h2></Link>
|
|
<time>{post.date}</time>
|
|
</article>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"blog/[slug]/page.tsx",
|
|
`
|
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
|
const { slug } = await params;
|
|
return { title: slug.replace(/-/g, " ") };
|
|
}
|
|
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
|
|
const { slug } = await params;
|
|
const paragraphs = Array.from({ length: 5 }, (_, i) =>
|
|
\`Paragraph \${i + 1} of "\${slug}". Lorem ipsum dolor sit amet, consectetur adipiscing elit.\`
|
|
);
|
|
return (
|
|
<div>
|
|
<h1>{slug.replace(/-/g, " ")}</h1>
|
|
<time>{new Date().toLocaleDateString()}</time>
|
|
{paragraphs.map((p, i) => <p key={i}>{p}</p>)}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Dashboard (nested layout + client components) ─────────────────────────────
|
|
write(
|
|
"dashboard/layout.tsx",
|
|
`
|
|
import { Timer } from "../_components/timer";
|
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}>
|
|
<aside style={{ padding: "1rem", borderRight: "1px solid #eee" }}>
|
|
<h3>Dashboard</h3>
|
|
<nav style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
<a href="/dashboard">Overview</a>
|
|
<a href="/dashboard/analytics">Analytics</a>
|
|
<a href="/dashboard/users">Users</a>
|
|
<a href="/dashboard/settings">Settings</a>
|
|
</nav>
|
|
<div style={{ marginTop: "1rem", fontSize: "0.8rem", color: "#666" }}><Timer /></div>
|
|
</aside>
|
|
<div style={{ padding: "1rem" }}>{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"dashboard/page.tsx",
|
|
`
|
|
import { Counter } from "../_components/counter";
|
|
export const metadata = { title: "Dashboard" };
|
|
export default function DashboardPage() {
|
|
const stats = [
|
|
{ label: "Total Users", value: "12,345" },
|
|
{ label: "Revenue", value: "$98,765" },
|
|
{ label: "Orders", value: "3,456" },
|
|
{ label: "Conversion", value: "3.2%" },
|
|
];
|
|
return (
|
|
<div>
|
|
<h1>Dashboard Overview</h1>
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem", marginBottom: "2rem" }}>
|
|
{stats.map(s => (
|
|
<div key={s.label} style={{ padding: "1rem", border: "1px solid #ddd", borderRadius: "8px" }}>
|
|
<div style={{ fontSize: "0.8rem", color: "#666" }}>{s.label}</div>
|
|
<div style={{ fontSize: "1.5rem", fontWeight: "bold" }}>{s.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Counter label="Page Views" />
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// Pre-compute analytics data at generation time with seeded PRNG for reproducibility.
|
|
const analyticsRows = Array.from({ length: 10 }, () => ({
|
|
views: Math.floor(random() * 10000),
|
|
bounce: Math.floor(random() * 100),
|
|
}));
|
|
|
|
write(
|
|
"dashboard/analytics/page.tsx",
|
|
`
|
|
export const metadata = { title: "Analytics" };
|
|
|
|
const rows = [
|
|
${analyticsRows.map((r, i) => ` { page: "/page-${i + 1}", views: ${r.views}, bounce: ${r.bounce} },`).join("\n")}
|
|
];
|
|
|
|
export default function AnalyticsPage() {
|
|
return (
|
|
<div>
|
|
<h1>Analytics</h1>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead><tr><th>Page</th><th>Views</th><th>Bounce Rate</th></tr></thead>
|
|
<tbody>{rows.map(r => (
|
|
<tr key={r.page} style={{ borderBottom: "1px solid #eee" }}>
|
|
<td style={{ padding: "0.5rem" }}>{r.page}</td>
|
|
<td style={{ padding: "0.5rem" }}>{r.views.toLocaleString()}</td>
|
|
<td style={{ padding: "0.5rem" }}>{r.bounce}%</td>
|
|
</tr>
|
|
))}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"dashboard/users/page.tsx",
|
|
`
|
|
export const metadata = { title: "Users" };
|
|
export default function UsersPage() {
|
|
const users = Array.from({ length: 15 }, (_, i) => ({
|
|
id: i + 1, name: \`User \${i + 1}\`, email: \`user\${i + 1}@example.com\`, role: i % 3 === 0 ? "Admin" : "User",
|
|
}));
|
|
return (
|
|
<div>
|
|
<h1>Users</h1>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead><tr><th>ID</th><th>Name</th><th>Email</th><th>Role</th></tr></thead>
|
|
<tbody>{users.map(u => (
|
|
<tr key={u.id} style={{ borderBottom: "1px solid #eee" }}>
|
|
<td style={{ padding: "0.5rem" }}>{u.id}</td><td style={{ padding: "0.5rem" }}>{u.name}</td>
|
|
<td style={{ padding: "0.5rem" }}>{u.email}</td><td style={{ padding: "0.5rem" }}>{u.role}</td>
|
|
</tr>
|
|
))}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"dashboard/settings/page.tsx",
|
|
`
|
|
import { Counter } from "../../_components/counter";
|
|
export const metadata = { title: "Dashboard Settings" };
|
|
export default function DashboardSettingsPage() {
|
|
return (<div><h1>Dashboard Settings</h1><p>Configure dashboard preferences.</p><Counter label="Saves" /></div>);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Docs (catch-all) ──────────────────────────────────────────────────────────
|
|
write(
|
|
"docs/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
export const metadata = { title: "Documentation" };
|
|
const sections = ["getting-started", "installation", "configuration", "api-reference", "deployment", "troubleshooting", "migration", "plugins"];
|
|
export default function DocsIndex() {
|
|
return (<div><h1>Documentation</h1><ul>{sections.map(s => <li key={s}><Link href={\`/docs/\${s}\`}>{s.replace(/-/g, " ")}</Link></li>)}</ul></div>);
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"docs/[...slug]/page.tsx",
|
|
`
|
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }) {
|
|
const { slug } = await params;
|
|
return { title: \`Docs: \${slug.join(" / ")}\` };
|
|
}
|
|
export default async function DocPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
|
const { slug } = await params;
|
|
return (<div><h1>Docs: {slug.join(" / ")}</h1>{Array.from({ length: 3 }, (_, i) => <p key={i}>Section {i + 1} content.</p>)}</div>);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Settings (nested layout) ──────────────────────────────────────────────────
|
|
write(
|
|
"settings/layout.tsx",
|
|
`
|
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div style={{ display: "flex", gap: "1rem", padding: "0.5rem", background: "#fafafa", marginBottom: "1rem" }}>
|
|
<a href="/settings">General</a>
|
|
<a href="/settings/profile">Profile</a>
|
|
<a href="/settings/notifications">Notifications</a>
|
|
<a href="/settings/billing">Billing</a>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
for (const page of ["", "profile", "notifications", "billing"]) {
|
|
const name = page || "General";
|
|
const dir = page ? `settings/${page}` : "settings";
|
|
write(
|
|
`${dir}/page.tsx`,
|
|
`
|
|
export const metadata = { title: "Settings - ${name}" };
|
|
export default function Settings${name.charAt(0).toUpperCase() + name.slice(1)}Page() {
|
|
return (<div><h1>Settings: ${name}</h1><p>Configure ${name.toLowerCase()} settings.</p></div>);
|
|
}
|
|
`,
|
|
);
|
|
}
|
|
|
|
// ─── API routes ────────────────────────────────────────────────────────────────
|
|
write(
|
|
"api/health/route.ts",
|
|
`
|
|
export function GET() {
|
|
return Response.json({ status: "ok", timestamp: Date.now() });
|
|
}
|
|
`,
|
|
);
|
|
|
|
write(
|
|
"api/data/route.ts",
|
|
`
|
|
export function GET() {
|
|
const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, value: Math.random(), label: \`Item \${i + 1}\` }));
|
|
return Response.json(data);
|
|
}
|
|
`,
|
|
);
|
|
|
|
// ─── Additional static pages (volume) ──────────────────────────────────────────
|
|
const staticPages = [
|
|
"features",
|
|
"pricing",
|
|
"team",
|
|
"careers",
|
|
"contact",
|
|
"faq",
|
|
"terms",
|
|
"privacy",
|
|
"changelog",
|
|
"roadmap",
|
|
"support",
|
|
"community",
|
|
"partners",
|
|
"press",
|
|
"security",
|
|
];
|
|
|
|
for (const page of staticPages) {
|
|
const title = page.charAt(0).toUpperCase() + page.slice(1);
|
|
write(
|
|
`${page}/page.tsx`,
|
|
`
|
|
export const metadata = { title: "${title}" };
|
|
export default function ${title}Page() {
|
|
return (
|
|
<div>
|
|
<h1>${title}</h1>
|
|
<p>This is the ${page} page with information about ${page}.</p>
|
|
${
|
|
page === "faq"
|
|
? `
|
|
<div>
|
|
{Array.from({ length: 10 }, (_, i) => (
|
|
<details key={i} style={{ marginBottom: "0.5rem" }}>
|
|
<summary>Question {i + 1}?</summary>
|
|
<p>Answer to question {i + 1}.</p>
|
|
</details>
|
|
))}
|
|
</div>`
|
|
: `
|
|
<p>More content for the ${page} section would go here.</p>`
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
}
|
|
|
|
// Count results
|
|
function countFiles(dir, name) {
|
|
let count = 0;
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) count += countFiles(full, name);
|
|
else if (entry === name) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
const pages = countFiles(APP, "page.tsx");
|
|
const routes = countFiles(APP, "route.ts");
|
|
console.log(
|
|
`Generated benchmark app: ${pages} pages + ${routes} API routes = ${pages + routes} total routes`,
|
|
);
|
|
|
|
// Copy to each benchmark project (symlinks don't work with Turbopack)
|
|
const BASE = benchmarkRoot;
|
|
for (const project of ["nextjs", "vinext"]) {
|
|
const dest = join(BASE, project, "app");
|
|
rmSync(dest, { recursive: true, force: true });
|
|
cpSync(APP, dest, { recursive: true });
|
|
console.log(` Copied to ${project}/app`);
|
|
}
|