Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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[] = [];

Expand Down
82 changes: 77 additions & 5 deletions packages/vinext/src/server/app-page-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>,
): Promise<Response> {
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<Response> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
39 changes: 35 additions & 4 deletions tests/app-page-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,29 @@ function createDeferred<T = void>() {
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<void>[] = [];
const renderToReadableStream = vi.fn(() => createStream(["flight-data"]));
Expand Down Expand Up @@ -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("<html>page</html>");
const { html, rsc } = await readPrerenderBundle(response);
expect(html).toBe("<html>page</html>");
expect(rsc).toBe("flight-data");
expect(common.waitUntilPromises).toHaveLength(0);
expect(common.isrSet).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -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("<html>page</html>");
const { html, rsc } = await readPrerenderBundle(response);
expect(html).toBe("<html>page</html>");
expect(rsc).toBe("flight-data");
expect(common.isrSet).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -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("<html>page</html>");
const { html, rsc } = await readPrerenderBundle(response);
expect(html).toBe("<html>page</html>");
expect(rsc).toBe("flight-data");
expect(consumeRequestCacheLife()).toEqual({ revalidate: 1, expire: 1 });
});

Expand Down Expand Up @@ -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("<html>page</html>");
const { html, rsc } = await readPrerenderBundle(response);
expect(html).toBe("<html>page</html>");
expect(rsc).toBe("flight-data");
expect(common.waitUntilPromises).toHaveLength(0);
expect(common.isrSet).not.toHaveBeenCalled();
});
Expand Down
Loading