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();