diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 918e83cbc..22c484006 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -1019,6 +1019,10 @@ export async function prerenderApp({ // (devWorker.fetch) so the ALS context set up here on the Node side never // reaches the worker isolate. The wrapping is a no-op for the CF path but // harmless — and it keeps renderUrl() shape-compatible across both modes. + // The prod-server is in prerender mode (VINEXT_PRERENDER=1) so HTML + // responses for App Router carry the raw RSC bytes appended to the + // body, advertised via `x-vinext-rsc-byte-length`. Mirrors Next.js's + // `metadata.flightData` side-channel: one render emits both halves. const htmlRequest = new Request(`http://localhost${urlPath}`); const htmlRender = await runWithHeadersContext( headersContextFromRequest(htmlRequest), @@ -1030,16 +1034,38 @@ export async function prerenderApp({ return { cacheControl, html: null, + rsc: null, ok: response.ok, requestCacheLife: null, status: response.status, }; } - const html = await response.text(); + const rscByteLengthHeader = response.headers.get("x-vinext-rsc-byte-length"); + if (!rscByteLengthHeader) { + throw new Error( + "[vinext] Prerender HTML response missing x-vinext-rsc-byte-length header", + ); + } + const rscByteLength = Number(rscByteLengthHeader); + if (!Number.isFinite(rscByteLength) || rscByteLength < 0) { + throw new Error( + `[vinext] Invalid x-vinext-rsc-byte-length header value: ${rscByteLengthHeader}`, + ); + } + + const buffer = new Uint8Array(await response.arrayBuffer()); + if (rscByteLength > buffer.byteLength) { + throw new Error( + `[vinext] x-vinext-rsc-byte-length (${rscByteLength}) exceeds response body length (${buffer.byteLength})`, + ); + } + const splitAt = buffer.byteLength - rscByteLength; + const decoder = new TextDecoder(); return { cacheControl, - html, + html: decoder.decode(buffer.subarray(0, splitAt)), + rsc: decoder.decode(buffer.subarray(splitAt)), ok: true, requestCacheLife: _consumeRequestScopedCacheLife(), status: response.status, @@ -1076,16 +1102,7 @@ export async function prerenderApp({ }; } const html = htmlRender.html; - - // Fetch RSC payload via a second invocation with RSC headers - // TODO: Extract RSC payload from the first response instead of invoking the handler twice. - const rscRequest = new Request(`http://localhost${urlPath}`, { - headers: { Accept: "text/x-component", RSC: "1" }, - }); - const rscRes = await runWithHeadersContext(headersContextFromRequest(rscRequest), () => - rscHandler(rscRequest), - ); - const rscData = rscRes.ok ? await rscRes.text() : null; + const rscData = htmlRender.rsc; const outputFiles: string[] = []; diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 7eab8ab5c..f81e7b79e 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -250,6 +250,49 @@ function wrapRscResponseForDevErrorReporting( }); } +/** + * Concatenate the captured raw RSC bytes onto the end of the HTML response + * body and advertise their byte length via `x-vinext-rsc-byte-length`. The + * prerender driver reads the body as one buffer and splits at + * `total - x-vinext-rsc-byte-length`. This avoids a second handler invocation + * per route — the same render produces both halves. + * + * Only invoked when the prod-server is in prerender mode (VINEXT_PRERENDER=1) + * and the request is for HTML (not an RSC request). Production runtime is + * unaffected because `isPrerender` is false there. + */ +async function appendCapturedRscToHtmlResponse( + response: Response, + capturedRscDataPromise: Promise, +): Promise { + if (!response.body) return response; + + // Buffer the HTML body into memory to keep the protocol simple: a single + // body buffer of [HTML][RSC] with the RSC length declared as a header. + // Prerender HTML is already buffered downstream by the driver (it calls + // `response.text()`), so we don't lose any streaming benefit here. + const [htmlBuffer, rscBuffer] = await Promise.all([ + response.arrayBuffer(), + capturedRscDataPromise, + ]); + + const combined = new Uint8Array(htmlBuffer.byteLength + rscBuffer.byteLength); + combined.set(new Uint8Array(htmlBuffer), 0); + combined.set(new Uint8Array(rscBuffer), htmlBuffer.byteLength); + + const headers = new Headers(response.headers); + headers.set("x-vinext-rsc-byte-length", String(rscBuffer.byteLength)); + // The combined body's content-length must reflect the real buffer size or + // any downstream `Content-Length` validation will reject the response. + headers.set("content-length", String(combined.byteLength)); + + return new Response(combined, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + export async function renderAppPageLifecycle( options: RenderAppPageLifecycleOptions, ): Promise { @@ -306,7 +349,16 @@ export async function renderAppPageLifecycle( (options.isProduction || options.isPrerender === true) && (revalidateSeconds === null || (revalidateSeconds > 0 && revalidateSeconds !== Infinity)) && !options.isForceDynamic; - const rscCapture = teeAppPageRscStreamForCapture(rscStream, shouldCaptureRscForCacheMetadata); + // In prerender mode the side-channel always carries the captured RSC bytes + // back to the prerender driver, so the tee must run unconditionally for any + // route that could plausibly emit a `.rsc` (force-dynamic and revalidate=0 + // routes are filtered out by the driver before this handler runs, but we + // re-check defensively). This covers `revalidate: Infinity` static-forever + // routes which the cache-metadata predicate excludes. + const shouldCaptureRscForPrerender = + options.isPrerender === true && !options.isForceDynamic && revalidateSeconds !== 0; + const shouldCaptureRsc = shouldCaptureRscForCacheMetadata || shouldCaptureRscForPrerender; + const rscCapture = teeAppPageRscStreamForCapture(rscStream, shouldCaptureRsc); const rscForResponse = rscCapture.ssrStream; // When the fused tee (#981) is active, the sideStream carries both the embed @@ -522,6 +574,30 @@ export async function renderAppPageLifecycle( !options.scriptNonce && !dynamicUsedDuringRender; + // Prerender mode handles HTML responses uniformly, regardless of whether + // the route would write to the runtime ISR cache (force-static / null + // revalidate / positive revalidate / Infinity all land here). We append the + // captured raw RSC bytes so the driver recovers both halves from a single + // render. Runtime cache writeback (finalizeAppPageHtmlCacheResponse) does + // not apply during prerender — the prerender driver writes its own files. + if (options.isPrerender === true) { + const prerenderResponse = buildAppPageHtmlResponse(safeHtmlStream, { + draftCookie, + fontLinkHeader, + middlewareContext: options.middlewareContext, + policy: htmlResponsePolicy, + timing: htmlResponseTiming, + }); + // The capture is mandatory: force-dynamic and revalidate=0 routes are + // skipped by the driver before invocation, and shouldCaptureRscForPrerender + // covers all remaining cases. A null value is an invariant violation — + // surface it loudly rather than silently double-rendering. + if (!capturedRscDataRef.value) { + throw new Error("[vinext] Invariant: prerender HTML render produced no captured RSC bytes"); + } + return await appendCapturedRscToHtmlResponse(prerenderResponse, capturedRscDataRef.value); + } + if (htmlResponsePolicy.shouldWriteToCache || shouldSpeculativelyWriteCache) { const isrResponse = buildAppPageHtmlResponse(safeHtmlStream, { draftCookie, @@ -531,10 +607,6 @@ export async function renderAppPageLifecycle( timing: htmlResponseTiming, }); - if (options.isPrerender === true) { - return isrResponse; - } - return finalizeAppPageHtmlCacheResponse(isrResponse, { capturedRscDataPromise: capturedRscDataRef.value, cleanPathname: options.cleanPathname, diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index afa37064c..9e3f700b0 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -45,6 +45,29 @@ function createDeferred() { return { promise, reject, resolve }; } +/** + * Read a prerender HTML response and split the body into its HTML and RSC + * halves using the `x-vinext-rsc-byte-length` header. The prerender driver + * does the same — every render emits a single buffer of [HTML][raw RSC bytes]. + */ +async function readPrerenderBundle(response: Response): Promise<{ + html: string; + rsc: string; +}> { + const rscByteLengthHeader = response.headers.get("x-vinext-rsc-byte-length"); + if (rscByteLengthHeader === null) { + throw new Error("Expected x-vinext-rsc-byte-length header on prerender response"); + } + const rscByteLength = Number(rscByteLengthHeader); + const buffer = new Uint8Array(await response.arrayBuffer()); + const splitAt = buffer.byteLength - rscByteLength; + const decoder = new TextDecoder(); + return { + html: decoder.decode(buffer.subarray(0, splitAt)), + rsc: decoder.decode(buffer.subarray(splitAt)), + }; +} + function createCommonOptions() { const waitUntilPromises: Promise[] = []; const renderToReadableStream = vi.fn(() => createStream(["flight-data"])); @@ -530,7 +553,9 @@ describe("app page render lifecycle", () => { expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2"); expect(response.headers.get("x-vinext-cache")).toBeNull(); - await expect(response.text()).resolves.toBe("page"); + const { html, rsc } = await readPrerenderBundle(response); + expect(html).toBe("page"); + expect(rsc).toBe("flight-data"); expect(common.waitUntilPromises).toHaveLength(0); expect(common.isrSet).not.toHaveBeenCalled(); }); @@ -566,7 +591,9 @@ describe("app page render lifecycle", () => { }); expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2"); - await expect(response.text()).resolves.toBe("page"); + const { html, rsc } = await readPrerenderBundle(response); + expect(html).toBe("page"); + expect(rsc).toBe("flight-data"); expect(common.isrSet).not.toHaveBeenCalled(); }); @@ -605,7 +632,9 @@ describe("app page render lifecycle", () => { }); expect(response.headers.get("cache-control")).toBe("s-maxage=1"); - await expect(response.text()).resolves.toBe("page"); + const { html, rsc } = await readPrerenderBundle(response); + expect(html).toBe("page"); + expect(rsc).toBe("flight-data"); expect(consumeRequestCacheLife()).toEqual({ revalidate: 1, expire: 1 }); }); @@ -641,7 +670,9 @@ describe("app page render lifecycle", () => { expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2"); expect(response.headers.get("x-vinext-cache")).toBe("MISS"); - await expect(response.text()).resolves.toBe("page"); + const { html, rsc } = await readPrerenderBundle(response); + expect(html).toBe("page"); + expect(rsc).toBe("flight-data"); expect(common.waitUntilPromises).toHaveLength(0); expect(common.isrSet).not.toHaveBeenCalled(); });