diff --git a/apps/landing/check-prerender.ts b/apps/landing/check-prerender.ts new file mode 100644 index 000000000..e7fdb91b6 --- /dev/null +++ b/apps/landing/check-prerender.ts @@ -0,0 +1,9 @@ +import { fileURLToPath } from 'node:url' + +import { EXPECTED_EXPORTED_ROUTES } from '../../e2e/exported-routes' +import { verifyPrerender } from './verify-prerender' + +verifyPrerender( + fileURLToPath(new URL('./dist/client/', import.meta.url)), + EXPECTED_EXPORTED_ROUTES, +) diff --git a/apps/landing/package.json b/apps/landing/package.json index 7daeaf3b7..a245d5ead 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "node ./script.js && vinext dev", "search": "node ./script.js", - "build": "node ./script.js && vinext build", + "build": "node ./script.js && vinext build && bun ./check-prerender.ts", "build:next": "node ./script.js && LANDING_BUILD_MODE=next next build", "start": "npx serve ./dist/client", "lint": "eslint" diff --git a/apps/landing/verify-prerender.ts b/apps/landing/verify-prerender.ts new file mode 100644 index 000000000..401be9b48 --- /dev/null +++ b/apps/landing/verify-prerender.ts @@ -0,0 +1,46 @@ +import { statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { + getOutputPath, + getRscOutputPath, +} from 'vinext/internal/utils/prerender-output-paths' + +export class PrerenderArtifactError extends Error { + constructor( + readonly route: string, + readonly path: string, + readonly reason: 'missing' | 'empty' | 'not-file', + ) { + super(`Prerender artifact ${reason}: route ${route}, artifact ${path}`) + this.name = 'PrerenderArtifactError' + } +} + +function requireArtifact(route: string, path: string): void { + const stat = statSync(path, { throwIfNoEntry: false }) + if (!stat) throw new PrerenderArtifactError(route, path, 'missing') + if (!stat.isFile()) throw new PrerenderArtifactError(route, path, 'not-file') + if (stat.size === 0) throw new PrerenderArtifactError(route, path, 'empty') +} + +export function verifyPrerender( + outputDirectory: string, + expectedRoutes: readonly string[], +): void { + // Match landing's output: 'export', trailingSlash: false, empty basePath. + for (const route of expectedRoutes) { + requireArtifact( + route, + resolve(outputDirectory, getOutputPath(route, false)), + ) + requireArtifact( + route, + resolve( + outputDirectory, + getRscOutputPath(route, { mode: 'export', trailingSlash: false }), + ), + ) + } + requireArtifact('/404', resolve(outputDirectory, '404.html')) +} diff --git a/bun.lock b/bun.lock index ff3fddaa0..2b5f05143 100644 --- a/bun.lock +++ b/bun.lock @@ -677,6 +677,9 @@ }, }, }, + "patchedDependencies": { + "vinext@1.2.1": "patches/vinext@1.2.1.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], diff --git a/package.json b/package.json index bef5bf486..d2949c9ec 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "apps/*", "bindings/*", "benchmark/*" - ] + ], + "patchedDependencies": { + "vinext@1.2.1": "patches/vinext@1.2.1.patch" + } } diff --git a/packages/vite-plugin/src/__tests__/landing-prerender.test.ts b/packages/vite-plugin/src/__tests__/landing-prerender.test.ts new file mode 100644 index 000000000..2a8e96fbe --- /dev/null +++ b/packages/vite-plugin/src/__tests__/landing-prerender.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { + PrerenderArtifactError, + verifyPrerender, +} from '../../../../apps/landing/verify-prerender' + +const routes = ['/', '/nested/leaf'] as const +const artifacts = [ + ['/', 'index.html'], + ['/', 'index.txt'], + ['/nested/leaf', 'nested/leaf.html'], + ['/nested/leaf', 'nested/leaf.txt'], + ['/404', '404.html'], +] as const + +function temporaryOutput() { + const root = mkdtempSync(join(tmpdir(), 'landing-prerender-')) + return { + root, + [Symbol.dispose]() { + rmSync(root, { recursive: true, force: true }) + }, + } +} + +function writeCompleteOutput(root: string): void { + mkdirSync(join(root, 'nested')) + // Independent fixture paths, not generated by Vinext or the verifier. + writeFileSync(join(root, 'index.html'), 'home') + writeFileSync(join(root, 'index.txt'), 'home Flight') + writeFileSync(join(root, 'nested/leaf.html'), 'leaf') + writeFileSync(join(root, 'nested/leaf.txt'), 'leaf Flight') + writeFileSync(join(root, '404.html'), 'not found') +} + +it('accepts complete route pairs when the 404 has HTML only', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + // When / Then + expect(() => verifyPrerender(output.root, routes)).not.toThrow() +}) + +describe.each(artifacts)('route %s artifact %s', (route, artifact) => { + it('rejects the artifact when it is missing', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + rmSync(path) + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'missing', + }), + ) + }) + + it('rejects the artifact when it is empty', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + writeFileSync(path, '') + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'empty', + }), + ) + }) + + it('rejects the artifact when it is a directory', () => { + // Given + using output = temporaryOutput() + writeCompleteOutput(output.root) + const path = join(output.root, artifact) + rmSync(path) + mkdirSync(path) + // When / Then + expect(() => verifyPrerender(output.root, routes)).toThrow( + expect.objectContaining({ + constructor: PrerenderArtifactError, + route, + path, + reason: 'not-file', + }), + ) + }) +}) + +it('propagates native filesystem errors when the path contains a null byte', () => { + // Given + using output = temporaryOutput() + const path = join(output.root, '\0') + // When / Then + expect(() => verifyPrerender(path, routes)).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG_VALUE' }), + ) +}) diff --git a/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs new file mode 100644 index 000000000..fe22c742a --- /dev/null +++ b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.helper.mjs @@ -0,0 +1,208 @@ +// Runs Vinext's published prerenderApp in Node, the runtime of `vinext build`, +// against servers started by Vinext's own startProdServer, and prints a JSON +// summary for vinext-prerender-transport.test.ts. +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const [prodServerPath, prerenderPath] = process.argv.slice(2) +const { startProdServer } = await import(pathToFileURL(prodServerPath).href) +const { prerenderApp } = await import(pathToFileURL(prerenderPath).href) + +const flight = '0:["$","main",null,{"children":"victim"}]\n' +const html = `
victim
` +const entry = `export default async function handler(request) { + const { pathname } = new URL(request.url) + if (pathname === '/warm') return new Response('warm') + if (pathname === '/victim') return new Response(${JSON.stringify(html)}, { headers: { 'content-type': 'text/html; charset=utf-8' } }) + if (pathname === '/dynamic') return new Response('dynamic', { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } }) + return new Response('404', { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } }) +} +` + +function causeChain(error) { + const chain = [] + for (let cause = error; cause instanceof Error; cause = cause.cause) + chain.push({ name: cause.name, message: cause.message, code: cause.code }) + return chain +} + +function summarize(outDir, outcome) { + const victimHtml = join(outDir, 'victim.html') + const victimFlight = join(outDir, 'victim.txt') + return { + routes: + outcome.routes?.map(({ route, status, reason }) => + [route, status, reason].filter(Boolean).join(':'), + ) ?? null, + error: outcome.error ?? null, + html: existsSync(victimHtml) && readFileSync(victimHtml, 'utf8') === html, + flight: + existsSync(victimFlight) && readFileSync(victimFlight, 'utf8') === flight, + written: ['victim.html', 'victim.txt', 'dynamic.html'].filter((file) => + existsSync(join(outDir, file)), + ), + } +} + +function classifyVictim({ routes, error, html, flight, written }) { + if (error) + return error.name === 'AggregateError' && + error.message.endsWith('\n /victim: fetch failed (ECONNRESET)') && + written.length === 0 + ? 'failed-closed' + : error.message + return routes.includes('/victim:rendered') && html && flight + ? 'rendered' + : routes.find((route) => route.startsWith('/victim')) +} + +function prerender(prodServer, outDir, serverDir, rscBundlePath, routes) { + return prerenderApp({ + routes, + metadataRoutes: [], + outDir, + serverDir, + rscBundlePath, + mode: 'export', + config: { basePath: '', trailingSlash: false }, + _prodServer: prodServer, + }).then( + (result) => summarize(outDir, { routes: result.routes }), + (error) => + summarize(outDir, { + error: { + name: error.name, + message: error.message, + causes: causeChain(error.cause), + errors: error.errors?.length ?? 0, + }, + }), + ) +} + +const root = mkdtempSync(join(tmpdir(), 'vinext-prerender-transport-')) +try { + writeFileSync(join(root, 'package.json'), '{"type":"module"}') + const serverDir = join(root, 'server') + mkdirSync(serverDir) + mkdirSync(join(root, 'client')) + const rscBundlePath = join(serverDir, 'index.js') + writeFileSync(rscBundlePath, entry) + const routes = ['victim', 'dynamic'].map((name) => { + const pagePath = join(root, `${name}.tsx`) + writeFileSync( + pagePath, + `export default function Page() { return
${name}
}\n`, + ) + return { + pattern: `/${name}`, + pagePath, + routePath: null, + isDynamic: false, + layouts: [], + parallelSlots: [], + } + }) + + const production = await startProdServer({ + port: 0, + host: '127.0.0.1', + outDir: root, + rscEntryPath: rscBundlePath, + serverDir, + noCompression: true, + silent: true, + }) + const productionMaxRequestsPerSocket = production.server.maxRequestsPerSocket + production.server.close() + + // "shipped" keeps the server exactly as Vinext starts it for prerendering; + // "reuse" restores Node's default keep-alive reuse on that same server. + const servers = {} + const warm = {} + for (const name of ['shipped', 'reuse']) { + const prodServer = await startProdServer({ + port: 0, + host: '127.0.0.1', + outDir: root, + rscEntryPath: rscBundlePath, + serverDir, + noCompression: true, + purpose: 'prerender', + silent: true, + }) + warm[name] = { + maxRequestsPerSocket: prodServer.server.maxRequestsPerSocket, + } + if (name === 'reuse') prodServer.server.maxRequestsPerSocket = 0 + const response = await fetch(`http://127.0.0.1:${prodServer.port}/warm`) + await response.text() + warm[name].connection = response.headers.get('connection') + servers[name] = prodServer + } + + // Queue the prerender requests, then block the event loop past the servers' + // idle keep-alive timeout, as a long render does during a build. + const idleTimeout = Math.max( + ...Object.values(servers).map( + ({ server }) => server.keepAliveTimeout + server.keepAliveTimeoutBuffer, + ), + ) + const [shipped, reuse] = await new Promise((resolve) => + setImmediate(() => { + const pending = ['shipped', 'reuse'].map((name) => + prerender( + servers[name], + join(root, name), + serverDir, + rscBundlePath, + routes, + ), + ) + const start = performance.now() + while (performance.now() - start < idleTimeout); + resolve(Promise.all(pending)) + }), + ) + for (const { server } of Object.values(servers)) { + server.closeAllConnections() + server.close() + } + + // "reset" stands in for a prerender server whose connection fails after the + // request is sent: every request gets a TCP reset and no response. + const resetServer = createServer((socket) => + socket.once('data', () => socket.resetAndDestroy()), + ) + await new Promise((resolve) => resetServer.listen(0, '127.0.0.1', resolve)) + const reset = await prerender( + { server: resetServer, port: resetServer.address().port }, + join(root, 'reset'), + serverDir, + rscBundlePath, + routes, + ) + resetServer.close() + + process.stdout.write( + `${JSON.stringify({ + idleTimeout, + productionMaxRequestsPerSocket, + shipped: { ...warm.shipped, ...shipped }, + reuse: { ...warm.reuse, victim: classifyVictim(reuse) }, + reset, + })}\n`, + ) +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts new file mode 100644 index 000000000..e2cd2314b --- /dev/null +++ b/packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts @@ -0,0 +1,83 @@ +import { dirname, join } from 'node:path' + +import { expect, it } from 'bun:test' + +// The landing app's Vinext, with the repository's Bun patch applied. +const landing = join(import.meta.dir, '../../../../apps/landing') +const prerenderEntry = Bun.resolveSync( + 'vinext/internal/build/run-prerender', + landing, +) + +// `vinext build` runs in Node, so the helper does too; its fetch and HTTP +// server are the ones whose keep-alive reuse loses pages. +const child = Bun.spawnSync( + [ + 'node', + join(import.meta.dir, 'vinext-prerender-transport.helper.mjs'), + Bun.resolveSync('vinext/server/prod-server', landing), + join(dirname(prerenderEntry), 'prerender.js'), + ], + { stdout: 'pipe', stderr: 'pipe', timeout: 60000 }, +) +const report: unknown = JSON.parse( + child.stdout.toString().trim().split('\n').at(-1) ?? 'null', +) + +it('runs the published prerender code to completion', () => { + expect({ + exitCode: child.exitCode, + stderr: child.stderr.toString(), + }).toMatchObject({ exitCode: 0 }) + expect(report).toMatchObject({ idleTimeout: expect.any(Number) }) +}) + +it('serves each build-time prerender request on its own connection', () => { + expect(report).toMatchObject({ + shipped: { + maxRequestsPerSocket: 1, + connection: 'close', + routes: ['/victim:rendered', '/dynamic:skipped:dynamic', '/404:rendered'], + error: null, + html: true, + flight: true, + written: ['victim.html', 'victim.txt'], + }, + }) +}) + +it('keeps Node keep-alive defaults on servers that are not for prerendering', () => { + expect(report).toMatchObject({ productionMaxRequestsPerSocket: 0 }) +}) + +it('never drops a page silently when the server closes a reused connection', () => { + // Node 24 resets the reused connection here; newer Node versions render it. + expect(report).toMatchObject({ + reuse: { + maxRequestsPerSocket: 1, + connection: 'keep-alive', + victim: expect.stringMatching(/^(rendered|failed-closed)$/), + }, + }) +}) + +it('fails the export naming every route and keeping the original error when a request gets no response', () => { + expect(report).toMatchObject({ + reset: { + routes: null, + error: { + name: 'AggregateError', + message: + '[vinext] Static export failed: 2 prerender requests failed before a response was received.\n' + + ' /dynamic: fetch failed (ECONNRESET)\n' + + ' /victim: fetch failed (ECONNRESET)', + causes: [ + { name: 'TypeError', message: 'fetch failed' }, + { code: 'ECONNRESET' }, + ], + errors: 2, + }, + written: [], + }, + }) +}) diff --git a/patches/vinext@1.2.1.patch b/patches/vinext@1.2.1.patch new file mode 100644 index 000000000..dc5cc0b76 --- /dev/null +++ b/patches/vinext@1.2.1.patch @@ -0,0 +1,223 @@ +diff --git a/dist/build/prerender.js b/dist/build/prerender.js +index 0e1a3d21988690c2c69603d03861bc57406c2eae..8dfc5f75b44fb7efb1e0e022118d99d5416d9e00 100644 +--- a/dist/build/prerender.js ++++ b/dist/build/prerender.js +@@ -64,6 +64,45 @@ async function startOptionalPrerenderServerPool(outDir, poolSize, rscEntryPath, + } + var PrerenderUserFunctionError = class extends Error {}; + var InvalidStaticParamsError = class extends Error {}; ++/** ++* Errors thrown by `fetch()` for a request to a build-time prerender server. ++* Such a request failed before any response arrived, so it says nothing about ++* whether the route is dynamic. ++*/ ++const failedPrerenderRequests = /* @__PURE__ */ new WeakSet(); ++function fetchPrerenderServer(url, init) { ++ return fetch(url, init).catch((error) => { ++ if (typeof error === "object" && error !== null) failedPrerenderRequests.add(error); ++ throw error; ++ }); ++} ++/** ++* Collects failed prerender requests during a static export. The export fails ++* once in-flight renders have settled, naming every route in a stable order and ++* keeping each original error. ++*/ ++function createExportRequestFailures(mode) { ++ const failures = []; ++ return { ++ record(error, route, urlPath) { ++ if (mode !== "export" || !failedPrerenderRequests.has(error)) return false; ++ failures.push({ ++ route: urlPath && urlPath !== route ? `${route} (${urlPath})` : route, ++ error ++ }); ++ return true; ++ }, ++ throwIfAny() { ++ if (failures.length === 0) return; ++ failures.sort((a, b) => a.route < b.route ? -1 : a.route > b.route ? 1 : 0); ++ const lines = failures.map(({ route, error }) => { ++ const code = error.cause?.code; ++ return ` ${route}: ${error.message}${typeof code === "string" ? ` (${code})` : ""}`; ++ }); ++ throw new AggregateError(failures.map(({ error }) => error), `[vinext] Static export failed: ${failures.length} prerender request${failures.length === 1 ? "" : "s"} failed before a response was received.\n${lines.join("\n")}`, { cause: failures[0].error }); ++ } ++ }; ++} + function parsePrerenderEndpointError(text) { + try { + const parsed = JSON.parse(text); +@@ -368,6 +407,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const skipManifest = options.skipManifest ?? false; + const fileMatcher = createValidFileMatcher(config.pageExtensions); + const results = []; ++ const exportRequestFailures = createExportRequestFailures(mode); + if (!pagesBundlePath && !options._prodServer) throw new Error("[vinext] prerenderPages: either pagesBundlePath or _prodServer must be provided."); + fs.mkdirSync(outDir, { recursive: true }); + for (const apiRoute of apiRoutes) results.push({ +@@ -405,7 +445,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const port = renderPorts[renderRoundRobin++ % renderPorts.length]; + const routeRequestPath = config.trailingSlash && !urlPath.endsWith("/") ? `${urlPath}/` : urlPath; + const requestPath = config.basePath && routeRequestPath === "/" && !config.trailingSlash ? config.basePath : `${config.basePath ?? ""}${routeRequestPath}`; +- return fetch(`http://127.0.0.1:${port}${requestPath}`, { ++ return fetchPrerenderServer(`http://127.0.0.1:${port}${requestPath}`, { + headers: secretHeaders, + redirect: "manual" + }); +@@ -419,7 +459,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + const search = new URLSearchParams({ pattern: r.pattern }); + if (locales.length > 0) search.set("locales", JSON.stringify(locales)); + if (defaultLocale) search.set("defaultLocale", defaultLocale); +- const res = await fetch(`${baseUrl}/__vinext/prerender/pages-static-paths?${search}`, { headers: secretHeaders }); ++ const res = await fetchPrerenderServer(`${baseUrl}/__vinext/prerender/pages-static-paths?${search}`, { headers: secretHeaders }); + const text = await res.text(); + if (!res.ok) { + if (res.status === 500) throw new PrerenderUserFunctionError(parsePrerenderEndpointError(text)); +@@ -476,6 +516,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + defaultLocale: config.i18n?.defaultLocale ?? "" + }); + } catch (e) { ++ exportRequestFailures.record(e, route.pattern); + results.push({ + route: route.pattern, + status: "error", +@@ -630,6 +671,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + }; + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, route.pattern, urlPath); + const err = e; + result = { + route: route.pattern, +@@ -646,6 +688,7 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + return result; + }); + results.push(...pageResults); ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + const hasCustom404 = findFileWithExtensions(path.join(pagesDir, "404"), fileMatcher); + const hasErrorPage = findFileWithExtensions(path.join(pagesDir, "_error"), fileMatcher); +@@ -683,8 +726,10 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + } + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, "/404", localizePagesPath("/404", locale, config.i18n)); + } + } ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + if (!skipManifest) writePrerenderIndex(results, manifestDir, { + buildId: config.buildId, +@@ -716,7 +761,8 @@ async function prerenderPages({ routes, apiRoutes, pagesDir, outDir, config, mod + * Speculative static rendering: routes classified as 'unknown' (no explicit + * config, non-dynamic URL) are attempted with an empty headers/cookies context. + * If they succeed, they are marked as rendered. If they throw a DynamicUsageError +-* or fail, they are marked as skipped with reason 'dynamic'. ++* or fail, they are marked as skipped with reason 'dynamic'. In a static export, ++* a request that fails before any response arrives fails the export instead. + */ + async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, rscBundlePath, serverDir = path.dirname(rscBundlePath), buildOutDir = path.dirname(serverDir), ...options }) { + const manifestDir = options.manifestDir ?? outDir; +@@ -724,6 +770,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + const onProgress = options.onProgress; + const skipManifest = options.skipManifest ?? false; + const results = []; ++ const exportRequestFailures = createExportRequestFailures(mode); + fs.mkdirSync(outDir, { recursive: true }); + const previousHandler = getCacheHandler(); + setCacheHandler(new NoOpCacheHandler()); +@@ -756,7 +803,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + rscHandler = (req) => { + const parsed = new URL(req.url); + const url = `http://127.0.0.1:${renderPorts[renderRoundRobin++ % renderPorts.length]}${parsed.pathname}${parsed.search}`; +- return fetch(url, { ++ return fetchPrerenderServer(url, { + method: req.method, + headers: { + ...secretHeaders, +@@ -777,7 +824,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + const search = new URLSearchParams({ pattern }); + if (Object.keys(params).length > 0) search.set("parentParams", JSON.stringify(params)); + if (mode === "export") search.set("rejectEmptyResults", "1"); +- const res = await fetch(`${baseUrl}/__vinext/prerender/static-params?${search}`, { headers: secretHeaders }); ++ const res = await fetchPrerenderServer(`${baseUrl}/__vinext/prerender/static-params?${search}`, { headers: secretHeaders }); + const text = await res.text(); + if (!res.ok) { + if (res.status === 500) throw new PrerenderUserFunctionError(parsePrerenderEndpointError(text)); +@@ -925,6 +972,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + } + } + } catch (e) { ++ exportRequestFailures.record(e, route.pattern); + const err = e; + const detail = config.enablePrerenderSourceMaps ? getErrorMessageWithStack(err) : err.message; + results.push({ +@@ -1153,6 +1201,11 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + }; + } catch (e) { + renderPool?.recordRenderError(e); ++ if (exportRequestFailures.record(e, routePattern, urlPath)) return { ++ route: routePattern, ++ status: "error", ++ error: e.message ++ }; + if (isSpeculative) return { + route: routePattern, + status: "skipped", +@@ -1186,6 +1239,7 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + return result; + }); + results.push(...appResults); ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + const outputFiles = mode === "export" && metadataRoutes.length > 0 ? emitStaticMetadataFiles(metadataRoutes, outDir, config.basePath) : []; + try { +@@ -1204,7 +1258,9 @@ async function prerenderApp({ routes, metadataRoutes = [], outDir, config, mode, + } + } catch (e) { + renderPool?.recordRenderError(e); ++ exportRequestFailures.record(e, "/404"); + } ++ exportRequestFailures.throwIfAny(); + renderPool?.assertHealthy(); + if (!skipManifest) writePrerenderIndex(results, manifestDir, { + buildId: config.buildId, +diff --git a/dist/server/prod-server.js b/dist/server/prod-server.js +index 0220df4e8ed9df1a4f7d1a15b907d3153a0eccf4..e9887839e789518813a6b53f969051933b22bc47 100644 +--- a/dist/server/prod-server.js ++++ b/dist/server/prod-server.js +@@ -387,6 +387,17 @@ function logProdServerStarted(host, port, purpose) { + console.log(`[vinext] Production server running at ${url}`); + } + /** ++* Build-time prerender servers serve one request per connection. The build's ++* fetch() pools idle keep-alive connections, and a render that blocks the event ++* loop past `keepAliveTimeout` lets Node close an idle socket the client has ++* already written its next request to, so that request fails with ECONNRESET. ++* With `maxRequestsPerSocket = 1` every response carries `Connection: close`, ++* so no idle connection is ever reused. ++*/ ++function applyPrerenderConnectionPolicy(server, purpose) { ++ if (purpose === "prerender") server.maxRequestsPerSocket = 1; ++} ++/** + * Merge middleware/config headers and an optional status override into a new + * Web Response while preserving the original body stream when allowed. + * +@@ -1282,6 +1293,7 @@ async function startAppRouterServer(options) { + const server = createServer((req, res) => { + runWithServerEntryRequire(rscEntryRequire, () => handleRequest(req, res)); + }); ++ applyPrerenderConnectionPolicy(server, purpose); + await new Promise((resolve) => { + server.listen(port, host, () => { + const addr = server.address(); +@@ -1323,6 +1335,7 @@ async function startPagesRouterServer(options) { + const { port, host, purpose, silent } = options; + const handler = await createPagesRouterRequestHandler(options); + const server = createServer(handler); ++ applyPrerenderConnectionPolicy(server, purpose); + await new Promise((resolve) => { + server.listen(port, host, () => { + const addr = server.address();