mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
13d36378f8
* perf(build): parallelize prerender across a pool of render processes Build-time prerender rendered every static route by fetching it from a single in-process production server driven by a promise pool. React SSR/RSC rendering is CPU-bound JS, so the pool only overlaps I/O — every render serialized on one core, and raising --prerender-concurrency did nothing. Next.js forks a worker pool and saturates every core. Fork a pool of production-server child processes (one per core, capped) and round-robin the per-route render fetch across them, keeping route scanning, getStaticPaths/static-params resolution, file writing and the manifest on the main process. Pool size scales by cores AND routes, so small apps, low-memory machines, and --prerender-concurrency 1 keep the single in-process server (no fork, no regression); running from source (no built .js worker entry) also falls back to single-process. child_process, not worker_threads: worker threads contend for CPU on this workload (measured ~2x slower per route and non-scaling), which is also why Next.js uses processes. react.dev (809 routes, cold cache, same machine): 29.9s -> 15.1s, now faster than its own Next.js build (~19.3s). An 801-route static fixture: ~22s -> ~2-6s. Prerender output is byte-identical to the single-process path on deterministic renders; workers install the same NoOp cache handler the in-process path uses. A worker that exits unexpectedly fails the build loudly instead of shipping partial output. * fix(build): harden prerender worker pool --------- Co-authored-by: James <james@eli.cx>
131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
resolvePrerenderPoolSize,
|
|
startPrerenderServerPool,
|
|
} from "../packages/vinext/src/build/prerender-server-pool.js";
|
|
|
|
describe("prerender server pool sizing", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockMachine(cores: number, totalmem: number): void {
|
|
vi.spyOn(os, "availableParallelism").mockReturnValue(cores);
|
|
vi.spyOn(os, "totalmem").mockReturnValue(totalmem);
|
|
}
|
|
|
|
it("keeps small prerenders in the in-process server", () => {
|
|
mockMachine(16, 64 * 1024 * 1024 * 1024);
|
|
|
|
expect(resolvePrerenderPoolSize(47)).toBe(1);
|
|
});
|
|
|
|
it("caps by routes, cores, memory, and the explicit worker override", () => {
|
|
mockMachine(16, 64 * 1024 * 1024 * 1024);
|
|
|
|
expect(resolvePrerenderPoolSize(96, 2)).toBe(2);
|
|
expect(resolvePrerenderPoolSize(384)).toBe(8);
|
|
|
|
mockMachine(4, 64 * 1024 * 1024 * 1024);
|
|
expect(resolvePrerenderPoolSize(384)).toBe(3);
|
|
|
|
mockMachine(16, 2 * 1024 * 1024 * 1024);
|
|
expect(resolvePrerenderPoolSize(384)).toBe(1);
|
|
|
|
mockMachine(16, 2.5 * 1024 * 1024 * 1024);
|
|
expect(resolvePrerenderPoolSize(384)).toBe(2);
|
|
});
|
|
|
|
it("treats an explicit concurrency of 1 as opting out of the process pool", () => {
|
|
mockMachine(16, 64 * 1024 * 1024 * 1024);
|
|
|
|
expect(resolvePrerenderPoolSize(384, 1)).toBe(1);
|
|
});
|
|
|
|
function writeWorkerEntry(source: string): { dir: string; entry: string } {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prerender-pool-worker-"));
|
|
const entry = path.join(dir, "worker.js");
|
|
fs.writeFileSync(entry, source, "utf-8");
|
|
return { dir, entry };
|
|
}
|
|
|
|
it("starts and closes stub render workers", async () => {
|
|
const { dir, entry } = writeWorkerEntry(`
|
|
process.send({ type: "ready", port: 30000 + (process.pid % 10000) });
|
|
setInterval(() => {}, 1000);
|
|
`);
|
|
|
|
try {
|
|
const pool = await startPrerenderServerPool(dir, 2, entry);
|
|
try {
|
|
expect(pool.ports).toHaveLength(2);
|
|
expect(pool.ports.every((port) => typeof port === "number")).toBe(true);
|
|
expect(() => pool.assertHealthy()).not.toThrow();
|
|
} finally {
|
|
await pool.close();
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails assertHealthy immediately after a worker transport error", async () => {
|
|
const { dir, entry } = writeWorkerEntry(`
|
|
process.send({ type: "ready", port: 4124 });
|
|
setInterval(() => {}, 1000);
|
|
`);
|
|
|
|
try {
|
|
const pool = await startPrerenderServerPool(dir, 1, entry);
|
|
try {
|
|
pool.recordRenderError(new Error("renderPage returned 500"));
|
|
expect(() => pool.assertHealthy()).not.toThrow();
|
|
|
|
const transportError = Object.assign(new TypeError("fetch failed"), {
|
|
cause: { code: "ECONNRESET" },
|
|
});
|
|
pool.recordRenderError(transportError);
|
|
expect(() => pool.assertHealthy()).toThrow(/request failed \(ECONNRESET\)/);
|
|
} finally {
|
|
await pool.close();
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("fails assertHealthy when a ready worker exits unexpectedly", async () => {
|
|
const { dir, entry } = writeWorkerEntry(`
|
|
process.send({ type: "ready", port: 4123 });
|
|
setTimeout(() => process.exit(42), 25);
|
|
`);
|
|
|
|
try {
|
|
const pool = await startPrerenderServerPool(dir, 1, entry);
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(() => pool.assertHealthy()).toThrow(/exited unexpectedly .*code 42/);
|
|
} finally {
|
|
await pool.close();
|
|
}
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("rejects when a worker exits before reporting ready", async () => {
|
|
const { dir, entry } = writeWorkerEntry("process.exit(0);");
|
|
|
|
try {
|
|
await expect(startPrerenderServerPool(dir, 1, entry)).rejects.toThrow(
|
|
"exited during startup (code 0, signal null)",
|
|
);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|