From 8d8ceba9cfa7f518450fd4e7b9bedb7694f78894 Mon Sep 17 00:00:00 2001
From: Jared Stowell
Date: Tue, 10 Mar 2026 23:18:54 -0500
Subject: [PATCH 1/5] Fix Pages API parsing parity
---
.../vinext/src/entries/pages-server-entry.ts | 22 ++-
packages/vinext/src/server/api-handler.ts | 44 ++++--
packages/vinext/src/server/prod-server.ts | 27 +++-
.../entry-templates.test.ts.snap | 28 +++-
tests/api-handler.test.ts | 60 +++++++-
tests/fixtures/pages-basic/pages/api/parse.ts | 5 +
tests/pages-router.test.ts | 140 +++++++++++++++++-
7 files changed, 294 insertions(+), 32 deletions(-)
create mode 100644 tests/fixtures/pages-basic/pages/api/parse.ts
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index d41a5e80..74562090 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -262,6 +262,7 @@ import { safeJsonStringify } from "vinext/html";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
import { parseCookies } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))};
+import { decode as decodeQueryString } from "node:querystring";
import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(_requestContextShimPath)};
${instrumentationImportCode}
${middlewareImportCode}
@@ -324,6 +325,16 @@ function isrCacheKey(router, pathname) {
return prefix + ":__hash:" + fnv1a64(normalized);
}
+function getMediaType(contentType) {
+ var type = (contentType || "text/plain").split(";")[0];
+ type = type && type.trim().toLowerCase();
+ return type || "text/plain";
+}
+
+function isJsonMediaType(mediaType) {
+ return mediaType === "application/json" || mediaType === "application/ld+json";
+}
+
async function renderToStringAsync(element) {
const stream = await renderToReadableStream(element);
await stream.allReady;
@@ -1039,14 +1050,17 @@ export async function handleApiRoute(request, url) {
return new Response("Request body too large", { status: 413 });
}
let body;
- const ct = request.headers.get("content-type") || "";
+ const mediaType = getMediaType(request.headers.get("content-type"));
let rawBody;
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
- body = undefined;
- } else if (ct.includes("application/json")) {
- try { body = JSON.parse(rawBody); } catch { body = rawBody; }
+ body = isJsonMediaType(mediaType) ? {} : undefined;
+ } else if (isJsonMediaType(mediaType)) {
+ try { body = JSON.parse(rawBody); }
+ catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
+ } else if (mediaType === "application/x-www-form-urlencoded") {
+ body = decodeQueryString(rawBody);
} else {
body = rawBody;
}
diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts
index 59542f28..8fa5c475 100644
--- a/packages/vinext/src/server/api-handler.ts
+++ b/packages/vinext/src/server/api-handler.ts
@@ -9,6 +9,7 @@
*/
import type { ViteDevServer } from "vite";
import type { IncomingMessage, ServerResponse } from "node:http";
+import { decode as decodeQueryString } from "node:querystring";
import { type Route, matchRoute } from "../routing/pages-router.js";
import { reportRequestError } from "./instrumentation.js";
import { addQueryParam } from "../utils/query.js";
@@ -39,6 +40,25 @@ interface NextApiResponse extends ServerResponse {
*/
const MAX_BODY_SIZE = 1 * 1024 * 1024;
+class ApiBodyParseError extends Error {
+ constructor(
+ message: string,
+ readonly statusCode: number,
+ ) {
+ super(message);
+ this.name = "ApiBodyParseError";
+ }
+}
+
+function getMediaType(contentType: string | undefined): string {
+ const [type] = (contentType ?? "text/plain").split(";");
+ return type?.trim().toLowerCase() || "text/plain";
+}
+
+function isJsonMediaType(mediaType: string): boolean {
+ return mediaType === "application/json" || mediaType === "application/ld+json";
+}
+
/**
* Parse the request body based on content-type.
* Enforces a size limit to prevent memory exhaustion attacks.
@@ -68,24 +88,19 @@ async function parseBody(req: IncomingMessage): Promise {
if (settled) return;
settled = true;
const raw = Buffer.concat(chunks).toString("utf-8");
+ const mediaType = getMediaType(req.headers["content-type"]);
if (!raw) {
- resolve(undefined);
+ resolve(isJsonMediaType(mediaType) ? {} : undefined);
return;
}
- const contentType = req.headers["content-type"] ?? "";
- if (contentType.includes("application/json")) {
+ if (isJsonMediaType(mediaType)) {
try {
resolve(JSON.parse(raw));
} catch {
- resolve(raw);
- }
- } else if (contentType.includes("application/x-www-form-urlencoded")) {
- const params = new URLSearchParams(raw);
- const obj: Record = {};
- for (const [key, value] of params) {
- obj[key] = value;
+ reject(new ApiBodyParseError("Invalid JSON", 400));
}
- resolve(obj);
+ } else if (mediaType === "application/x-www-form-urlencoded") {
+ resolve(decodeQueryString(raw));
} else {
resolve(raw);
}
@@ -206,6 +221,13 @@ export async function handleApiRoute(
await handler(apiReq, apiRes);
return true;
} catch (e) {
+ if (e instanceof ApiBodyParseError) {
+ res.statusCode = e.statusCode;
+ res.statusMessage = e.message;
+ res.end(e.message);
+ return true;
+ }
+
server.ssrFixStacktrace(e as Error);
console.error(e);
reportRequestError(
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index b13dee14..65ba0318 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -166,11 +166,20 @@ function sendCompressed(
statusCode: number,
extraHeaders: Record = {},
compress: boolean = true,
+ statusText: string | undefined = undefined,
): void {
const buf = typeof body === "string" ? Buffer.from(body) : body;
const baseType = contentType.split(";")[0].trim();
const encoding = compress ? negotiateEncoding(req) : null;
+ const writeHead = (headers: Record) => {
+ if (statusText) {
+ res.writeHead(statusCode, statusText, headers);
+ } else {
+ res.writeHead(statusCode, headers);
+ }
+ };
+
if (encoding && COMPRESSIBLE_TYPES.has(baseType) && buf.length >= COMPRESS_THRESHOLD) {
const compressor = createCompressor(encoding);
// Merge Accept-Encoding into existing Vary header from extraHeaders instead
@@ -187,7 +196,7 @@ function sendCompressed(
} else {
varyValue = "Accept-Encoding";
}
- res.writeHead(statusCode, {
+ writeHead({
...extraHeaders,
"Content-Type": contentType,
"Content-Encoding": encoding,
@@ -198,7 +207,7 @@ function sendCompressed(
/* ignore pipeline errors on closed connections */
});
} else {
- res.writeHead(statusCode, {
+ writeHead({
...extraHeaders,
"Content-Type": contentType,
"Content-Length": String(buf.length),
@@ -393,6 +402,14 @@ async function sendWebResponse(
compress: boolean,
): Promise {
const status = webResponse.status;
+ const statusText = webResponse.statusText || undefined;
+ const writeHead = (headers: Record) => {
+ if (statusText) {
+ res.writeHead(status, statusText, headers);
+ } else {
+ res.writeHead(status, headers);
+ }
+ };
// Collect headers, handling multi-value headers (e.g. Set-Cookie)
const nodeHeaders: Record = {};
@@ -406,7 +423,7 @@ async function sendWebResponse(
});
if (!webResponse.body) {
- res.writeHead(status, nodeHeaders);
+ writeHead(nodeHeaders);
res.end();
return;
}
@@ -437,7 +454,7 @@ async function sendWebResponse(
}
}
- res.writeHead(status, nodeHeaders);
+ writeHead(nodeHeaders);
// HEAD requests: send headers only, skip the body
if (req.method === "HEAD") {
@@ -1014,6 +1031,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
middlewareRewriteStatus ?? response.status,
responseHeaders,
compress,
+ response.statusText || undefined,
);
return;
}
@@ -1074,6 +1092,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
middlewareRewriteStatus ?? response.status,
responseHeaders,
compress,
+ response.statusText || undefined,
);
} catch (e) {
console.error("[vinext] Server error:", e);
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index cb23dad7..cc525689 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -17518,6 +17518,7 @@ import { safeJsonStringify } from "vinext/html";
import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google";
import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local";
import { parseCookies } from "/packages/vinext/src/config/config-matchers.js";
+import { decode as decodeQueryString } from "node:querystring";
import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js";
import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts";
import * as middlewareModule from "/tests/fixtures/pages-basic/middleware.ts";
@@ -17591,6 +17592,16 @@ function isrCacheKey(router, pathname) {
return prefix + ":__hash:" + fnv1a64(normalized);
}
+function getMediaType(contentType) {
+ var type = (contentType || "text/plain").split(";")[0];
+ type = type && type.trim().toLowerCase();
+ return type || "text/plain";
+}
+
+function isJsonMediaType(mediaType) {
+ return mediaType === "application/json" || mediaType === "application/ld+json";
+}
+
async function renderToStringAsync(element) {
const stream = await renderToReadableStream(element);
await stream.allReady;
@@ -17635,7 +17646,8 @@ import * as api_2 from "/tests/fixtures/pages-basic/pages/api/hello.ts";
import * as api_3 from "/tests/fixtures/pages-basic/pages/api/instrumentation-test.ts";
import * as api_4 from "/tests/fixtures/pages-basic/pages/api/middleware-test.ts";
import * as api_5 from "/tests/fixtures/pages-basic/pages/api/no-content-type.ts";
-import * as api_6 from "/tests/fixtures/pages-basic/pages/api/users/[id].ts";
+import * as api_6 from "/tests/fixtures/pages-basic/pages/api/parse.ts";
+import * as api_7 from "/tests/fixtures/pages-basic/pages/api/users/[id].ts";
import { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx";
import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx";
@@ -17682,7 +17694,8 @@ const apiRoutes = [
{ pattern: "/api/instrumentation-test", patternParts: ["api","instrumentation-test"], isDynamic: false, params: [], module: api_3 },
{ pattern: "/api/middleware-test", patternParts: ["api","middleware-test"], isDynamic: false, params: [], module: api_4 },
{ pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_5 },
- { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_6 }
+ { pattern: "/api/parse", patternParts: ["api","parse"], isDynamic: false, params: [], module: api_6 },
+ { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_7 }
];
function matchRoute(url, routes) {
@@ -18380,14 +18393,17 @@ export async function handleApiRoute(request, url) {
return new Response("Request body too large", { status: 413 });
}
let body;
- const ct = request.headers.get("content-type") || "";
+ const mediaType = getMediaType(request.headers.get("content-type"));
let rawBody;
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
- body = undefined;
- } else if (ct.includes("application/json")) {
- try { body = JSON.parse(rawBody); } catch { body = rawBody; }
+ body = isJsonMediaType(mediaType) ? {} : undefined;
+ } else if (isJsonMediaType(mediaType)) {
+ try { body = JSON.parse(rawBody); }
+ catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
+ } else if (mediaType === "application/x-www-form-urlencoded") {
+ body = decodeQueryString(rawBody);
} else {
body = rawBody;
}
diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts
index 0e10ac49..14818c91 100644
--- a/tests/api-handler.test.ts
+++ b/tests/api-handler.test.ts
@@ -181,20 +181,42 @@ describe("handleApiRoute", () => {
expect(capturedBody).toEqual({ name: "Alice", age: 30 });
});
- it("falls back to raw string for malformed JSON", async () => {
+ // Ported from Next.js: test/integration/api-support/test/index.test.ts
+ // https://github.com/vercel/next.js/blob/canary/test/integration/api-support/test/index.test.ts
+ it("returns 400 for malformed JSON instead of calling the handler", async () => {
+ const handler = vi.fn();
+ const server = mockServer({ default: handler });
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const req = mockReq("POST", "/api/users", "{not json", {
+ "content-type": "application/json",
+ });
+ const res = mockRes();
+
+ await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]);
+
+ expect(handler).not.toHaveBeenCalled();
+ expect(res._statusCode).toBe(400);
+ expect(res.statusMessage).toBe("Invalid JSON");
+ expect(res._body).toBe("Invalid JSON");
+ expect(server.ssrFixStacktrace).not.toHaveBeenCalled();
+ expect(errorSpy).not.toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+
+ it("parses empty application/json bodies as an empty object", async () => {
let capturedBody: unknown;
const handler = vi.fn((req: any) => {
capturedBody = req.body;
});
const server = mockServer({ default: handler });
- const req = mockReq("POST", "/api/users", "{not json", {
+ const req = mockReq("POST", "/api/users", "", {
"content-type": "application/json",
});
const res = mockRes();
await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]);
- expect(capturedBody).toBe("{not json");
+ expect(capturedBody).toEqual({});
});
it("parses application/x-www-form-urlencoded body", async () => {
@@ -213,6 +235,38 @@ describe("handleApiRoute", () => {
expect(capturedBody).toEqual({ name: "Alice", role: "admin" });
});
+ it("preserves duplicate application/x-www-form-urlencoded keys as arrays", async () => {
+ let capturedBody: unknown;
+ const handler = vi.fn((req: any) => {
+ capturedBody = req.body;
+ });
+ const server = mockServer({ default: handler });
+ const req = mockReq("POST", "/api/users", "tag=a&tag=b&tag=c", {
+ "content-type": "application/x-www-form-urlencoded",
+ });
+ const res = mockRes();
+
+ await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]);
+
+ expect(capturedBody).toEqual({ tag: ["a", "b", "c"] });
+ });
+
+ it("parses application/ld+json bodies as JSON", async () => {
+ let capturedBody: unknown;
+ const handler = vi.fn((req: any) => {
+ capturedBody = req.body;
+ });
+ const server = mockServer({ default: handler });
+ const req = mockReq("POST", "/api/users", JSON.stringify({ title: "doc" }), {
+ "content-type": "application/ld+json; charset=utf-8",
+ });
+ const res = mockRes();
+
+ await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]);
+
+ expect(capturedBody).toEqual({ title: "doc" });
+ });
+
it("returns raw string for unknown content-type", async () => {
let capturedBody: unknown;
const handler = vi.fn((req: any) => {
diff --git a/tests/fixtures/pages-basic/pages/api/parse.ts b/tests/fixtures/pages-basic/pages/api/parse.ts
new file mode 100644
index 00000000..d1408b65
--- /dev/null
+++ b/tests/fixtures/pages-basic/pages/api/parse.ts
@@ -0,0 +1,5 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+export default function handler(req: NextApiRequest, res: NextApiResponse) {
+ res.status(200).json(req.body);
+}
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index c3949321..020bd4d9 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
+import { Readable } from "node:stream";
import { pathToFileURL } from "node:url";
import vinext from "../packages/vinext/src/index.js";
import { PAGES_FIXTURE_DIR, startFixtureServer } from "./helpers.js";
@@ -187,6 +188,53 @@ describe("Pages Router integration", () => {
expect(data).toEqual({ user: { id: "123", name: "User 123" } });
});
+ // Ported from Next.js: test/integration/api-support/test/index.test.ts
+ // https://github.com/vercel/next.js/blob/canary/test/integration/api-support/test/index.test.ts
+ it("returns 400 for invalid JSON bodies on Pages API routes", async () => {
+ const res = await fetch(`${baseUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `{"message":Invalid"}`,
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.statusText).toBe("Invalid JSON");
+ expect(await res.text()).toBe("Invalid JSON");
+ });
+
+ it("parses empty JSON bodies on Pages API routes as {}", async () => {
+ const res = await fetch(`${baseUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({});
+ });
+
+ it("preserves duplicate urlencoded body keys on Pages API routes", async () => {
+ const res = await fetch(`${baseUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: "tag=a&tag=b&tag=c",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
+ });
+
+ it("parses application/ld+json bodies on Pages API routes", async () => {
+ const res = await fetch(`${baseUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/ld+json; charset=utf-8" },
+ body: JSON.stringify({ title: "doc" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ title: "doc" });
+ });
+
it("returns 404 for non-existent API routes", async () => {
const res = await fetch(`${baseUrl}/api/nonexistent`);
expect(res.status).toBe(404);
@@ -1481,10 +1529,16 @@ export default function CounterPage() {
if (v) headers.set(k, Array.isArray(v) ? v.join(", ") : v);
}
const host = req.headers.host ?? "localhost";
- const webRequest = new Request(`http://${host}${url}`, {
- method: req.method,
+ const method = req.method ?? "GET";
+ const init: RequestInit & { duplex?: "half" } = {
+ method,
headers,
- });
+ };
+ if (method !== "GET" && method !== "HEAD") {
+ init.body = Readable.toWeb(req) as ReadableStream;
+ init.duplex = "half";
+ }
+ const webRequest = new Request(`http://${host}${url}`, init);
let response: Response;
if (pathname.startsWith("/api/") || pathname === "/api") {
@@ -1499,7 +1553,7 @@ export default function CounterPage() {
response.headers.forEach((v: string, k: string) => {
resHeaders[k] = v;
});
- res.writeHead(response.status, resHeaders);
+ res.writeHead(response.status, response.statusText || undefined, resHeaders);
res.end(body);
});
@@ -1534,6 +1588,39 @@ export default function CounterPage() {
const apiData = await apiRes.json();
expect(apiData).toEqual({ message: "Hello from API!" });
+ const invalidJsonRes = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `{"message":Invalid"}`,
+ });
+ expect(invalidJsonRes.status).toBe(400);
+ expect(invalidJsonRes.statusText).toBe("Invalid JSON");
+ expect(await invalidJsonRes.text()).toBe("Invalid JSON");
+
+ const duplicateFormRes = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: "tag=a&tag=b&tag=c",
+ });
+ expect(duplicateFormRes.status).toBe(200);
+ expect(await duplicateFormRes.json()).toEqual({ tag: ["a", "b", "c"] });
+
+ const emptyJsonRes = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "",
+ });
+ expect(emptyJsonRes.status).toBe(200);
+ expect(await emptyJsonRes.json()).toEqual({});
+
+ const ldJsonRes = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/ld+json; charset=utf-8" },
+ body: JSON.stringify({ title: "doc" }),
+ });
+ expect(ldJsonRes.status).toBe(200);
+ expect(await ldJsonRes.json()).toEqual({ title: "doc" });
+
// Test: 404 for unknown route
const notFoundRes = await fetch(`${prodUrl}/nonexistent`);
expect(notFoundRes.status).toBe(404);
@@ -1765,6 +1852,51 @@ describe("Production server middleware (Pages Router)", () => {
expect(res.headers.get("x-custom-middleware")).toBeNull();
});
+ it("preserves invalid JSON failures for Pages API routes in production", async () => {
+ const res = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `{"message":Invalid"}`,
+ });
+
+ expect(res.status).toBe(400);
+ expect(res.statusText).toBe("Invalid JSON");
+ expect(await res.text()).toBe("Invalid JSON");
+ });
+
+ it("preserves duplicate urlencoded body keys for Pages API routes in production", async () => {
+ const res = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: "tag=a&tag=b&tag=c",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
+ });
+
+ it("parses empty JSON bodies for Pages API routes in production as {}", async () => {
+ const res = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({});
+ });
+
+ it("parses application/ld+json bodies for Pages API routes in production", async () => {
+ const res = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/ld+json; charset=utf-8" },
+ body: JSON.stringify({ title: "doc" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ title: "doc" });
+ });
+
it("production object-form matcher requires has and missing conditions", async () => {
const noHeaderRes = await fetch(`${prodUrl}/mw-object-gated`);
expect(noHeaderRes.status).toBe(200);
From 414204151b5f287af036f6c2b74340030180f4e1 Mon Sep 17 00:00:00 2001
From: Jared Stowell
Date: Tue, 10 Mar 2026 23:38:19 -0500
Subject: [PATCH 2/5] Fix Pages API body parsing parity
---
.../vinext/src/entries/pages-server-entry.ts | 6 ++++-
packages/vinext/src/server/api-handler.ts | 8 ++++++-
packages/vinext/src/server/prod-server.ts | 6 ++++-
.../entry-templates.test.ts.snap | 6 ++++-
tests/api-handler.test.ts | 16 +++++++++++++
tests/fixtures/pages-basic/middleware.ts | 2 +-
tests/pages-router.test.ts | 23 +++++++++++++++++++
7 files changed, 62 insertions(+), 5 deletions(-)
diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts
index 74562090..c7b6c5c4 100644
--- a/packages/vinext/src/entries/pages-server-entry.ts
+++ b/packages/vinext/src/entries/pages-server-entry.ts
@@ -1055,7 +1055,11 @@ export async function handleApiRoute(request, url) {
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
- body = isJsonMediaType(mediaType) ? {} : undefined;
+ body = isJsonMediaType(mediaType)
+ ? {}
+ : mediaType === "application/x-www-form-urlencoded"
+ ? decodeQueryString(rawBody)
+ : undefined;
} else if (isJsonMediaType(mediaType)) {
try { body = JSON.parse(rawBody); }
catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts
index 8fa5c475..a3907040 100644
--- a/packages/vinext/src/server/api-handler.ts
+++ b/packages/vinext/src/server/api-handler.ts
@@ -90,7 +90,13 @@ async function parseBody(req: IncomingMessage): Promise {
const raw = Buffer.concat(chunks).toString("utf-8");
const mediaType = getMediaType(req.headers["content-type"]);
if (!raw) {
- resolve(isJsonMediaType(mediaType) ? {} : undefined);
+ resolve(
+ isJsonMediaType(mediaType)
+ ? {}
+ : mediaType === "application/x-www-form-urlencoded"
+ ? decodeQueryString(raw)
+ : undefined,
+ );
return;
}
if (isJsonMediaType(mediaType)) {
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 65ba0318..661cf48f 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -896,7 +896,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
});
const setCookies = result.response.headers.getSetCookie?.() ?? [];
if (setCookies.length > 0) respHeaders["set-cookie"] = setCookies;
- res.writeHead(result.response.status, respHeaders);
+ if (result.response.statusText) {
+ res.writeHead(result.response.status, result.response.statusText, respHeaders);
+ } else {
+ res.writeHead(result.response.status, respHeaders);
+ }
res.end(body);
return;
}
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index cc525689..989d379c 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -18398,7 +18398,11 @@ export async function handleApiRoute(request, url) {
try { rawBody = await readBodyWithLimit(request, 1 * 1024 * 1024); }
catch { return new Response("Request body too large", { status: 413 }); }
if (!rawBody) {
- body = isJsonMediaType(mediaType) ? {} : undefined;
+ body = isJsonMediaType(mediaType)
+ ? {}
+ : mediaType === "application/x-www-form-urlencoded"
+ ? decodeQueryString(rawBody)
+ : undefined;
} else if (isJsonMediaType(mediaType)) {
try { body = JSON.parse(rawBody); }
catch { return new Response("Invalid JSON", { status: 400, statusText: "Invalid JSON" }); }
diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts
index 14818c91..4024d6fb 100644
--- a/tests/api-handler.test.ts
+++ b/tests/api-handler.test.ts
@@ -251,6 +251,22 @@ describe("handleApiRoute", () => {
expect(capturedBody).toEqual({ tag: ["a", "b", "c"] });
});
+ it("parses empty application/x-www-form-urlencoded bodies as an empty object", async () => {
+ let capturedBody: unknown;
+ const handler = vi.fn((req: any) => {
+ capturedBody = req.body;
+ });
+ const server = mockServer({ default: handler });
+ const req = mockReq("POST", "/api/users", "", {
+ "content-type": "application/x-www-form-urlencoded",
+ });
+ const res = mockRes();
+
+ await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]);
+
+ expect(capturedBody).toEqual({});
+ });
+
it("parses application/ld+json bodies as JSON", async () => {
let capturedBody: unknown;
const handler = vi.fn((req: any) => {
diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts
index 67541d75..3dc31ae9 100644
--- a/tests/fixtures/pages-basic/middleware.ts
+++ b/tests/fixtures/pages-basic/middleware.ts
@@ -28,7 +28,7 @@ export function middleware(request: NextRequest) {
// Block /blocked with a custom response
if (url.pathname === "/blocked") {
- return new Response("Access Denied", { status: 403 });
+ return new Response("Access Denied", { status: 403, statusText: "Blocked by Middleware" });
}
// Throw an error to test that middleware errors return 500, not bypass auth
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index 020bd4d9..96365d61 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -224,6 +224,17 @@ describe("Pages Router integration", () => {
expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
});
+ it("parses empty urlencoded bodies on Pages API routes as {}", async () => {
+ const res = await fetch(`${baseUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: "",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({});
+ });
+
it("parses application/ld+json bodies on Pages API routes", async () => {
const res = await fetch(`${baseUrl}/api/parse`, {
method: "POST",
@@ -1815,6 +1826,7 @@ describe("Production server middleware (Pages Router)", () => {
it("blocks /blocked with 403 via middleware", async () => {
const res = await fetch(`${prodUrl}/blocked`);
expect(res.status).toBe(403);
+ expect(res.statusText).toBe("Blocked by Middleware");
const text = await res.text();
expect(text).toContain("Access Denied");
});
@@ -1875,6 +1887,17 @@ describe("Production server middleware (Pages Router)", () => {
expect(await res.json()).toEqual({ tag: ["a", "b", "c"] });
});
+ it("parses empty urlencoded bodies for Pages API routes in production as {}", async () => {
+ const res = await fetch(`${prodUrl}/api/parse`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: "",
+ });
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({});
+ });
+
it("parses empty JSON bodies for Pages API routes in production as {}", async () => {
const res = await fetch(`${prodUrl}/api/parse`, {
method: "POST",
From af293f991580c48950494b580174e2f0fa630828 Mon Sep 17 00:00:00 2001
From: Jared Stowell
Date: Tue, 10 Mar 2026 23:42:43 -0500
Subject: [PATCH 3/5] Fix empty form body parity
---
packages/vinext/src/server/prod-server.ts | 14 ++--
tests/pages-router.test.ts | 83 +++++++++++++++++++++++
2 files changed, 93 insertions(+), 4 deletions(-)
diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts
index 661cf48f..f3dc32aa 100644
--- a/packages/vinext/src/server/prod-server.ts
+++ b/packages/vinext/src/server/prod-server.ts
@@ -1026,16 +1026,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
// the handler doesn't set an explicit Content-Type.
const ct = response.headers.get("content-type") ?? "application/octet-stream";
const responseHeaders = mergeResponseHeaders(middlewareHeaders, response);
+ const finalStatus = middlewareRewriteStatus ?? response.status;
+ const finalStatusText =
+ finalStatus === response.status ? response.statusText || undefined : undefined;
sendCompressed(
req,
res,
responseBody,
ct,
- middlewareRewriteStatus ?? response.status,
+ finalStatus,
responseHeaders,
compress,
- response.statusText || undefined,
+ finalStatusText,
);
return;
}
@@ -1087,16 +1090,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
const responseBody = Buffer.from(await response.arrayBuffer());
const ct = response.headers.get("content-type") ?? "text/html";
const responseHeaders = mergeResponseHeaders(middlewareHeaders, response);
+ const finalStatus = middlewareRewriteStatus ?? response.status;
+ const finalStatusText =
+ finalStatus === response.status ? response.statusText || undefined : undefined;
sendCompressed(
req,
res,
responseBody,
ct,
- middlewareRewriteStatus ?? response.status,
+ finalStatus,
responseHeaders,
compress,
- response.statusText || undefined,
+ finalStatusText,
);
} catch (e) {
console.error("[vinext] Server error:", e);
diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts
index 96365d61..020ab332 100644
--- a/tests/pages-router.test.ts
+++ b/tests/pages-router.test.ts
@@ -2360,6 +2360,89 @@ describe("Static export (Pages Router)", () => {
});
});
+describe("Pages Router production rewrite status reason phrases", () => {
+ it("drops stale statusText when middleware rewrite status overrides an API response status", async () => {
+ const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-pages-rewrite-status-text-"));
+ const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
+ const outDir = path.join(tmpRoot, "dist");
+
+ try {
+ await fsp.symlink(rootNodeModules, path.join(tmpRoot, "node_modules"), "junction");
+ await fsp.mkdir(path.join(tmpRoot, "pages", "api"), { recursive: true });
+
+ await fsp.writeFile(path.join(tmpRoot, "package.json"), JSON.stringify({ type: "module" }));
+ await fsp.writeFile(path.join(tmpRoot, "next.config.mjs"), `export default {};\n`);
+ await fsp.writeFile(
+ path.join(tmpRoot, "middleware.ts"),
+ `import { NextResponse } from "next/server";
+export function middleware(request) {
+ const url = new URL(request.url);
+ if (url.pathname === "/blocked") {
+ return NextResponse.rewrite(new URL("/api/parse", request.url), { status: 403 });
+ }
+ return NextResponse.next();
+}
+`,
+ );
+ await fsp.writeFile(
+ path.join(tmpRoot, "pages", "api", "parse.ts"),
+ `export default function handler(req, res) {
+ res.status(200).json(req.body ?? null);
+}
+`,
+ );
+
+ await build({
+ root: tmpRoot,
+ configFile: false,
+ plugins: [vinext()],
+ logLevel: "silent",
+ build: {
+ outDir: path.join(outDir, "server"),
+ ssr: "virtual:vinext-server-entry",
+ rollupOptions: { output: { entryFileNames: "entry.js" } },
+ },
+ });
+ await build({
+ root: tmpRoot,
+ configFile: false,
+ plugins: [vinext()],
+ logLevel: "silent",
+ build: {
+ outDir: path.join(outDir, "client"),
+ manifest: true,
+ ssrManifest: true,
+ rollupOptions: { input: "virtual:vinext-client-entry" },
+ },
+ });
+
+ const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
+ const prodServer = await startProdServer({
+ port: 0,
+ host: "127.0.0.1",
+ outDir,
+ });
+
+ try {
+ const addr = prodServer.address() as { port: number };
+ const res = await fetch(`http://127.0.0.1:${addr.port}/blocked`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `{"message":Invalid"}`,
+ });
+
+ expect(res.status).toBe(403);
+ expect(res.statusText).toBe("Forbidden");
+ expect(await res.text()).toBe("Invalid JSON");
+ } finally {
+ await new Promise((resolve) => prodServer.close(() => resolve()));
+ }
+ } finally {
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ }
+ });
+});
+
describe("router __NEXT_DATA__ correctness (Pages Router)", () => {
let routerServer: ViteDevServer;
let routerBaseUrl: string;
From b7d7c5fa51011407ac107afa953e6f66eec5072d Mon Sep 17 00:00:00 2001
From: James
Date: Wed, 11 Mar 2026 13:03:42 +0000
Subject: [PATCH 4/5] Regenerate entry-templates snapshots after merge
---
.../entry-templates.test.ts.snap | 19046 ++++++++++++++++
1 file changed, 19046 insertions(+)
create mode 100644 tests/__snapshots__/entry-templates.test.ts.snap
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
new file mode 100644
index 00000000..0c640382
--- /dev/null
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -0,0 +1,19046 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`App Router entry templates > generateBrowserEntry snapshot 1`] = `
+"
+import {
+ createFromReadableStream,
+ createFromFetch,
+ setServerCallback,
+ encodeReply,
+ createTemporaryReferenceSet,
+} from "@vitejs/plugin-rsc/browser";
+import { hydrateRoot } from "react-dom/client";
+import { flushSync } from "react-dom";
+import { setClientParams, setNavigationContext, toRscUrl, getPrefetchCache, getPrefetchedUrls, PREFETCH_CACHE_TTL } from "next/navigation";
+
+let reactRoot;
+
+/**
+ * Convert the embedded RSC chunks back to a ReadableStream.
+ * Each chunk is a text string that needs to be encoded back to Uint8Array.
+ */
+function chunksToReadableStream(chunks) {
+ const encoder = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) {
+ controller.enqueue(encoder.encode(chunk));
+ }
+ controller.close();
+ }
+ });
+}
+
+/**
+ * Create a ReadableStream from progressively-embedded RSC chunks.
+ * The server injects RSC data as
+ *
+ * Chunks are embedded as text strings (not byte arrays) since the RSC flight
+ * protocol is text-based. The browser entry encodes them back to Uint8Array.
+ * This is ~3x more compact than the previous byte-array format.
+ */
+function createRscEmbedTransform(embedStream) {
+ const reader = embedStream.getReader();
+ const _decoder = new TextDecoder();
+ let done = false;
+ let pendingChunks = [];
+ let reading = false;
+
+ // Fix invalid preload "as" values in RSC Flight hint lines before
+ // they reach the client. React Flight emits HL hints with
+ // as="stylesheet" for CSS, but the HTML spec requires as="style"
+ // for . The fixPreloadAs() below only fixes the
+ // server-rendered HTML stream; this fixes the raw Flight data that
+ // gets embedded as __VINEXT_RSC_CHUNKS__ and processed client-side.
+ function fixFlightHints(text) {
+ // Flight hint format: :HL["url","stylesheet"] or with options
+ return text.replace(/(\\d+:HL\\[.*?),"stylesheet"(\\]|,)/g, '$1,"style"$2');
+ }
+
+ // Start reading RSC chunks in the background, accumulating them as text strings.
+ // The RSC flight protocol is text-based, so decoding to strings and embedding
+ // as JSON strings is ~3x more compact than the byte-array format.
+ async function pumpReader() {
+ if (reading) return;
+ reading = true;
+ try {
+ while (true) {
+ const result = await reader.read();
+ if (result.done) {
+ done = true;
+ break;
+ }
+ const text = _decoder.decode(result.value, { stream: true });
+ pendingChunks.push(fixFlightHints(text));
+ }
+ } catch (err) {
+ if (process.env.NODE_ENV !== "production") {
+ console.warn("[vinext] RSC embed stream read error:", err);
+ }
+ done = true;
+ }
+ reading = false;
+ }
+
+ // Fire off the background reader immediately
+ const pumpPromise = pumpReader();
+
+ return {
+ /**
+ * Flush any accumulated RSC chunks as ";
+ }
+ return scripts;
+ },
+
+ /**
+ * Wait for the RSC stream to fully complete and return any final
+ * script tags plus the closing signal.
+ */
+ async finalize() {
+ await pumpPromise;
+ let scripts = this.flush();
+ // Signal that all RSC chunks have been sent.
+ // Params are already embedded in — no need to include here.
+ scripts += "";
+ return scripts;
+ },
+ };
+}
+
+/**
+ * Render the RSC stream to HTML.
+ *
+ * @param rscStream - The RSC payload stream from the RSC environment
+ * @param navContext - Navigation context for client component SSR hooks.
+ * "use client" components like those using usePathname() need the current
+ * request URL during SSR, and they run in this SSR environment (separate
+ * from the RSC environment where the context was originally set).
+ * @param fontData - Font links and styles collected from the RSC environment.
+ * Fonts are loaded during RSC rendering (when layout calls Geist() etc.),
+ * and the data needs to be passed to SSR since they're separate module instances.
+ */
+export async function handleSsr(rscStream, navContext, fontData) {
+ // Wrap in a navigation ALS scope for per-request isolation in the SSR
+ // environment. The SSR environment has separate module instances from RSC,
+ // so it needs its own ALS scope.
+ return _runWithNavCtx(async () => {
+ // Set navigation context so hooks like usePathname() work during SSR
+ // of "use client" components
+ if (navContext) {
+ setNavigationContext(navContext);
+ }
+
+ // Clear any stale callbacks from previous requests
+ const { clearServerInsertedHTML, flushServerInsertedHTML, useServerInsertedHTML: _addInsertedHTML } = await import("next/navigation");
+ clearServerInsertedHTML();
+
+ try {
+ // Tee the RSC stream - one for SSR rendering, one for embedding in HTML.
+ // This ensures the browser uses the SAME RSC payload for hydration that
+ // was used to generate the HTML, avoiding hydration mismatches (React #418).
+ const [ssrStream, embedStream] = rscStream.tee();
+
+ // Create the progressive RSC embed helper — it reads the embed stream
+ // in the background and provides script tags to inject into the HTML stream.
+ const rscEmbed = createRscEmbedTransform(embedStream);
+
+ // Deserialize RSC stream back to React VDOM.
+ // IMPORTANT: Do NOT await this — createFromReadableStream returns a thenable
+ // that React's renderToReadableStream can consume progressively. By passing
+ // the unresolved thenable, React will render Suspense fallbacks (loading.tsx)
+ // immediately in the HTML shell, then stream in resolved content as RSC
+ // chunks arrive. Awaiting here would block until all async server components
+ // complete, collapsing the streaming behavior.
+ // Lazily create the Flight root inside render so React's hook dispatcher is set
+ // (avoids React 19 dev-mode resolveErrorDev() crash). VinextFlightRoot returns
+ // a thenable (not a ReactNode), which React 19 consumes via its internal
+ // thenable-as-child suspend/resume behavior. This matches Next.js's approach.
+ let flightRoot;
+ function VinextFlightRoot() {
+ if (!flightRoot) {
+ flightRoot = createFromReadableStream(ssrStream);
+ }
+ return flightRoot;
+ }
+ const root = _ssrCE(VinextFlightRoot);
+
+ // Wrap with ServerInsertedHTMLContext.Provider so libraries that use
+ // useContext(ServerInsertedHTMLContext) (Apollo Client, styled-components,
+ // etc.) get a working callback registration function during SSR.
+ // The provider value is useServerInsertedHTML — same function that direct
+ // callers use — so both paths push to the same ALS-backed callback array.
+ const ssrRoot = ServerInsertedHTMLContext
+ ? _ssrCE(ServerInsertedHTMLContext.Provider, { value: _addInsertedHTML }, root)
+ : root;
+
+ // Get the bootstrap script content for the browser entry
+ const bootstrapScriptContent =
+ await import.meta.viteRsc.loadBootstrapScriptContent("index");
+
+ // djb2 hash for digest generation in the SSR environment.
+ // Matches the RSC environment's __errorDigest function.
+ function ssrErrorDigest(str) {
+ let hash = 5381;
+ for (let i = str.length - 1; i >= 0; i--) {
+ hash = (hash * 33) ^ str.charCodeAt(i);
+ }
+ return (hash >>> 0).toString();
+ }
+
+ // Render HTML (streaming SSR)
+ // useServerInsertedHTML callbacks are registered during this render.
+ // The onError callback preserves the digest for Next.js navigation errors
+ // (redirect, notFound, forbidden, unauthorized) thrown inside Suspense
+ // boundaries during RSC streaming. Without this, React's default onError
+ // returns undefined and the digest is lost in the $RX() call, preventing
+ // client-side error boundaries from identifying the error type.
+ // In production, non-navigation errors also get a digest hash so they
+ // can be correlated with server logs without leaking details to clients.
+ const htmlStream = await renderToReadableStream(ssrRoot, {
+ bootstrapScriptContent,
+ onError(error) {
+ if (error && typeof error === "object" && "digest" in error) {
+ return String(error.digest);
+ }
+ // In production, generate a digest hash for non-navigation errors
+ if (process.env.NODE_ENV === "production" && error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ const stack = error instanceof Error ? (error.stack || "") : "";
+ return ssrErrorDigest(msg + stack);
+ }
+ return undefined;
+ },
+ });
+
+ // Flush useServerInsertedHTML callbacks (CSS-in-JS style injection)
+ const insertedElements = flushServerInsertedHTML();
+
+ // Render the inserted elements to HTML strings
+ const { Fragment } = await import("react");
+ let insertedHTML = "";
+ for (const el of insertedElements) {
+ try {
+ insertedHTML += renderToStaticMarkup(_ssrCE(Fragment, null, el));
+ } catch {
+ // Skip elements that can't be rendered
+ }
+ }
+
+ // Escape HTML attribute values (defense-in-depth for font URLs/types).
+ function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); }
+
+ // Build font HTML from data passed from RSC environment
+ // (Fonts are loaded during RSC rendering, and RSC/SSR are separate module instances)
+ let fontHTML = "";
+ if (fontData) {
+ if (fontData.links && fontData.links.length > 0) {
+ for (const url of fontData.links) {
+ fontHTML += '\\n';
+ }
+ }
+ // Emit for local font files
+ if (fontData.preloads && fontData.preloads.length > 0) {
+ for (const preload of fontData.preloads) {
+ fontHTML += '\\n';
+ }
+ }
+ if (fontData.styles && fontData.styles.length > 0) {
+ fontHTML += '\\n';
+ }
+ }
+
+ // Extract client entry module URL from bootstrapScriptContent to emit
+ // a hint. The RSC plugin formats bootstrap
+ // content as: import("URL") — we extract the URL so the browser can
+ // speculatively fetch and parse the JS module while still processing
+ // the HTML body, instead of waiting until it reaches the inline script.
+ let modulePreloadHTML = "";
+ if (bootstrapScriptContent) {
+ const m = bootstrapScriptContent.match(/import\\("([^"]+)"\\)/);
+ if (m && m[1]) {
+ modulePreloadHTML = '\\n';
+ }
+ }
+
+ // Head-injected HTML: server-inserted HTML, font HTML, route params,
+ // and modulepreload hints.
+ // RSC payload is now embedded progressively via script tags in the body stream.
+ // Params are embedded eagerly in so they're available before client
+ // hydration starts, avoiding the need for polling on the client.
+ const paramsScript = '';
+ // Embed the initial navigation context (pathname + searchParams) so the
+ // browser useSyncExternalStore getServerSnapshot can return the correct
+ // value during hydration. Without this, getServerSnapshot returns "/" and
+ // React detects a mismatch against the SSR-rendered HTML.
+ // Serialise searchParams as an array of [key, value] pairs to preserve
+ // duplicate keys (e.g. ?tag=a&tag=b). Object.fromEntries() would keep
+ // only the last value, causing a hydration mismatch for multi-value params.
+ const __navPayload = { pathname: navContext?.pathname ?? '/', searchParams: navContext?.searchParams ? [...navContext.searchParams.entries()] : [] };
+ const navScript = '';
+ const injectHTML = paramsScript + navScript + modulePreloadHTML + insertedHTML + fontHTML;
+
+ // Inject the collected HTML before and progressively embed RSC
+ // chunks as script tags throughout the HTML body stream.
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+ let injected = false;
+
+ // Fix invalid preload "as" values in server-rendered HTML.
+ // React Fizz emits for CSS,
+ // but the HTML spec requires as="style" for .
+ // Note: fixFlightHints() in createRscEmbedTransform handles the
+ // complementary case — fixing the raw Flight stream data before
+ // it's embedded as __VINEXT_RSC_CHUNKS__ for client-side processing.
+ // See: https://html.spec.whatwg.org/multipage/links.html#link-type-preload
+ function fixPreloadAs(html) {
+ // Match in any attribute order
+ return html.replace(/]*\\srel="preload")[^>]*>/g, function(tag) {
+ return tag.replace(' as="stylesheet"', ' as="style"');
+ });
+ }
+
+ // Tick-buffered RSC script injection.
+ //
+ // React's renderToReadableStream (Fizz) flushes chunks synchronously
+ // within one microtask — all chunks from a single flushCompletedQueues
+ // call arrive in the same macrotask. We buffer HTML chunks as they
+ // arrive, then use setTimeout(0) to defer emitting them plus any
+ // accumulated RSC scripts to the next macrotask. This guarantees we
+ // never inject ');
+ }
+ if (m) {
+ // Always inject shared chunks (framework, vinext runtime, entry) and
+ // page-specific chunks. The manifest maps module file paths to their
+ // associated JS/CSS assets.
+ //
+ // For page-specific injection, the module IDs may be absolute paths
+ // while the manifest uses relative paths. Try both the original ID
+ // and a suffix match to find the correct manifest entry.
+ var allFiles = [];
+
+ if (moduleIds && moduleIds.length > 0) {
+ // Collect assets for the requested page modules
+ for (var mi = 0; mi < moduleIds.length; mi++) {
+ var id = moduleIds[mi];
+ var files = m[id];
+ if (!files) {
+ // Absolute path didn't match — try matching by suffix.
+ // Manifest keys are relative (e.g. "pages/about.tsx") while
+ // moduleIds may be absolute (e.g. "/home/.../pages/about.tsx").
+ for (var mk in m) {
+ if (id.endsWith("/" + mk) || id === mk) {
+ files = m[mk];
+ break;
+ }
+ }
+ }
+ if (files) {
+ for (var fi = 0; fi < files.length; fi++) allFiles.push(files[fi]);
+ }
+ }
+
+ // Also inject shared chunks that every page needs: framework,
+ // vinext runtime, and the entry bootstrap. These are identified
+ // by scanning all manifest values for chunk filenames containing
+ // known prefixes.
+ for (var key in m) {
+ var vals = m[key];
+ if (!vals) continue;
+ for (var vi = 0; vi < vals.length; vi++) {
+ var file = vals[vi];
+ var basename = file.split("/").pop() || "";
+ if (
+ basename.startsWith("framework-") ||
+ basename.startsWith("vinext-") ||
+ basename.includes("vinext-client-entry") ||
+ basename.includes("vinext-app-browser-entry")
+ ) {
+ allFiles.push(file);
+ }
+ }
+ }
+ } else {
+ // No specific modules — include all assets from manifest
+ for (var akey in m) {
+ var avals = m[akey];
+ if (avals) {
+ for (var ai = 0; ai < avals.length; ai++) allFiles.push(avals[ai]);
+ }
+ }
+ }
+
+ for (var ti = 0; ti < allFiles.length; ti++) {
+ var tf = allFiles[ti];
+ // Normalize: Vite's SSR manifest values include a leading '/'
+ // (from base path), but we prepend '/' ourselves when building
+ // href/src attributes. Strip any existing leading slash to avoid
+ // producing protocol-relative URLs like "//assets/chunk.js".
+ // This also ensures consistent keys for the seen-set dedup and
+ // lazySet.has() checks (which use values without leading slash).
+ if (tf.charAt(0) === '/') tf = tf.slice(1);
+ if (seen.has(tf)) continue;
+ seen.add(tf);
+ if (tf.endsWith(".css")) {
+ tags.push('');
+ } else if (tf.endsWith(".js")) {
+ // Skip lazy chunks — they are behind dynamic import() boundaries
+ // (React.lazy, next/dynamic) and should only be fetched on demand.
+ if (lazySet && lazySet.has(tf)) continue;
+ tags.push('');
+ tags.push('');
+ }
+ }
+ }
+ return tags.join("\\n ");
+}
+
+// i18n helpers
+function extractLocale(url) {
+ if (!i18nConfig) return { locale: undefined, url, hadPrefix: false };
+ const pathname = url.split("?")[0];
+ const parts = pathname.split("/").filter(Boolean);
+ const query = url.includes("?") ? url.slice(url.indexOf("?")) : "";
+ if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) {
+ const locale = parts[0];
+ const rest = "/" + parts.slice(1).join("/");
+ return { locale, url: (rest || "/") + query, hadPrefix: true };
+ }
+ return { locale: i18nConfig.defaultLocale, url, hadPrefix: false };
+}
+
+function detectLocaleFromHeaders(headers) {
+ if (!i18nConfig) return null;
+ const acceptLang = headers.get("accept-language");
+ if (!acceptLang) return null;
+ const langs = acceptLang.split(",").map(function(part) {
+ const pieces = part.trim().split(";");
+ const q = pieces[1] ? parseFloat(pieces[1].replace("q=", "")) : 1;
+ return { lang: pieces[0].trim().toLowerCase(), q: q };
+ }).sort(function(a, b) { return b.q - a.q; });
+ for (let k = 0; k < langs.length; k++) {
+ const lang = langs[k].lang;
+ for (let j = 0; j < i18nConfig.locales.length; j++) {
+ if (i18nConfig.locales[j].toLowerCase() === lang) return i18nConfig.locales[j];
+ }
+ const prefix = lang.split("-")[0];
+ for (let j = 0; j < i18nConfig.locales.length; j++) {
+ const loc = i18nConfig.locales[j].toLowerCase();
+ if (loc === prefix || loc.startsWith(prefix + "-")) return i18nConfig.locales[j];
+ }
+ }
+ return null;
+}
+
+function parseCookieLocaleFromHeader(cookieHeader) {
+ if (!i18nConfig || !cookieHeader) return null;
+ const match = cookieHeader.match(/(?:^|;\\s*)NEXT_LOCALE=([^;]*)/);
+ if (!match) return null;
+ var value;
+ try { value = decodeURIComponent(match[1].trim()); } catch (e) { return null; }
+ if (i18nConfig.locales.indexOf(value) !== -1) return value;
+ return null;
+}
+
+// Lightweight req/res facade for getServerSideProps and API routes.
+// Next.js pages expect ctx.req/ctx.res with Node-like shapes.
+function createReqRes(request, url, query, body) {
+ const headersObj = {};
+ for (const [k, v] of request.headers) headersObj[k.toLowerCase()] = v;
+
+ const req = {
+ method: request.method,
+ url: url,
+ headers: headersObj,
+ query: query,
+ body: body,
+ cookies: parseCookies(request.headers.get("cookie")),
+ };
+
+ let resStatusCode = 200;
+ const resHeaders = {};
+ // set-cookie needs array support (multiple Set-Cookie headers are common)
+ const setCookieHeaders = [];
+ let resBody = null;
+ let ended = false;
+ let resolveResponse;
+ const responsePromise = new Promise(function(r) { resolveResponse = r; });
+
+ const res = {
+ get statusCode() { return resStatusCode; },
+ set statusCode(code) { resStatusCode = code; },
+ writeHead: function(code, headers) {
+ resStatusCode = code;
+ if (headers) {
+ for (const [k, v] of Object.entries(headers)) {
+ if (k.toLowerCase() === "set-cookie") {
+ if (Array.isArray(v)) { for (const c of v) setCookieHeaders.push(c); }
+ else { setCookieHeaders.push(v); }
+ } else {
+ resHeaders[k] = v;
+ }
+ }
+ }
+ return res;
+ },
+ setHeader: function(name, value) {
+ if (name.toLowerCase() === "set-cookie") {
+ if (Array.isArray(value)) { for (const c of value) setCookieHeaders.push(c); }
+ else { setCookieHeaders.push(value); }
+ } else {
+ resHeaders[name.toLowerCase()] = value;
+ }
+ return res;
+ },
+ getHeader: function(name) {
+ if (name.toLowerCase() === "set-cookie") return setCookieHeaders.length > 0 ? setCookieHeaders : undefined;
+ return resHeaders[name.toLowerCase()];
+ },
+ end: function(data) {
+ if (ended) return;
+ ended = true;
+ if (data !== undefined && data !== null) resBody = data;
+ const h = new Headers(resHeaders);
+ for (const c of setCookieHeaders) h.append("set-cookie", c);
+ resolveResponse(new Response(resBody, { status: resStatusCode, headers: h }));
+ },
+ status: function(code) { resStatusCode = code; return res; },
+ json: function(data) {
+ resHeaders["content-type"] = "application/json";
+ res.end(JSON.stringify(data));
+ },
+ send: function(data) {
+ if (Buffer.isBuffer(data)) {
+ if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream";
+ resHeaders["content-length"] = String(data.length);
+ res.end(data);
+ } else if (typeof data === "object" && data !== null) {
+ res.json(data);
+ } else {
+ if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain";
+ res.end(String(data));
+ }
+ },
+ redirect: function(statusOrUrl, url2) {
+ if (typeof statusOrUrl === "string") { res.writeHead(307, { Location: statusOrUrl }); }
+ else { res.writeHead(statusOrUrl, { Location: url2 }); }
+ res.end();
+ },
+ getHeaders: function() {
+ var h = Object.assign({}, resHeaders);
+ if (setCookieHeaders.length > 0) h["set-cookie"] = setCookieHeaders;
+ return h;
+ },
+ get headersSent() { return ended; },
+ };
+
+ return { req, res, responsePromise };
+}
+
+/**
+ * Read request body as text with a size limit.
+ * Throws if the body exceeds maxBytes. This prevents DoS via chunked
+ * transfer encoding where Content-Length is absent or spoofed.
+ */
+async function readBodyWithLimit(request, maxBytes) {
+ if (!request.body) return "";
+ var reader = request.body.getReader();
+ var decoder = new TextDecoder();
+ var chunks = [];
+ var totalSize = 0;
+ for (;;) {
+ var result = await reader.read();
+ if (result.done) break;
+ totalSize += result.value.byteLength;
+ if (totalSize > maxBytes) {
+ reader.cancel();
+ throw new Error("Request body too large");
+ }
+ chunks.push(decoder.decode(result.value, { stream: true }));
+ }
+ chunks.push(decoder.decode());
+ return chunks.join("");
+}
+
+export async function renderPage(request, url, manifest, ctx) {
+ if (ctx) return _runWithExecutionContext(ctx, () => _renderPage(request, url, manifest));
+ return _renderPage(request, url, manifest);
+}
+
+async function _renderPage(request, url, manifest) {
+ const localeInfo = extractLocale(url);
+ const locale = localeInfo.locale;
+ const routeUrl = localeInfo.url;
+ const cookieHeader = request.headers.get("cookie") || "";
+
+ // i18n redirect: check NEXT_LOCALE cookie first, then Accept-Language
+ if (i18nConfig && !localeInfo.hadPrefix) {
+ const cookieLocale = parseCookieLocaleFromHeader(cookieHeader);
+ if (cookieLocale && cookieLocale !== i18nConfig.defaultLocale) {
+ return new Response(null, { status: 307, headers: { Location: "/" + cookieLocale + routeUrl } });
+ }
+ if (!cookieLocale && i18nConfig.localeDetection !== false) {
+ const detected = detectLocaleFromHeaders(request.headers);
+ if (detected && detected !== i18nConfig.defaultLocale) {
+ return new Response(null, { status: 307, headers: { Location: "/" + detected + routeUrl } });
+ }
+ }
+ }
+
+ const match = matchRoute(routeUrl, pageRoutes);
+ if (!match) {
+ return new Response("404 - Page not found
",
+ { status: 404, headers: { "Content-Type": "text/html" } });
+ }
+
+ const { route, params } = match;
+ return runWithRouterState(() =>
+ runWithHeadState(() =>
+ _runWithCacheState(() =>
+ runWithPrivateCache(() =>
+ runWithFetchCache(async () => {
+ try {
+ if (typeof setSSRContext === "function") {
+ setSSRContext({
+ pathname: patternToNextFormat(route.pattern),
+ query: { ...params, ...parseQuery(routeUrl) },
+ asPath: routeUrl,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
+ });
+ }
+
+ if (i18nConfig) {
+ globalThis.__VINEXT_LOCALE__ = locale;
+ globalThis.__VINEXT_LOCALES__ = i18nConfig.locales;
+ globalThis.__VINEXT_DEFAULT_LOCALE__ = i18nConfig.defaultLocale;
+ }
+
+ const pageModule = route.module;
+ const PageComponent = pageModule.default;
+ if (!PageComponent) {
+ return new Response("Page has no default export", { status: 500 });
+ }
+
+ // Handle getStaticPaths for dynamic routes
+ if (typeof pageModule.getStaticPaths === "function" && route.isDynamic) {
+ const pathsResult = await pageModule.getStaticPaths({
+ locales: i18nConfig ? i18nConfig.locales : [],
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : "",
+ });
+ const fallback = pathsResult && pathsResult.fallback !== undefined ? pathsResult.fallback : false;
+
+ if (fallback === false) {
+ const paths = pathsResult && pathsResult.paths ? pathsResult.paths : [];
+ const isValidPath = paths.some(function(p) {
+ return Object.entries(p.params).every(function(entry) {
+ var key = entry[0], val = entry[1];
+ var actual = params[key];
+ if (Array.isArray(val)) {
+ return Array.isArray(actual) && val.join("/") === actual.join("/");
+ }
+ return String(val) === String(actual);
+ });
+ });
+ if (!isValidPath) {
+ return new Response("404 - Page not found
",
+ { status: 404, headers: { "Content-Type": "text/html" } });
+ }
+ }
+ }
+
+ let pageProps = {};
+ var gsspRes = null;
+ if (typeof pageModule.getServerSideProps === "function") {
+ const { req, res, responsePromise } = createReqRes(request, routeUrl, parseQuery(routeUrl), undefined);
+ const ctx = {
+ params, req, res,
+ query: parseQuery(routeUrl),
+ resolvedUrl: routeUrl,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
+ };
+ const result = await pageModule.getServerSideProps(ctx);
+ // If gSSP called res.end() directly (short-circuit), return that response.
+ if (res.headersSent) {
+ return await responsePromise;
+ }
+ if (result && result.props) pageProps = result.props;
+ if (result && result.redirect) {
+ var gsspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
+ return new Response(null, { status: gsspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
+ }
+ if (result && result.notFound) {
+ return new Response("404", { status: 404 });
+ }
+ // Preserve the res object so headers/status/cookies set by gSSP
+ // can be merged into the final HTML response.
+ gsspRes = res;
+ }
+ // Build font Link header early so it's available for ISR cached responses too.
+ // Font preloads are module-level state populated at import time and persist across requests.
+ var _fontLinkHeader = "";
+ var _allFp = [];
+ try {
+ var _fpGoogle = typeof _getSSRFontPreloadsGoogle === "function" ? _getSSRFontPreloadsGoogle() : [];
+ var _fpLocal = typeof _getSSRFontPreloadsLocal === "function" ? _getSSRFontPreloadsLocal() : [];
+ _allFp = _fpGoogle.concat(_fpLocal);
+ if (_allFp.length > 0) {
+ _fontLinkHeader = _allFp.map(function(p) { return "<" + p.href + ">; rel=preload; as=font; type=" + p.type + "; crossorigin"; }).join(", ");
+ }
+ } catch (e) { /* font preloads not available */ }
+
+ let isrRevalidateSeconds = null;
+ if (typeof pageModule.getStaticProps === "function") {
+ const pathname = routeUrl.split("?")[0];
+ const cacheKey = isrCacheKey("pages", pathname);
+ const cached = await isrGet(cacheKey);
+
+ if (cached && !cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
+ var _hitHeaders = {
+ "Content-Type": "text/html", "X-Vinext-Cache": "HIT",
+ "Cache-Control": "s-maxage=" + (cached.value.value.revalidate || 60) + ", stale-while-revalidate",
+ };
+ if (_fontLinkHeader) _hitHeaders["Link"] = _fontLinkHeader;
+ return new Response(cached.value.value.html, { status: 200, headers: _hitHeaders });
+ }
+
+ if (cached && cached.isStale && cached.value.value && cached.value.value.kind === "PAGES") {
+ triggerBackgroundRegeneration(cacheKey, async function() {
+ const freshResult = await pageModule.getStaticProps({ params });
+ if (freshResult && freshResult.props && typeof freshResult.revalidate === "number" && freshResult.revalidate > 0) {
+ await isrSet(cacheKey, { kind: "PAGES", html: cached.value.value.html, pageData: freshResult.props, headers: undefined, status: undefined }, freshResult.revalidate);
+ }
+ });
+ var _staleHeaders = {
+ "Content-Type": "text/html", "X-Vinext-Cache": "STALE",
+ "Cache-Control": "s-maxage=0, stale-while-revalidate",
+ };
+ if (_fontLinkHeader) _staleHeaders["Link"] = _fontLinkHeader;
+ return new Response(cached.value.value.html, { status: 200, headers: _staleHeaders });
+ }
+
+ const ctx = {
+ params,
+ locale: locale,
+ locales: i18nConfig ? i18nConfig.locales : undefined,
+ defaultLocale: i18nConfig ? i18nConfig.defaultLocale : undefined,
+ };
+ const result = await pageModule.getStaticProps(ctx);
+ if (result && result.props) pageProps = result.props;
+ if (result && result.redirect) {
+ var gspStatus = result.redirect.statusCode != null ? result.redirect.statusCode : (result.redirect.permanent ? 308 : 307);
+ return new Response(null, { status: gspStatus, headers: { Location: sanitizeDestinationLocal(result.redirect.destination) } });
+ }
+ if (result && result.notFound) {
+ return new Response("404", { status: 404 });
+ }
+ if (typeof result.revalidate === "number" && result.revalidate > 0) {
+ isrRevalidateSeconds = result.revalidate;
+ }
+ }
+
+ let element;
+ if (AppComponent) {
+ element = React.createElement(AppComponent, { Component: PageComponent, pageProps });
+ } else {
+ element = React.createElement(PageComponent, pageProps);
+ }
+ element = wrapWithRouterContext(element);
+
+ if (typeof resetSSRHead === "function") resetSSRHead();
+ if (typeof flushPreloads === "function") await flushPreloads();
+
+ const ssrHeadHTML = typeof getSSRHeadHTML === "function" ? getSSRHeadHTML() : "";
+
+ // Collect SSR font data (Google Font links, font preloads, font-face styles)
+ var fontHeadHTML = "";
+ function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); }
+ try {
+ var fontLinks = typeof _getSSRFontLinks === "function" ? _getSSRFontLinks() : [];
+ for (var fl of fontLinks) { fontHeadHTML += '\\n '; }
+ } catch (e) { /* next/font/google not used */ }
+ // Emit for all font files (reuse _allFp collected earlier for Link header)
+ for (var fp of _allFp) { fontHeadHTML += '\\n '; }
+ try {
+ var allFontStyles = [];
+ if (typeof _getSSRFontStylesGoogle === "function") allFontStyles.push(..._getSSRFontStylesGoogle());
+ if (typeof _getSSRFontStylesLocal === "function") allFontStyles.push(..._getSSRFontStylesLocal());
+ if (allFontStyles.length > 0) { fontHeadHTML += '\\n '; }
+ } catch (e) { /* font styles not available */ }
+
+ const pageModuleIds = route.filePath ? [route.filePath] : [];
+ const assetTags = collectAssetTags(manifest, pageModuleIds);
+ const nextDataPayload = {
+ props: { pageProps }, page: patternToNextFormat(route.pattern), query: params, buildId, isFallback: false,
+ };
+ if (i18nConfig) {
+ nextDataPayload.locale = locale;
+ nextDataPayload.locales = i18nConfig.locales;
+ nextDataPayload.defaultLocale = i18nConfig.defaultLocale;
+ }
+ const localeGlobals = i18nConfig
+ ? ";window.__VINEXT_LOCALE__=" + safeJsonStringify(locale) +
+ ";window.__VINEXT_LOCALES__=" + safeJsonStringify(i18nConfig.locales) +
+ ";window.__VINEXT_DEFAULT_LOCALE__=" + safeJsonStringify(i18nConfig.defaultLocale)
+ : "";
+ const nextDataScript = "";
+
+ // Build the document shell with a placeholder for the streamed body
+ var BODY_MARKER = "";
+ var shellHtml;
+ if (DocumentComponent) {
+ const docElement = React.createElement(DocumentComponent);
+ shellHtml = await renderToStringAsync(docElement);
+ shellHtml = shellHtml.replace("__NEXT_MAIN__", BODY_MARKER);
+ if (ssrHeadHTML || assetTags || fontHeadHTML) {
+ shellHtml = shellHtml.replace("", " " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n");
+ }
+ shellHtml = shellHtml.replace("", nextDataScript);
+ if (!shellHtml.includes("__NEXT_DATA__")) {
+ shellHtml = shellHtml.replace("
\\n " + BODY_MARKER + "
\\n " + nextDataScript + "\\n", " " + nextDataScript + "\\n");
+ }
+ } else {
+ shellHtml = "\\n\\n