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("", " " + nextDataScript + "\\n"); + } + } else { + shellHtml = "\\n\\n\\n \\n \\n " + fontHeadHTML + ssrHeadHTML + "\\n " + assetTags + "\\n\\n\\n
" + BODY_MARKER + "
\\n " + nextDataScript + "\\n\\n"; + } + + if (typeof setSSRContext === "function") setSSRContext(null); + + // Split the shell at the body marker + var markerIdx = shellHtml.indexOf(BODY_MARKER); + var shellPrefix = shellHtml.slice(0, markerIdx); + var shellSuffix = shellHtml.slice(markerIdx + BODY_MARKER.length); + + // Start the React body stream — progressive SSR (no allReady wait) + var bodyStream = await renderToReadableStream(element); + var encoder = new TextEncoder(); + + // Create a composite stream: prefix + body + suffix + var compositeStream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(shellPrefix)); + var reader = bodyStream.getReader(); + try { + for (;;) { + var chunk = await reader.read(); + if (chunk.done) break; + controller.enqueue(chunk.value); + } + } finally { + reader.releaseLock(); + } + controller.enqueue(encoder.encode(shellSuffix)); + controller.close(); + } + }); + + // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { + // Tee the stream so we can cache and respond simultaneously would be ideal, + // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. + var isrElement; + if (AppComponent) { + isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); + } else { + isrElement = React.createElement(PageComponent, pageProps); + } + isrElement = wrapWithRouterContext(isrElement); + var isrHtml = await renderToStringAsync(isrElement); + var fullHtml = shellPrefix + isrHtml + shellSuffix; + var isrPathname = url.split("?")[0]; + var _cacheKey = isrCacheKey("pages", isrPathname); + await isrSet(_cacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + } + + // Merge headers/status/cookies set by getServerSideProps on the res object. + // gSSP commonly uses res.setHeader("Set-Cookie", ...) or res.status(304). + var finalStatus = 200; + const responseHeaders = new Headers({ "Content-Type": "text/html" }); + if (gsspRes) { + finalStatus = gsspRes.statusCode; + var gsspHeaders = gsspRes.getHeaders(); + for (var hk of Object.keys(gsspHeaders)) { + var hv = gsspHeaders[hk]; + if (hk === "set-cookie" && Array.isArray(hv)) { + for (var sc of hv) responseHeaders.append("set-cookie", sc); + } else if (hv != null) { + responseHeaders.set(hk, String(hv)); + } + } + // Ensure Content-Type stays text/html (gSSP shouldn't override it for page renders) + responseHeaders.set("Content-Type", "text/html"); + } + if (isrRevalidateSeconds) { + responseHeaders.set("Cache-Control", "s-maxage=" + isrRevalidateSeconds + ", stale-while-revalidate"); + responseHeaders.set("X-Vinext-Cache", "MISS"); + } + // Set HTTP Link header for font preloading + if (_fontLinkHeader) { + responseHeaders.set("Link", _fontLinkHeader); + } + return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + } catch (e) { + console.error("[vinext] SSR error:", e); + return new Response("Internal Server Error", { status: 500 }); + } + }) // end runWithFetchCache + ) // end runWithPrivateCache + ) // end _runWithCacheState + ) // end runWithHeadState + ); // end runWithRouterState +} + +export async function handleApiRoute(request, url) { + const match = matchRoute(url, apiRoutes); + if (!match) { + return new Response("404 - API route not found", { status: 404 }); + } + + const { route, params } = match; + const handler = route.module.default; + if (typeof handler !== "function") { + return new Response("API route does not export a default function", { status: 500 }); + } + + const query = { ...params }; + const qs = url.split("?")[1]; + if (qs) { + for (const [k, v] of new URLSearchParams(qs)) { + if (k in query) { + // Multi-value: promote to array (Next.js returns string[] for duplicate keys) + query[k] = Array.isArray(query[k]) ? query[k].concat(v) : [query[k], v]; + } else { + query[k] = v; + } + } + } + + // Parse request body (enforce 1MB limit to prevent memory exhaustion, + // matching Next.js default bodyParser sizeLimit). + // Check Content-Length first as a fast path, then enforce on the actual + // stream to prevent bypasses via chunked transfer encoding. + const contentLength = parseInt(request.headers.get("content-length") || "0", 10); + if (contentLength > 1 * 1024 * 1024) { + return new Response("Request body too large", { status: 413 }); + } + try { + let body; + 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 = isJsonMediaType(mediaType) + ? {} + : mediaType === "application/x-www-form-urlencoded" + ? decodeQueryString(rawBody) + : undefined; + } else if (isJsonMediaType(mediaType)) { + try { body = JSON.parse(rawBody); } + catch { throw new ApiBodyParseError("Invalid JSON", 400); } + } else if (mediaType === "application/x-www-form-urlencoded") { + body = decodeQueryString(rawBody); + } else { + body = rawBody; + } + + const { req, res, responsePromise } = createReqRes(request, url, query, body); + await handler(req, res); + // If handler didn't call res.end(), end it now. + // The end() method is idempotent — safe to call twice. + res.end(); + return await responsePromise; + } catch (e) { + if (e instanceof ApiBodyParseError) { + return new Response(e.message, { status: e.statusCode }); + } + console.error("[vinext] API error:", e); + return new Response("Internal Server Error", { status: 500 }); + } +} + + +// --- Middleware support (generated from middleware-codegen.ts) --- + +function __normalizePath(pathname) { + if ( + pathname === "/" || + (pathname.length > 1 && + pathname[0] === "/" && + !pathname.includes("//") && + !pathname.includes("/./") && + !pathname.includes("/../") && + !pathname.endsWith("/.") && + !pathname.endsWith("/..")) + ) { + return pathname; + } + var segments = pathname.split("/"); + var resolved = []; + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (seg === "" || seg === ".") continue; + if (seg === "..") { resolved.pop(); } + else { resolved.push(seg); } + } + return "/" + resolved.join("/"); +} + +function __isSafeRegex(pattern) { + var quantifierAtDepth = []; + var depth = 0; + var i = 0; + while (i < pattern.length) { + var ch = pattern[i]; + if (ch === "\\\\") { i += 2; continue; } + if (ch === "[") { + i++; + while (i < pattern.length && pattern[i] !== "]") { + if (pattern[i] === "\\\\") i++; + i++; + } + i++; + continue; + } + if (ch === "(") { + depth++; + if (quantifierAtDepth.length <= depth) quantifierAtDepth.push(false); + else quantifierAtDepth[depth] = false; + i++; + continue; + } + if (ch === ")") { + var hadQ = depth > 0 && quantifierAtDepth[depth]; + if (depth > 0) depth--; + var next = pattern[i + 1]; + if (next === "+" || next === "*" || next === "{") { + if (hadQ) return false; + if (depth >= 0 && depth < quantifierAtDepth.length) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "+" || ch === "*") { + if (depth > 0) quantifierAtDepth[depth] = true; + i++; + continue; + } + if (ch === "?") { + var prev = i > 0 ? pattern[i - 1] : ""; + if (prev !== "+" && prev !== "*" && prev !== "?" && prev !== "}") { + if (depth > 0) quantifierAtDepth[depth] = true; + } + i++; + continue; + } + if (ch === "{") { + var j = i + 1; + while (j < pattern.length && /[\\d,]/.test(pattern[j])) j++; + if (j < pattern.length && pattern[j] === "}" && j > i + 1) { + if (depth > 0) quantifierAtDepth[depth] = true; + i = j + 1; + continue; + } + } + i++; + } + return true; +} +function __safeRegExp(pattern, flags) { + if (!__isSafeRegex(pattern)) { + console.warn("[vinext] Ignoring potentially unsafe regex pattern (ReDoS risk): " + pattern); + return null; + } + try { return new RegExp(pattern, flags); } catch { return null; } +} + +var __mwPatternCache = new Map(); +function __compileMwPattern(pattern) { + if (pattern.includes("(") || pattern.includes("\\\\")) { + return __safeRegExp("^" + pattern + "$"); + } + var regexStr = ""; + var tokenRe = /\\/:([\\w-]+)\\*|\\/:([\\w-]+)\\+|:([\\w-]+)|[.]|[^/:.]+|./g; + var tok; + while ((tok = tokenRe.exec(pattern)) !== null) { + if (tok[1] !== undefined) { regexStr += "(?:/.*)?"; } + else if (tok[2] !== undefined) { regexStr += "(?:/.+)"; } + else if (tok[3] !== undefined) { regexStr += "([^/]+)"; } + else if (tok[0] === ".") { regexStr += "\\\\."; } + else { regexStr += tok[0]; } + } + return __safeRegExp("^" + regexStr + "$"); +} +function matchMiddlewarePattern(pathname, pattern) { + var cached = __mwPatternCache.get(pattern); + if (cached === undefined) { + cached = __compileMwPattern(pattern); + __mwPatternCache.set(pattern, cached); + } + return cached ? cached.test(pathname) : pathname === pattern; +} + +var __middlewareConditionRegexCache = new Map(); +// Requestless matcher checks reuse this singleton. Treat it as immutable. +var __emptyMiddlewareRequestContext = { + headers: new Headers(), + cookies: {}, + query: new URLSearchParams(), + host: "", +}; + +function __normalizeMiddlewareHost(hostHeader, fallbackHostname) { + var host = hostHeader ?? fallbackHostname; + return host.split(":", 1)[0].toLowerCase(); +} + +function __parseMiddlewareCookies(cookieHeader) { + if (!cookieHeader) return {}; + var cookies = {}; + for (var part of cookieHeader.split(";")) { + var eq = part.indexOf("="); + if (eq === -1) continue; + var key = part.slice(0, eq).trim(); + var value = part.slice(eq + 1).trim(); + if (key) cookies[key] = value; + } + return cookies; +} + +function __middlewareRequestContextFromRequest(request) { + if (!request) return __emptyMiddlewareRequestContext; + var url = new URL(request.url); + return { + headers: request.headers, + cookies: __parseMiddlewareCookies(request.headers.get("cookie")), + query: url.searchParams, + host: __normalizeMiddlewareHost(request.headers.get("host"), url.hostname), + }; +} + +function __stripMiddlewareLocalePrefix(pathname, i18nConfig) { + if (pathname === "/") return null; + var segments = pathname.split("/"); + var firstSegment = segments[1]; + if (!firstSegment || !i18nConfig || !i18nConfig.locales.includes(firstSegment)) { + return null; + } + var stripped = "/" + segments.slice(2).join("/"); + return stripped === "/" ? "/" : stripped.replace(/\\/+$/, "") || "/"; +} + +function __matchMiddlewareMatcherPattern(pathname, pattern, i18nConfig) { + if (!i18nConfig) return matchMiddlewarePattern(pathname, pattern); + var localeStrippedPathname = __stripMiddlewareLocalePrefix(pathname, i18nConfig); + return matchMiddlewarePattern(localeStrippedPathname ?? pathname, pattern); +} + +function __middlewareConditionRegex(value) { + if (__middlewareConditionRegexCache.has(value)) { + return __middlewareConditionRegexCache.get(value); + } + var re = __safeRegExp(value); + __middlewareConditionRegexCache.set(value, re); + return re; +} + +function __checkMiddlewareCondition(condition, ctx) { + switch (condition.type) { + case "header": { + var headerValue = ctx.headers.get(condition.key); + if (headerValue === null) return false; + if (condition.value !== undefined) { + var re = __middlewareConditionRegex(condition.value); + if (re) return re.test(headerValue); + return headerValue === condition.value; + } + return true; + } + case "cookie": { + var cookieValue = ctx.cookies[condition.key]; + if (cookieValue === undefined) return false; + if (condition.value !== undefined) { + var re = __middlewareConditionRegex(condition.value); + if (re) return re.test(cookieValue); + return cookieValue === condition.value; + } + return true; + } + case "query": { + var queryValue = ctx.query.get(condition.key); + if (queryValue === null) return false; + if (condition.value !== undefined) { + var re = __middlewareConditionRegex(condition.value); + if (re) return re.test(queryValue); + return queryValue === condition.value; + } + return true; + } + case "host": { + if (condition.value !== undefined) { + var re = __middlewareConditionRegex(condition.value); + if (re) return re.test(ctx.host); + return ctx.host === condition.value; + } + return ctx.host === condition.key; + } + default: + return false; + } +} + +function __checkMiddlewareHasConditions(has, missing, ctx) { + if (has) { + for (var condition of has) { + if (!__checkMiddlewareCondition(condition, ctx)) return false; + } + } + if (missing) { + for (var condition of missing) { + if (__checkMiddlewareCondition(condition, ctx)) return false; + } + } + return true; +} + +// Keep this in sync with isValidMiddlewareMatcherObject in middleware.ts. +function __isValidMiddlewareMatcherObject(matcher) { + if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) return false; + if (typeof matcher.source !== "string") return false; + for (var key of Object.keys(matcher)) { + if (key !== "source" && key !== "locale" && key !== "has" && key !== "missing") { + return false; + } + } + if ("locale" in matcher && matcher.locale !== undefined && matcher.locale !== false) return false; + if ("has" in matcher && matcher.has !== undefined && !Array.isArray(matcher.has)) return false; + if ("missing" in matcher && matcher.missing !== undefined && !Array.isArray(matcher.missing)) { + return false; + } + return true; +} + +function __matchMiddlewareObject(pathname, matcher, i18nConfig) { + return matcher.locale === false + ? matchMiddlewarePattern(pathname, matcher.source) + : __matchMiddlewareMatcherPattern(pathname, matcher.source, i18nConfig); +} + +function matchesMiddleware(pathname, matcher, request, i18nConfig) { + if (!matcher) { + return true; + } + if (typeof matcher === "string") { + return __matchMiddlewareMatcherPattern(pathname, matcher, i18nConfig); + } + if (!Array.isArray(matcher)) { + return false; + } + var requestContext = __middlewareRequestContextFromRequest(request); + for (var m of matcher) { + if (typeof m === "string") { + if (__matchMiddlewareMatcherPattern(pathname, m, i18nConfig)) return true; + continue; + } + if (__isValidMiddlewareMatcherObject(m)) { + if (!__matchMiddlewareObject(pathname, m, i18nConfig)) continue; + if (!__checkMiddlewareHasConditions(m.has, m.missing, requestContext)) continue; + return true; + } + } + return false; +} + +export async function runMiddleware(request, ctx) { + if (ctx) return _runWithExecutionContext(ctx, () => _runMiddleware(request)); + return _runMiddleware(request); +} + +async function _runMiddleware(request) { + var isProxy = false; + var middlewareFn = isProxy + ? (middlewareModule.proxy ?? middlewareModule.default) + : (middlewareModule.middleware ?? middlewareModule.default); + if (typeof middlewareFn !== "function") { + var fileType = isProxy ? "Proxy" : "Middleware"; + var expectedExport = isProxy ? "proxy" : "middleware"; + throw new Error("The " + fileType + " file must export a function named \`" + expectedExport + "\` or a \`default\` function."); + } + + var config = middlewareModule.config; + var matcher = config && config.matcher; + var url = new URL(request.url); + + // Normalize pathname before matching to prevent path-confusion bypasses + // (percent-encoding like /%61dmin, double slashes like /dashboard//settings). + var decodedPathname; + try { decodedPathname = decodeURIComponent(url.pathname); } catch (e) { + return { continue: false, response: new Response("Bad Request", { status: 400 }) }; + } + var normalizedPathname = __normalizePath(decodedPathname); + + if (!matchesMiddleware(normalizedPathname, matcher, request, i18nConfig)) return { continue: true }; + + // Construct a new Request with the decoded + normalized pathname so middleware + // always sees the same canonical path that the router uses. + var mwRequest = request; + if (normalizedPathname !== url.pathname) { + var mwUrl = new URL(url); + mwUrl.pathname = normalizedPathname; + mwRequest = new Request(mwUrl, request); + } + var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); + var response; + try { response = await middlewareFn(nextRequest, fetchEvent); } + catch (e) { + console.error("[vinext] Middleware error:", e); + return { continue: false, response: new Response("Internal Server Error", { status: 500 }) }; + } + var _mwCtx = _getRequestExecutionContext(); + if (_mwCtx && typeof _mwCtx.waitUntil === "function") { _mwCtx.waitUntil(fetchEvent.drainWaitUntil()); } else { fetchEvent.drainWaitUntil(); } + + if (!response) return { continue: true }; + + if (response.headers.get("x-middleware-next") === "1") { + var rHeaders = new Headers(); + for (var [key, value] of response.headers) { + // Keep x-middleware-request-* headers so the production server can + // apply middleware-request header overrides before stripping internals + // from the final client response. + if ( + !key.startsWith("x-middleware-") || + key === "x-middleware-override-headers" || + key.startsWith("x-middleware-request-") + ) rHeaders.append(key, value); + } + return { continue: true, responseHeaders: rHeaders }; + } + + if (response.status >= 300 && response.status < 400) { + var location = response.headers.get("Location") || response.headers.get("location"); + if (location) { + var rdHeaders = new Headers(); + for (var [rk, rv] of response.headers) { + if (!rk.startsWith("x-middleware-") && rk.toLowerCase() !== "location") rdHeaders.append(rk, rv); + } + return { continue: false, redirectUrl: location, redirectStatus: response.status, responseHeaders: rdHeaders }; + } + } + + var rewriteUrl = response.headers.get("x-middleware-rewrite"); + if (rewriteUrl) { + var rwHeaders = new Headers(); + for (var [k, v] of response.headers) { + if (!k.startsWith("x-middleware-") || k === "x-middleware-override-headers" || k.startsWith("x-middleware-request-")) rwHeaders.append(k, v); + } + var rewritePath; + try { var parsed = new URL(rewriteUrl, request.url); rewritePath = parsed.pathname + parsed.search; } + catch { rewritePath = rewriteUrl; } + return { continue: true, rewriteUrl: rewritePath, rewriteStatus: response.status !== 200 ? response.status : undefined, responseHeaders: rwHeaders }; + } + + return { continue: false, response: response }; +} + +" +`; From a7852c6e2ca361d8cf1fbd1454ab7491e01a52d9 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 13:15:49 +0000 Subject: [PATCH 5/5] fix: propagate statusText through prod server sendCompressed non-compressed path The sendCompressed function had a statusText parameter but only used it in the compressed response path. The non-compressed else branch called res.writeHead directly without forwarding statusText, so short error responses (like 'Invalid JSON' at 12 bytes, well below COMPRESS_THRESHOLD) lost their custom reason phrase and fell back to the default 'Bad Request'. Fix: replace the direct res.writeHead call in the else branch with the writeHead closure that already handles the statusText conditional. Also set statusText on the ApiBodyParseError response in the pages server entry template so the value is present for prod-server to forward. --- packages/vinext/src/entries/pages-server-entry.ts | 2 +- packages/vinext/src/server/prod-server.ts | 2 +- tests/__snapshots__/entry-templates.test.ts.snap | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 08fefec2..bb320985 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -1094,7 +1094,7 @@ export async function handleApiRoute(request, url) { return await responsePromise; } catch (e) { if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode }); + return new Response(e.message, { status: e.statusCode, statusText: e.message }); } console.error("[vinext] API error:", e); return new Response("Internal Server Error", { status: 500 }); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 80dc02be..bbffc4a0 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -211,7 +211,7 @@ function sendCompressed( // Strip any pre-existing content-length (from the Web Response constructor) // before setting our own — avoids duplicate Content-Length headers. const { "content-length": _cl, "Content-Length": _CL, ...headersWithoutLength } = extraHeaders; - res.writeHead(statusCode, { + writeHead({ ...headersWithoutLength, "Content-Type": contentType, "Content-Length": String(buf.length), diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 0c640382..ddc94f5a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18651,7 +18651,7 @@ export async function handleApiRoute(request, url) { return await responsePromise; } catch (e) { if (e instanceof ApiBodyParseError) { - return new Response(e.message, { status: e.statusCode }); + return new Response(e.message, { status: e.statusCode, statusText: e.message }); } console.error("[vinext] API error:", e); return new Response("Internal Server Error", { status: 500 });