diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 9e95e33cb..d79f73ddd 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -178,6 +178,88 @@ const NOT_FOUND_SENTINEL_PATH = "/__vinext_nonexistent_for_404__"; const DEFAULT_CONCURRENCY = Math.min(os.availableParallelism(), 8); +const RSC_CHUNK_SCRIPT_PREFIX = "self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];"; +const RSC_DONE_MARKER = "__VINEXT_RSC_DONE__=true"; +// Full literal that createRscEmbedTransform concatenates before the +// safeJsonStringify(chunk) argument. Keep this in sync with the writer at +// packages/vinext/src/server/app-ssr-stream.ts:73. +const RSC_CHUNK_FULL_PREFIX = `${RSC_CHUNK_SCRIPT_PREFIX}self.__VINEXT_RSC_CHUNKS__.push(`; + +/** + * Reconstruct the RSC payload from a prerender HTML response by parsing the + * inline bootstrap chunk scripts emitted by createRscEmbedTransform. + * + * Returns null when the HTML contains no chunk scripts at all — the caller + * should fall back to a second handler invocation. This is reachable when + * middleware short-circuits the App Router pipeline with a custom 200 HTML + * response that never went through createRscEmbedTransform. + * + * Throws on partial or malformed embeds (chunks present but no done marker, + * tampered chunk JSON, etc.) — those are real vinext-internal regressions. + * + * Safe regex usage: safeJsonStringify (used by createRscEmbedTransform) escapes + * all '<' and '>' in the embedded JSON, preventing false matches. + */ +export function extractRscPayloadFromPrerenderedHtml(html: string): string | null { + const scriptPattern = /]*)?>([\s\S]*?)<\/script>/gi; + const chunks: string[] = []; + let sawDone = false; + let match: RegExpExecArray | null; + + while ((match = scriptPattern.exec(html)) !== null) { + const script = (match[1] ?? "").trim().replace(/;$/, ""); + + if (script === `self.${RSC_DONE_MARKER}`) { + sawDone = true; + continue; + } + + if (script.startsWith(RSC_CHUNK_SCRIPT_PREFIX)) { + chunks.push(parseRscChunkPushArgument(script)); + } + } + + // No chunks AND no done marker → middleware/early-return path. Caller falls + // back to a second invocation with `RSC: 1`. + if (chunks.length === 0 && !sawDone) { + return null; + } + if (chunks.length === 0) { + throw new Error( + "[vinext] Malformed prerender RSC embed: done marker present without chunk scripts", + ); + } + if (!sawDone) { + throw new Error("[vinext] Malformed prerender RSC embed: missing __VINEXT_RSC_DONE__ marker"); + } + + return chunks.join(""); +} + +/** + * Parse the JSON-string argument of a single chunk-push script. The script + * shape is exactly `()` because the writer + * concatenates those literals — so the body always starts with the full + * prefix and ends with `)`. JSON.parse on the slice catches any tampering or + * trailing code. + */ +function parseRscChunkPushArgument(script: string): string { + if (!script.startsWith(RSC_CHUNK_FULL_PREFIX) || !script.endsWith(")")) { + throw new Error("[vinext] Malformed prerender RSC embed: unexpected chunk script shape"); + } + const jsonSource = script.slice(RSC_CHUNK_FULL_PREFIX.length, -1); + let parsed: unknown; + try { + parsed = JSON.parse(jsonSource); + } catch { + throw new Error("[vinext] Malformed prerender RSC embed: invalid chunk JSON"); + } + if (typeof parsed !== "string") { + throw new Error("[vinext] Malformed prerender RSC embed: chunk payload is not a string"); + } + return parsed; +} + /** * Run an array of async tasks with bounded concurrency. * Results are returned in the same order as `items`. @@ -1075,15 +1157,32 @@ 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; + // Reconstruct the RSC payload from the inline bootstrap chunks already + // streamed into the HTML body. The chunks went through fixFlightHints + // (createRscEmbedTransform applies it before pushing each chunk into + // the embed scripts), so the resulting `.rsc` file contains the + // rewritten Flight form rather than raw Flight bytes. + // + // Falls back to a second invocation with `RSC: 1` when the HTML has + // no chunk scripts at all — covers cases where middleware + // short-circuits the App Router pipeline with a custom 200 HTML + // response that never went through createRscEmbedTransform. + let rscData = extractRscPayloadFromPrerenderedHtml(html); + if (rscData === null) { + const rscRequest = new Request(`http://localhost${urlPath}`, { + headers: { Accept: "text/x-component", RSC: "1" }, + }); + const rscRes = await runWithHeadersContext(headersContextFromRequest(rscRequest), () => + rscHandler(rscRequest), + ); + if (!rscRes.ok) { + await rscRes.body?.cancel(); + throw new Error( + `[vinext] prerenderApp: RSC fallback returned ${rscRes.status} for ${urlPath}`, + ); + } + rscData = await rscRes.text(); + } const outputFiles: string[] = []; @@ -1095,13 +1194,11 @@ export async function prerenderApp({ outputFiles.push(htmlOutputPath); // Write RSC payload (.rsc file) - if (rscData !== null) { - const rscOutputPath = getRscOutputPath(urlPath); - const rscFullPath = path.join(outDir, rscOutputPath); - fs.mkdirSync(path.dirname(rscFullPath), { recursive: true }); - fs.writeFileSync(rscFullPath, rscData, "utf-8"); - outputFiles.push(rscOutputPath); - } + const rscOutputPath = getRscOutputPath(urlPath); + const rscFullPath = path.join(outDir, rscOutputPath); + fs.mkdirSync(path.dirname(rscFullPath), { recursive: true }); + fs.writeFileSync(rscFullPath, rscData, "utf-8"); + outputFiles.push(rscOutputPath); const renderedCacheControl = resolveRenderedCacheControl( htmlRender.requestCacheLife ?? {}, diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index f9f003000..565c68d38 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -158,6 +158,7 @@ describe("AppElementsWire", () => { if (!isAppElementsRecord(payload)) return; expect(AppElementsWire.readMetadata(payload)).toEqual({ + artifactCompatibility: createArtifactCompatibilityEnvelope(), interceptionContext: null, layoutFlags: { [AppElementsWire.encodeLayoutId("/")]: "s" }, rootLayoutTreePath: "/", diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index c79bb1f72..7baca65a2 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -10,14 +10,17 @@ */ import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; import fs from "node:fs"; +import { createServer, type Server } from "node:http"; import os from "node:os"; import path from "node:path"; import { buildPagesFixture, buildAppFixture, buildCloudflareAppFixture } from "./helpers.js"; import { + extractRscPayloadFromPrerenderedHtml, resolveParentParams, type PrerenderRouteResult, type StaticParamsMap, } from "../packages/vinext/src/build/prerender.js"; +import { safeJsonStringify } from "../packages/vinext/src/server/html.js"; import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic"); @@ -37,6 +40,329 @@ function findRoute( return results.find((r) => r.route === route || ("path" in r && r.path === route)); } +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + resolve(address.port); + } else { + reject(new Error("test server did not expose a TCP port")); + } + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +// ─── App Router RSC payload extraction ─────────────────────────────────────── + +describe("extractRscPayloadFromPrerenderedHtml", () => { + it("reconstructs streamed RSC chunks from inline bootstrap scripts", () => { + const chunks = [ + '0:D{"name":"layout"}\n', + '1:["$","div",null,{"children":"hello ) world"}]\n', + '2:["$","span",null,{"children":""}]\n', + ]; + const html = + "" + + chunks + .map( + (chunk) => + "`, + ) + .join("") + + "" + + ""; + + expect(extractRscPayloadFromPrerenderedHtml(html)).toBe(chunks.join("")); + }); + + it("throws when the done marker is missing", () => { + const html = + "" + + `` + + ""; + + expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(/missing __VINEXT_RSC_DONE__/); + }); + + it("does not treat marker-looking RSC payload text as the done control script", () => { + const html = + "" + + `` + + ""; + + expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow(/missing __VINEXT_RSC_DONE__/); + }); + + it("rejects chunk scripts with trailing code after the payload push", () => { + const html = + "" + + `` + + "" + + ""; + + // JSON.parse rejects the slice (which includes the `)` and `alert(1` after + // the JSON-encoded string), so this is reported as malformed JSON rather + // than a separate "trailing code" diagnostic. + expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow( + "[vinext] Malformed prerender RSC embed: invalid chunk JSON", + ); + }); + + it("rejects chunk scripts with invalid JSON", () => { + const html = + "" + + '' + + "" + + ""; + + expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow( + "[vinext] Malformed prerender RSC embed: invalid chunk JSON", + ); + }); + + it("returns null when no chunk scripts and no done marker are present (middleware short-circuit)", () => { + // Middleware that returns a custom 200 HTML body bypasses the App Router + // pipeline entirely — no chunks, no done marker. The driver detects this + // null and falls back to a second invocation with `RSC: 1`. + expect(extractRscPayloadFromPrerenderedHtml("legacy")).toBeNull(); + }); + + it("throws when only the done marker is present without any chunks", () => { + // Half-emitted embed (done marker but no chunks) is a real bug — partial + // emission shouldn't fall back silently. + const html = ""; + + expect(() => extractRscPayloadFromPrerenderedHtml(html)).toThrow( + "[vinext] Malformed prerender RSC embed: done marker present without chunk scripts", + ); + }); +}); + +describe("prerenderApp — RSC extraction", () => { + it("writes the .rsc file from rendered HTML without a second RSC request", async () => { + const root = tmpDir("vinext-prerender-rsc-dedupe-"); + const outDir = path.join(root, "out"); + const appDir = path.join(root, "app"); + const pagePath = path.join(appDir, "page.tsx"); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + pagePath, + "export const dynamic = 'force-static';\nexport default function Page() { return null; }\n", + ); + + const rscPayload = '0:["$","div",null,{"children":"from html"}]\n'; + let rscRequestCount = 0; + const server = createServer((req, res) => { + if (req.headers.rsc === "1" || req.headers.accept === "text/x-component") { + rscRequestCount++; + res.statusCode = 500; + res.end("unexpected RSC request"); + return; + } + + if (req.url === "/__vinext_nonexistent_for_404__") { + res.statusCode = 404; + res.end("not found"); + return; + } + + res.setHeader("content-type", "text/html"); + res.end( + "" + + `` + + "" + + "", + ); + }); + + const port = await listen(server); + try { + const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js"); + const { appRouter } = await import("../packages/vinext/src/routing/app-router.js"); + const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); + const routes = await appRouter(appDir); + const config = await resolveNextConfig({}); + + const prerenderResult = await prerenderApp({ + mode: "default", + rscBundlePath: path.join(root, "dist", "server", "index.js"), + routes, + outDir, + config, + _prodServer: { server, port }, + }); + + expect(findRoute(prerenderResult.routes, "/")).toMatchObject({ + route: "/", + status: "rendered", + }); + expect(fs.readFileSync(path.join(outDir, "index.rsc"), "utf-8")).toBe(rscPayload); + expect(rscRequestCount).toBe(0); + } finally { + await closeServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("falls back to a second RSC: 1 invocation when middleware short-circuits with custom HTML", async () => { + // Middleware that returns a 200 HTML body bypasses the App Router + // pipeline — the response contains no embed chunks. The driver must + // recover by issuing a second invocation with `RSC: 1` and use whatever + // that returns as the .rsc file. + const root = tmpDir("vinext-prerender-rsc-fallback-"); + const outDir = path.join(root, "out"); + const appDir = path.join(root, "app"); + const pagePath = path.join(appDir, "page.tsx"); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + pagePath, + "export const dynamic = 'force-static';\nexport default function Page() { return null; }\n", + ); + + const middlewareHtml = "middleware short-circuit"; + const fallbackRscPayload = '0:["$","div",null,{"children":"from fallback"}]\n'; + let pageRequestCount = 0; + let rscRequestCount = 0; + const server = createServer((req, res) => { + const isRsc = req.headers.rsc === "1" || req.headers.accept === "text/x-component"; + + if (req.url === "/__vinext_nonexistent_for_404__") { + res.statusCode = 404; + res.end("not found"); + return; + } + + if (isRsc) { + rscRequestCount++; + res.setHeader("content-type", "text/x-component"); + res.end(fallbackRscPayload); + return; + } + + // Page request: middleware short-circuits with plain HTML and no + // RSC embed chunks — exercising the fallback path. + pageRequestCount++; + res.setHeader("content-type", "text/html"); + res.end(middlewareHtml); + }); + + const port = await listen(server); + try { + const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js"); + const { appRouter } = await import("../packages/vinext/src/routing/app-router.js"); + const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); + const routes = await appRouter(appDir); + const config = await resolveNextConfig({}); + + const prerenderResult = await prerenderApp({ + mode: "default", + rscBundlePath: path.join(root, "dist", "server", "index.js"), + routes, + outDir, + config, + _prodServer: { server, port }, + }); + + expect(findRoute(prerenderResult.routes, "/")).toMatchObject({ + route: "/", + status: "rendered", + }); + + // HTML on disk is the middleware response. + expect(fs.readFileSync(path.join(outDir, "index.html"), "utf-8")).toBe(middlewareHtml); + // .rsc on disk is the fallback RSC: 1 response. + expect(fs.readFileSync(path.join(outDir, "index.rsc"), "utf-8")).toBe(fallbackRscPayload); + + // Exactly one page request and one RSC fallback request per route. + expect(pageRequestCount).toBe(1); + expect(rscRequestCount).toBe(1); + } finally { + await closeServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("errors without writing .rsc when the middleware short-circuit fallback RSC request fails", async () => { + const root = tmpDir("vinext-prerender-rsc-fallback-failure-"); + const outDir = path.join(root, "out"); + const appDir = path.join(root, "app"); + const pagePath = path.join(appDir, "page.tsx"); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync( + pagePath, + "export const dynamic = 'force-static';\nexport default function Page() { return null; }\n", + ); + + const middlewareHtml = "middleware short-circuit"; + let pageRequestCount = 0; + let rscRequestCount = 0; + const server = createServer((req, res) => { + const isRsc = req.headers.rsc === "1" || req.headers.accept === "text/x-component"; + + if (req.url === "/__vinext_nonexistent_for_404__") { + res.statusCode = 404; + res.end("not found"); + return; + } + + if (isRsc) { + rscRequestCount++; + res.statusCode = 500; + res.end("fallback failed"); + return; + } + + pageRequestCount++; + res.setHeader("content-type", "text/html"); + res.end(middlewareHtml); + }); + + const port = await listen(server); + try { + const { prerenderApp } = await import("../packages/vinext/src/build/prerender.js"); + const { appRouter } = await import("../packages/vinext/src/routing/app-router.js"); + const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); + const routes = await appRouter(appDir); + const config = await resolveNextConfig({}); + + const prerenderResult = await prerenderApp({ + mode: "default", + rscBundlePath: path.join(root, "dist", "server", "index.js"), + routes, + outDir, + config, + _prodServer: { server, port }, + }); + + const route = findRoute(prerenderResult.routes, "/"); + expect(route).toMatchObject({ + route: "/", + status: "error", + }); + if (route?.status !== "error") throw new Error("expected route to fail prerender"); + expect(route.error).toContain("[vinext] prerenderApp: RSC fallback returned 500 for /"); + expect(fs.existsSync(path.join(outDir, "index.rsc"))).toBe(false); + expect(pageRequestCount).toBe(1); + expect(rscRequestCount).toBe(1); + } finally { + await closeServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + // ─── Pages Router ───────────────────────────────────────────────────────────── describe("prerenderPages — default mode (pages-basic)", () => {