diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 295100b5..bb320985 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -333,6 +333,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; @@ -1057,15 +1067,20 @@ export async function handleApiRoute(request, url) { } try { 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 { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (ct.includes("application/x-www-form-urlencoded")) { + 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; @@ -1079,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/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 74f9048f..28c1c701 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -50,6 +50,14 @@ class ApiBodyParseError extends Error { } } +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. @@ -79,18 +87,24 @@ 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) + ? {} + : mediaType === "application/x-www-form-urlencoded" + ? decodeQueryString(raw) + : undefined, + ); return; } - const contentType = req.headers["content-type"] ?? ""; - if (contentType.includes("application/json")) { + if (isJsonMediaType(mediaType)) { try { resolve(JSON.parse(raw)); } catch { reject(new ApiBodyParseError("Invalid JSON", 400)); } - } else if (contentType.includes("application/x-www-form-urlencoded")) { + } else if (mediaType === "application/x-www-form-urlencoded") { resolve(decodeQueryString(raw)); } else { resolve(raw); @@ -223,6 +237,7 @@ export async function handleApiRoute( } catch (e) { if (e instanceof ApiBodyParseError) { res.statusCode = e.statusCode; + res.statusMessage = e.message; res.end(e.message); return true; } diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 0b1720f1..bbffc4a0 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -167,11 +167,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 @@ -188,7 +197,7 @@ function sendCompressed( } else { varyValue = "Accept-Encoding"; } - res.writeHead(statusCode, { + writeHead({ ...extraHeaders, "Content-Type": contentType, "Content-Encoding": encoding, @@ -202,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), @@ -397,6 +406,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 = {}; @@ -410,7 +427,7 @@ async function sendWebResponse( }); if (!webResponse.body) { - res.writeHead(status, nodeHeaders); + writeHead(nodeHeaders); res.end(); return; } @@ -441,7 +458,7 @@ async function sendWebResponse( } } - res.writeHead(status, nodeHeaders); + writeHead(nodeHeaders); // HEAD requests: send headers only, skip the body if (req.method === "HEAD") { @@ -936,7 +953,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; } @@ -1044,15 +1065,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, + finalStatusText, ); return; } @@ -1104,15 +1129,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, + finalStatusText, ); } 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 bb23fad8..ddc94f5a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17810,6 +17810,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; @@ -17855,8 +17865,9 @@ import * as api_3 from "/tests/fixtures/pages-basic/pages/api/hello.ts"; import * as api_4 from "/tests/fixtures/pages-basic/pages/api/instrumentation-test.ts"; import * as api_5 from "/tests/fixtures/pages-basic/pages/api/middleware-test.ts"; import * as api_6 from "/tests/fixtures/pages-basic/pages/api/no-content-type.ts"; -import * as api_7 from "/tests/fixtures/pages-basic/pages/api/send-buffer.ts"; -import * as api_8 from "/tests/fixtures/pages-basic/pages/api/users/[id].ts"; +import * as api_7 from "/tests/fixtures/pages-basic/pages/api/parse.ts"; +import * as api_8 from "/tests/fixtures/pages-basic/pages/api/send-buffer.ts"; +import * as api_9 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"; @@ -17904,8 +17915,9 @@ const apiRoutes = [ { pattern: "/api/instrumentation-test", patternParts: ["api","instrumentation-test"], isDynamic: false, params: [], module: api_4 }, { pattern: "/api/middleware-test", patternParts: ["api","middleware-test"], isDynamic: false, params: [], module: api_5 }, { pattern: "/api/no-content-type", patternParts: ["api","no-content-type"], isDynamic: false, params: [], module: api_6 }, - { pattern: "/api/send-buffer", patternParts: ["api","send-buffer"], isDynamic: false, params: [], module: api_7 }, - { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_8 } + { pattern: "/api/parse", patternParts: ["api","parse"], isDynamic: false, params: [], module: api_7 }, + { pattern: "/api/send-buffer", patternParts: ["api","send-buffer"], isDynamic: false, params: [], module: api_8 }, + { pattern: "/api/users/:id", patternParts: ["api","users",":id"], isDynamic: true, params: ["id"], module: api_9 } ]; function matchRoute(url, routes) { @@ -18612,15 +18624,20 @@ export async function handleApiRoute(request, url) { } try { 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 { throw new ApiBodyParseError("Invalid JSON", 400); } - } else if (ct.includes("application/x-www-form-urlencoded")) { + 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; @@ -18634,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 }); diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts index a753c7e6..ee09153b 100644 --- a/tests/api-handler.test.ts +++ b/tests/api-handler.test.ts @@ -189,9 +189,12 @@ describe("handleApiRoute", () => { expect(capturedBody).toEqual({ name: "Alice", age: 30 }); }); - it("returns 400 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", }); @@ -201,9 +204,28 @@ describe("handleApiRoute", () => { 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(); expect(reportRequestError).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", "", { + "content-type": "application/json", + }); + const res = mockRes(); + + await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); + + expect(capturedBody).toEqual({}); }); it("parses application/x-www-form-urlencoded body", async () => { @@ -222,23 +244,52 @@ describe("handleApiRoute", () => { expect(capturedBody).toEqual({ name: "Alice", role: "admin" }); }); - it("preserves repeated urlencoded keys as arrays", async () => { + 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", "a=1&a=2&b=3", { + 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 as Record) }).toEqual({ - a: ["1", "2"], - b: "3", + 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) => { + 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 () => { diff --git a/tests/fixtures/pages-basic/middleware.ts b/tests/fixtures/pages-basic/middleware.ts index d89d0418..45285d2e 100644 --- a/tests/fixtures/pages-basic/middleware.ts +++ b/tests/fixtures/pages-basic/middleware.ts @@ -40,7 +40,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/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 321f917b..81ec62e2 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,31 +188,62 @@ describe("Pages Router integration", () => { expect(data).toEqual({ user: { id: "123", name: "User 123" } }); }); - it("preserves repeated urlencoded API body keys", async () => { - const res = await fetch(`${baseUrl}/api/echo-body`, { + // 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/x-www-form-urlencoded", - }, - body: "a=1&a=2&b=3", + 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({}); + }); - const data = await res.json(); - expect(data).toEqual({ body: { a: ["1", "2"], b: "3" } }); + 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("returns 400 for malformed JSON API bodies", async () => { - const res = await fetch(`${baseUrl}/api/echo-body`, { + it("parses empty urlencoded bodies on Pages API routes as {}", async () => { + const res = await fetch(`${baseUrl}/api/parse`, { method: "POST", - headers: { - "content-type": "application/json", - }, - body: "{invalid json", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "", }); - expect(res.status).toBe(400); - expect(await res.text()).toBe("Invalid JSON"); + 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", + 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("sends Buffer payloads from res.send() as raw bytes", async () => { @@ -1557,10 +1589,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") { @@ -1575,7 +1613,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); }); @@ -1610,6 +1648,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); @@ -1843,6 +1914,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"); }); @@ -1880,6 +1952,62 @@ 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 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", + 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); @@ -2357,6 +2485,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;