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 = /"}]\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)", () => {