cloudflare/vinext
Publicmirrored from https://github.com/cloudflare/vinextAvailable
benchmarks/generate-app.mjs
493lines · modecode
| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * Generate the shared 33-route benchmark app. |
| 4 | * Shared between the Next.js and vinext benchmark projects. |
| 5 | */ |
| 6 | import { mkdirSync, writeFileSync, rmSync } from "node:fs"; |
| 7 | import { join, dirname } from "node:path"; |
| 8 | |
| 9 | // Seeded PRNG (mulberry32) for deterministic source generation across runs. |
| 10 | function mulberry32(seed) { |
| 11 | let s = seed | 0; |
| 12 | return () => { |
| 13 | s = (s + 0x6d2b79f5) | 0; |
| 14 | let t = Math.imul(s ^ (s >>> 15), 1 | s); |
| 15 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
| 16 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 17 | }; |
| 18 | } |
| 19 | const random = mulberry32(42); |
| 20 | |
| 21 | const APP = join(dirname(new URL(import.meta.url).pathname), "app"); |
| 22 | |
| 23 | // Clean and recreate |
| 24 | rmSync(APP, { recursive: true, force: true }); |
| 25 | |
| 26 | function write(rel, content) { |
| 27 | const p = join(APP, rel); |
| 28 | mkdirSync(dirname(p), { recursive: true }); |
| 29 | writeFileSync(p, content.trimStart() + "\n"); |
| 30 | } |
| 31 | |
| 32 | // ─── Root layout ─────────────────────────────────────────────────────────────── |
| 33 | write("layout.tsx", ` |
| 34 | // Force all pages to be dynamically rendered (no static pre-rendering). |
| 35 | // Without this, Next.js detects most pages as static and pre-renders them at |
| 36 | // build time — work that vinext doesn't do. This benchmark is designed to |
| 37 | // compare build/compilation speed, not static generation, so we opt out of |
| 38 | // pre-rendering to keep the comparison apples-to-apples. |
| 39 | export const dynamic = "force-dynamic"; |
| 40 | |
| 41 | export const metadata = { |
| 42 | title: { default: "Benchmark App", template: "%s | Benchmark" }, |
| 43 | description: "A realistic benchmark app for comparing Next.js and vinext", |
| 44 | }; |
| 45 | |
| 46 | export default function RootLayout({ children }: { children: React.ReactNode }) { |
| 47 | return ( |
| 48 | <html lang="en"> |
| 49 | <body> |
| 50 | <nav style={{ padding: "1rem", borderBottom: "1px solid #eee", display: "flex", gap: "1rem" }}> |
| 51 | <a href="/">Home</a> |
| 52 | <a href="/products">Products</a> |
| 53 | <a href="/blog">Blog</a> |
| 54 | <a href="/dashboard">Dashboard</a> |
| 55 | <a href="/about">About</a> |
| 56 | <a href="/docs">Docs</a> |
| 57 | <a href="/settings">Settings</a> |
| 58 | </nav> |
| 59 | <main style={{ padding: "1rem" }}>{children}</main> |
| 60 | </body> |
| 61 | </html> |
| 62 | ); |
| 63 | } |
| 64 | `); |
| 65 | |
| 66 | // ─── Home page ───────────────────────────────────────────────────────────────── |
| 67 | write("page.tsx", ` |
| 68 | export default function Home() { |
| 69 | const now = new Date().toISOString(); |
| 70 | return ( |
| 71 | <div> |
| 72 | <h1>Benchmark App</h1> |
| 73 | <p>Server-rendered at {now}</p> |
| 74 | <p>This is a realistic benchmark app with 33 routes, nested layouts, dynamic routes, and client components.</p> |
| 75 | </div> |
| 76 | ); |
| 77 | } |
| 78 | `); |
| 79 | |
| 80 | // ─── Shared client components ────────────────────────────────────────────────── |
| 81 | write("_components/counter.tsx", ` |
| 82 | "use client"; |
| 83 | import { useState } from "react"; |
| 84 | |
| 85 | export function Counter({ label = "Count" }: { label?: string }) { |
| 86 | const [count, setCount] = useState(0); |
| 87 | return ( |
| 88 | <div style={{ padding: "0.5rem", border: "1px solid #ddd", borderRadius: "4px", display: "inline-block" }}> |
| 89 | <span>{label}: {count}</span> |
| 90 | <button onClick={() => setCount(c => c + 1)} style={{ marginLeft: "0.5rem" }}>+</button> |
| 91 | </div> |
| 92 | ); |
| 93 | } |
| 94 | `); |
| 95 | |
| 96 | write("_components/timer.tsx", ` |
| 97 | "use client"; |
| 98 | import { useState, useEffect } from "react"; |
| 99 | |
| 100 | export function Timer() { |
| 101 | const [elapsed, setElapsed] = useState(0); |
| 102 | useEffect(() => { |
| 103 | const id = setInterval(() => setElapsed(e => e + 1), 1000); |
| 104 | return () => clearInterval(id); |
| 105 | }, []); |
| 106 | return <span>Uptime: {elapsed}s</span>; |
| 107 | } |
| 108 | `); |
| 109 | |
| 110 | write("_components/search.tsx", ` |
| 111 | "use client"; |
| 112 | import { useState } from "react"; |
| 113 | |
| 114 | export function Search({ placeholder = "Search..." }: { placeholder?: string }) { |
| 115 | const [query, setQuery] = useState(""); |
| 116 | return ( |
| 117 | <div style={{ marginBottom: "1rem" }}> |
| 118 | <input |
| 119 | type="text" |
| 120 | value={query} |
| 121 | onChange={e => setQuery(e.target.value)} |
| 122 | placeholder={placeholder} |
| 123 | style={{ padding: "0.5rem", border: "1px solid #ddd", borderRadius: "4px", width: "300px" }} |
| 124 | /> |
| 125 | {query && <p style={{ fontSize: "0.8rem", color: "#666" }}>Searching for: {query}</p>} |
| 126 | </div> |
| 127 | ); |
| 128 | } |
| 129 | `); |
| 130 | |
| 131 | // ─── About ───────────────────────────────────────────────────────────────────── |
| 132 | write("about/page.tsx", ` |
| 133 | export const metadata = { title: "About" }; |
| 134 | export default function AboutPage() { |
| 135 | return ( |
| 136 | <div> |
| 137 | <h1>About</h1> |
| 138 | <p>This is a benchmark application for comparing Next.js and vinext (Vite) performance.</p> |
| 139 | <p>It includes 33 routes with nested layouts, dynamic routes, server components, client components, and metadata.</p> |
| 140 | </div> |
| 141 | ); |
| 142 | } |
| 143 | `); |
| 144 | |
| 145 | // ─── Products section (dynamic + layout) ─────────────────────────────────────── |
| 146 | write("products/layout.tsx", ` |
| 147 | export default function ProductsLayout({ children }: { children: React.ReactNode }) { |
| 148 | return ( |
| 149 | <div> |
| 150 | <div style={{ padding: "0.5rem", background: "#f0f0f0", marginBottom: "1rem" }}> |
| 151 | <strong>Products</strong> — <a href="/products">All</a> |
| 152 | </div> |
| 153 | {children} |
| 154 | </div> |
| 155 | ); |
| 156 | } |
| 157 | `); |
| 158 | |
| 159 | // Pre-compute product prices at generation time with seeded PRNG for reproducibility. |
| 160 | const productPrices = Array.from({ length: 20 }, () => (Math.round(random() * 10000) / 100).toFixed(2)); |
| 161 | |
| 162 | write("products/page.tsx", ` |
| 163 | import Link from "next/link"; |
| 164 | export const metadata = { title: "Products" }; |
| 165 | |
| 166 | const products = [ |
| 167 | ${productPrices.map((price, i) => ` { id: ${i + 1}, name: "Product ${i + 1}", price: ${price} },`).join("\n")} |
| 168 | ]; |
| 169 | |
| 170 | export default function ProductsPage() { |
| 171 | return ( |
| 172 | <div> |
| 173 | <h1>Products</h1> |
| 174 | <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: "1rem" }}> |
| 175 | {products.map(p => ( |
| 176 | <Link key={p.id} href={\`/products/\${p.id}\`} style={{ padding: "1rem", border: "1px solid #ddd", textDecoration: "none", color: "inherit" }}> |
| 177 | <h3>{p.name}</h3> |
| 178 | <p>\${p.price}</p> |
| 179 | </Link> |
| 180 | ))} |
| 181 | </div> |
| 182 | </div> |
| 183 | ); |
| 184 | } |
| 185 | `); |
| 186 | |
| 187 | write("products/[id]/page.tsx", ` |
| 188 | export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { |
| 189 | const { id } = await params; |
| 190 | return { title: \`Product \${id}\` }; |
| 191 | } |
| 192 | export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { |
| 193 | const { id } = await params; |
| 194 | return ( |
| 195 | <div> |
| 196 | <h1>Product {id}</h1> |
| 197 | <p>This is the detail page for product {id}. Rendered at {new Date().toISOString()}</p> |
| 198 | </div> |
| 199 | ); |
| 200 | } |
| 201 | `); |
| 202 | |
| 203 | // ─── Blog section (25 posts) ─────────────────────────────────────────────────── |
| 204 | write("blog/layout.tsx", ` |
| 205 | import { Search } from "../_components/search"; |
| 206 | export default function BlogLayout({ children }: { children: React.ReactNode }) { |
| 207 | return ( |
| 208 | <div> |
| 209 | <div style={{ padding: "0.5rem", background: "#f5f5ff", marginBottom: "1rem" }}> |
| 210 | <strong>Blog</strong> — <a href="/blog">All Posts</a> |
| 211 | </div> |
| 212 | <Search placeholder="Search posts..." /> |
| 213 | {children} |
| 214 | </div> |
| 215 | ); |
| 216 | } |
| 217 | `); |
| 218 | |
| 219 | write("blog/page.tsx", ` |
| 220 | import Link from "next/link"; |
| 221 | export const metadata = { title: "Blog" }; |
| 222 | const posts = Array.from({ length: 25 }, (_, i) => ({ |
| 223 | slug: \`post-\${i + 1}\`, |
| 224 | title: \`Blog Post \${i + 1}: \${["React Patterns", "Server Components", "Caching", "Deployment", "Performance"][i % 5]}\`, |
| 225 | date: new Date(2025, 0, i + 1).toLocaleDateString(), |
| 226 | })); |
| 227 | export default function BlogPage() { |
| 228 | return ( |
| 229 | <div> |
| 230 | <h1>Blog</h1> |
| 231 | {posts.map(post => ( |
| 232 | <article key={post.slug} style={{ marginBottom: "1rem", paddingBottom: "1rem", borderBottom: "1px solid #eee" }}> |
| 233 | <Link href={\`/blog/\${post.slug}\`}><h2>{post.title}</h2></Link> |
| 234 | <time>{post.date}</time> |
| 235 | </article> |
| 236 | ))} |
| 237 | </div> |
| 238 | ); |
| 239 | } |
| 240 | `); |
| 241 | |
| 242 | write("blog/[slug]/page.tsx", ` |
| 243 | export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { |
| 244 | const { slug } = await params; |
| 245 | return { title: slug.replace(/-/g, " ") }; |
| 246 | } |
| 247 | export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { |
| 248 | const { slug } = await params; |
| 249 | const paragraphs = Array.from({ length: 5 }, (_, i) => |
| 250 | \`Paragraph \${i + 1} of "\${slug}". Lorem ipsum dolor sit amet, consectetur adipiscing elit.\` |
| 251 | ); |
| 252 | return ( |
| 253 | <div> |
| 254 | <h1>{slug.replace(/-/g, " ")}</h1> |
| 255 | <time>{new Date().toLocaleDateString()}</time> |
| 256 | {paragraphs.map((p, i) => <p key={i}>{p}</p>)} |
| 257 | </div> |
| 258 | ); |
| 259 | } |
| 260 | `); |
| 261 | |
| 262 | // ─── Dashboard (nested layout + client components) ───────────────────────────── |
| 263 | write("dashboard/layout.tsx", ` |
| 264 | import { Timer } from "../_components/timer"; |
| 265 | export default function DashboardLayout({ children }: { children: React.ReactNode }) { |
| 266 | return ( |
| 267 | <div style={{ display: "grid", gridTemplateColumns: "200px 1fr" }}> |
| 268 | <aside style={{ padding: "1rem", borderRight: "1px solid #eee" }}> |
| 269 | <h3>Dashboard</h3> |
| 270 | <nav style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}> |
| 271 | <a href="/dashboard">Overview</a> |
| 272 | <a href="/dashboard/analytics">Analytics</a> |
| 273 | <a href="/dashboard/users">Users</a> |
| 274 | <a href="/dashboard/settings">Settings</a> |
| 275 | </nav> |
| 276 | <div style={{ marginTop: "1rem", fontSize: "0.8rem", color: "#666" }}><Timer /></div> |
| 277 | </aside> |
| 278 | <div style={{ padding: "1rem" }}>{children}</div> |
| 279 | </div> |
| 280 | ); |
| 281 | } |
| 282 | `); |
| 283 | |
| 284 | write("dashboard/page.tsx", ` |
| 285 | import { Counter } from "../_components/counter"; |
| 286 | export const metadata = { title: "Dashboard" }; |
| 287 | export default function DashboardPage() { |
| 288 | const stats = [ |
| 289 | { label: "Total Users", value: "12,345" }, |
| 290 | { label: "Revenue", value: "$98,765" }, |
| 291 | { label: "Orders", value: "3,456" }, |
| 292 | { label: "Conversion", value: "3.2%" }, |
| 293 | ]; |
| 294 | return ( |
| 295 | <div> |
| 296 | <h1>Dashboard Overview</h1> |
| 297 | <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem", marginBottom: "2rem" }}> |
| 298 | {stats.map(s => ( |
| 299 | <div key={s.label} style={{ padding: "1rem", border: "1px solid #ddd", borderRadius: "8px" }}> |
| 300 | <div style={{ fontSize: "0.8rem", color: "#666" }}>{s.label}</div> |
| 301 | <div style={{ fontSize: "1.5rem", fontWeight: "bold" }}>{s.value}</div> |
| 302 | </div> |
| 303 | ))} |
| 304 | </div> |
| 305 | <Counter label="Page Views" /> |
| 306 | </div> |
| 307 | ); |
| 308 | } |
| 309 | `); |
| 310 | |
| 311 | // Pre-compute analytics data at generation time with seeded PRNG for reproducibility. |
| 312 | const analyticsRows = Array.from({ length: 10 }, () => ({ |
| 313 | views: Math.floor(random() * 10000), |
| 314 | bounce: Math.floor(random() * 100), |
| 315 | })); |
| 316 | |
| 317 | write("dashboard/analytics/page.tsx", ` |
| 318 | export const metadata = { title: "Analytics" }; |
| 319 | |
| 320 | const rows = [ |
| 321 | ${analyticsRows.map((r, i) => ` { page: "/page-${i + 1}", views: ${r.views}, bounce: ${r.bounce} },`).join("\n")} |
| 322 | ]; |
| 323 | |
| 324 | export default function AnalyticsPage() { |
| 325 | return ( |
| 326 | <div> |
| 327 | <h1>Analytics</h1> |
| 328 | <table style={{ width: "100%", borderCollapse: "collapse" }}> |
| 329 | <thead><tr><th>Page</th><th>Views</th><th>Bounce Rate</th></tr></thead> |
| 330 | <tbody>{rows.map(r => ( |
| 331 | <tr key={r.page} style={{ borderBottom: "1px solid #eee" }}> |
| 332 | <td style={{ padding: "0.5rem" }}>{r.page}</td> |
| 333 | <td style={{ padding: "0.5rem" }}>{r.views.toLocaleString()}</td> |
| 334 | <td style={{ padding: "0.5rem" }}>{r.bounce}%</td> |
| 335 | </tr> |
| 336 | ))}</tbody> |
| 337 | </table> |
| 338 | </div> |
| 339 | ); |
| 340 | } |
| 341 | `); |
| 342 | |
| 343 | write("dashboard/users/page.tsx", ` |
| 344 | export const metadata = { title: "Users" }; |
| 345 | export default function UsersPage() { |
| 346 | const users = Array.from({ length: 15 }, (_, i) => ({ |
| 347 | id: i + 1, name: \`User \${i + 1}\`, email: \`user\${i + 1}@example.com\`, role: i % 3 === 0 ? "Admin" : "User", |
| 348 | })); |
| 349 | return ( |
| 350 | <div> |
| 351 | <h1>Users</h1> |
| 352 | <table style={{ width: "100%", borderCollapse: "collapse" }}> |
| 353 | <thead><tr><th>ID</th><th>Name</th><th>Email</th><th>Role</th></tr></thead> |
| 354 | <tbody>{users.map(u => ( |
| 355 | <tr key={u.id} style={{ borderBottom: "1px solid #eee" }}> |
| 356 | <td style={{ padding: "0.5rem" }}>{u.id}</td><td style={{ padding: "0.5rem" }}>{u.name}</td> |
| 357 | <td style={{ padding: "0.5rem" }}>{u.email}</td><td style={{ padding: "0.5rem" }}>{u.role}</td> |
| 358 | </tr> |
| 359 | ))}</tbody> |
| 360 | </table> |
| 361 | </div> |
| 362 | ); |
| 363 | } |
| 364 | `); |
| 365 | |
| 366 | write("dashboard/settings/page.tsx", ` |
| 367 | import { Counter } from "../../_components/counter"; |
| 368 | export const metadata = { title: "Dashboard Settings" }; |
| 369 | export default function DashboardSettingsPage() { |
| 370 | return (<div><h1>Dashboard Settings</h1><p>Configure dashboard preferences.</p><Counter label="Saves" /></div>); |
| 371 | } |
| 372 | `); |
| 373 | |
| 374 | // ─── Docs (catch-all) ────────────────────────────────────────────────────────── |
| 375 | write("docs/page.tsx", ` |
| 376 | import Link from "next/link"; |
| 377 | export const metadata = { title: "Documentation" }; |
| 378 | const sections = ["getting-started", "installation", "configuration", "api-reference", "deployment", "troubleshooting", "migration", "plugins"]; |
| 379 | export default function DocsIndex() { |
| 380 | return (<div><h1>Documentation</h1><ul>{sections.map(s => <li key={s}><Link href={\`/docs/\${s}\`}>{s.replace(/-/g, " ")}</Link></li>)}</ul></div>); |
| 381 | } |
| 382 | `); |
| 383 | |
| 384 | write("docs/[...slug]/page.tsx", ` |
| 385 | export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }) { |
| 386 | const { slug } = await params; |
| 387 | return { title: \`Docs: \${slug.join(" / ")}\` }; |
| 388 | } |
| 389 | export default async function DocPage({ params }: { params: Promise<{ slug: string[] }> }) { |
| 390 | const { slug } = await params; |
| 391 | return (<div><h1>Docs: {slug.join(" / ")}</h1>{Array.from({ length: 3 }, (_, i) => <p key={i}>Section {i + 1} content.</p>)}</div>); |
| 392 | } |
| 393 | `); |
| 394 | |
| 395 | // ─── Settings (nested layout) ────────────────────────────────────────────────── |
| 396 | write("settings/layout.tsx", ` |
| 397 | export default function SettingsLayout({ children }: { children: React.ReactNode }) { |
| 398 | return ( |
| 399 | <div> |
| 400 | <div style={{ display: "flex", gap: "1rem", padding: "0.5rem", background: "#fafafa", marginBottom: "1rem" }}> |
| 401 | <a href="/settings">General</a> |
| 402 | <a href="/settings/profile">Profile</a> |
| 403 | <a href="/settings/notifications">Notifications</a> |
| 404 | <a href="/settings/billing">Billing</a> |
| 405 | </div> |
| 406 | {children} |
| 407 | </div> |
| 408 | ); |
| 409 | } |
| 410 | `); |
| 411 | |
| 412 | for (const page of ["", "profile", "notifications", "billing"]) { |
| 413 | const name = page || "General"; |
| 414 | const dir = page ? `settings/${page}` : "settings"; |
| 415 | write(`${dir}/page.tsx`, ` |
| 416 | export const metadata = { title: "Settings - ${name}" }; |
| 417 | export default function Settings${name.charAt(0).toUpperCase() + name.slice(1)}Page() { |
| 418 | return (<div><h1>Settings: ${name}</h1><p>Configure ${name.toLowerCase()} settings.</p></div>); |
| 419 | } |
| 420 | `); |
| 421 | } |
| 422 | |
| 423 | // ─── API routes ──────────────────────────────────────────────────────────────── |
| 424 | write("api/health/route.ts", ` |
| 425 | export function GET() { |
| 426 | return Response.json({ status: "ok", timestamp: Date.now() }); |
| 427 | } |
| 428 | `); |
| 429 | |
| 430 | write("api/data/route.ts", ` |
| 431 | export function GET() { |
| 432 | const data = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, value: Math.random(), label: \`Item \${i + 1}\` })); |
| 433 | return Response.json(data); |
| 434 | } |
| 435 | `); |
| 436 | |
| 437 | // ─── Additional static pages (volume) ────────────────────────────────────────── |
| 438 | const staticPages = [ |
| 439 | "features", "pricing", "team", "careers", "contact", |
| 440 | "faq", "terms", "privacy", "changelog", "roadmap", |
| 441 | "support", "community", "partners", "press", "security", |
| 442 | ]; |
| 443 | |
| 444 | for (const page of staticPages) { |
| 445 | const title = page.charAt(0).toUpperCase() + page.slice(1); |
| 446 | write(`${page}/page.tsx`, ` |
| 447 | export const metadata = { title: "${title}" }; |
| 448 | export default function ${title}Page() { |
| 449 | return ( |
| 450 | <div> |
| 451 | <h1>${title}</h1> |
| 452 | <p>This is the ${page} page with information about ${page}.</p> |
| 453 | ${page === "faq" ? ` |
| 454 | <div> |
| 455 | {Array.from({ length: 10 }, (_, i) => ( |
| 456 | <details key={i} style={{ marginBottom: "0.5rem" }}> |
| 457 | <summary>Question {i + 1}?</summary> |
| 458 | <p>Answer to question {i + 1}.</p> |
| 459 | </details> |
| 460 | ))} |
| 461 | </div>` : ` |
| 462 | <p>More content for the ${page} section would go here.</p>`} |
| 463 | </div> |
| 464 | ); |
| 465 | } |
| 466 | `); |
| 467 | } |
| 468 | |
| 469 | // Count results |
| 470 | import { readdirSync, statSync } from "node:fs"; |
| 471 | function countFiles(dir, name) { |
| 472 | let count = 0; |
| 473 | for (const entry of readdirSync(dir)) { |
| 474 | const full = join(dir, entry); |
| 475 | if (statSync(full).isDirectory()) count += countFiles(full, name); |
| 476 | else if (entry === name) count++; |
| 477 | } |
| 478 | return count; |
| 479 | } |
| 480 | |
| 481 | const pages = countFiles(APP, "page.tsx"); |
| 482 | const routes = countFiles(APP, "route.ts"); |
| 483 | console.log(`Generated benchmark app: ${pages} pages + ${routes} API routes = ${pages + routes} total routes`); |
| 484 | |
| 485 | // Copy to each benchmark project (symlinks don't work with Turbopack) |
| 486 | import { cpSync } from "node:fs"; |
| 487 | const BASE = dirname(new URL(import.meta.url).pathname); |
| 488 | for (const project of ["nextjs", "vinext", "vinext-rolldown"]) { |
| 489 | const dest = join(BASE, project, "app"); |
| 490 | rmSync(dest, { recursive: true, force: true }); |
| 491 | cpSync(APP, dest, { recursive: true }); |
| 492 | console.log(` Copied to ${project}/app`); |
| 493 | } |
| 494 | |