diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index d41a5e80..18fb3bc2 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -259,6 +259,7 @@ import { runWithPrivateCache } from "vinext/cache-runtime"; import { runWithRouterState } from "vinext/router-state"; import { runWithHeadState } from "vinext/head-state"; import { safeJsonStringify } from "vinext/html"; +import { decode as decodeQueryString } from "node:querystring"; 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, "/"))}; @@ -277,6 +278,14 @@ const buildId = ${buildIdJson}; // Full resolved config for production server (embedded at build time) export const vinextConfig = ${vinextConfigJson}; +class ApiBodyParseError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + this.name = "ApiBodyParseError"; + } +} + // ISR cache helpers (inlined for the server entry) async function isrGet(key) { const handler = getCacheHandler(); @@ -631,8 +640,16 @@ function createReqRes(request, url, query, body) { res.end(JSON.stringify(data)); }, send: function(data) { - if (typeof data === "object" && data !== null) { res.json(data); } - else { if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; res.end(String(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 }); } @@ -1038,28 +1055,32 @@ export async function handleApiRoute(request, url) { if (contentLength > 1 * 1024 * 1024) { return new Response("Request body too large", { status: 413 }); } - let body; - const ct = 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; } - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - try { + let body; + const ct = 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 = 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 }); } diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 59542f28..74f9048f 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,16 @@ 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"; + } +} + /** * Parse the request body based on content-type. * Enforces a size limit to prevent memory exhaustion attacks. @@ -77,15 +88,10 @@ async function parseBody(req: IncomingMessage): Promise { try { resolve(JSON.parse(raw)); } catch { - resolve(raw); + reject(new ApiBodyParseError("Invalid JSON", 400)); } } 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; - } - resolve(obj); + resolve(decodeQueryString(raw)); } else { resolve(raw); } @@ -135,6 +141,15 @@ function enhanceApiObjects( }; apiRes.send = function (data: unknown) { + if (Buffer.isBuffer(data)) { + if (!this.getHeader("Content-Type")) { + this.setHeader("Content-Type", "application/octet-stream"); + } + this.setHeader("Content-Length", String(data.length)); + this.end(data); + return; + } + if (typeof data === "object" && data !== null) { this.setHeader("Content-Type", "application/json"); this.end(JSON.stringify(data)); @@ -206,6 +221,12 @@ export async function handleApiRoute( await handler(apiReq, apiRes); return true; } catch (e) { + if (e instanceof ApiBodyParseError) { + res.statusCode = e.statusCode; + 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..c12ee3f4 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -198,8 +198,11 @@ function sendCompressed( /* ignore pipeline errors on closed connections */ }); } else { + // 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, { - ...extraHeaders, + ...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 40ce67c8..b7c21660 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17587,6 +17587,7 @@ import { runWithPrivateCache } from "vinext/cache-runtime"; import { runWithRouterState } from "vinext/router-state"; import { runWithHeadState } from "vinext/head-state"; import { safeJsonStringify } from "vinext/html"; +import { decode as decodeQueryString } from "node:querystring"; 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"; @@ -17616,6 +17617,14 @@ const buildId = "test-build-id"; // Full resolved config for production server (embedded at build time) export const vinextConfig = {"basePath":"","trailingSlash":false,"redirects":[{"source":"/old-about","destination":"/about","permanent":true},{"source":"/repeat-redirect/:id","destination":"/docs/:id/:id","permanent":false}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"source":"/repeat-rewrite/:id","destination":"/docs/:id/:id"},{"source":"/mw-gated-before","has":[{"type":"cookie","key":"mw-before-user"}],"destination":"/about"}],"afterFiles":[{"source":"/after-rewrite","destination":"/about"},{"source":"/mw-gated-rewrite","has":[{"type":"cookie","key":"mw-user"}],"destination":"/about"}],"fallback":[{"source":"/fallback-rewrite","destination":"/about"}]},"headers":[{"source":"/api/(.*)","headers":[{"key":"X-Custom-Header","value":"vinext"}]},{"source":"/about","has":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Auth-Only-Header","value":"1"}]},{"source":"/about","missing":[{"type":"cookie","key":"logged-in"}],"headers":[{"key":"X-Guest-Only-Header","value":"1"}]},{"source":"/ssr","headers":[{"key":"Vary","value":"Accept-Language"}]}],"i18n":null,"images":{}}; +class ApiBodyParseError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + this.name = "ApiBodyParseError"; + } +} + // ISR cache helpers (inlined for the server entry) async function isrGet(key) { const handler = getCacheHandler(); @@ -17702,12 +17711,14 @@ import * as page_29 from "/tests/fixtures/pages-basic/pages/products/[pid] import * as page_30 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; import * as page_31 from "/tests/fixtures/pages-basic/pages/sign-up/[[...sign-up]]/index.tsx"; import * as api_0 from "/tests/fixtures/pages-basic/pages/api/binary.ts"; -import * as api_1 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; -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_1 from "/tests/fixtures/pages-basic/pages/api/echo-body.ts"; +import * as api_2 from "/tests/fixtures/pages-basic/pages/api/error-route.ts"; +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 { default as AppComponent } from "/tests/fixtures/pages-basic/pages/_app.tsx"; import { default as DocumentComponent } from "/tests/fixtures/pages-basic/pages/_document.tsx"; @@ -17749,12 +17760,14 @@ const pageRoutes = [ const apiRoutes = [ { pattern: "/api/binary", patternParts: ["api","binary"], isDynamic: false, params: [], module: api_0 }, - { pattern: "/api/error-route", patternParts: ["api","error-route"], isDynamic: false, params: [], module: api_1 }, - { pattern: "/api/hello", patternParts: ["api","hello"], isDynamic: false, params: [], module: api_2 }, - { 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/echo-body", patternParts: ["api","echo-body"], isDynamic: false, params: [], module: api_1 }, + { pattern: "/api/error-route", patternParts: ["api","error-route"], isDynamic: false, params: [], module: api_2 }, + { pattern: "/api/hello", patternParts: ["api","hello"], isDynamic: false, params: [], module: api_3 }, + { 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 } ]; function matchRoute(url, routes) { @@ -18044,8 +18057,16 @@ function createReqRes(request, url, query, body) { res.end(JSON.stringify(data)); }, send: function(data) { - if (typeof data === "object" && data !== null) { res.json(data); } - else { if (!resHeaders["content-type"]) resHeaders["content-type"] = "text/plain"; res.end(String(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 }); } @@ -18451,28 +18472,32 @@ export async function handleApiRoute(request, url) { if (contentLength > 1 * 1024 * 1024) { return new Response("Request body too large", { status: 413 }); } - let body; - const ct = 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; } - } else { - body = rawBody; - } - - const { req, res, responsePromise } = createReqRes(request, url, query, body); - try { + let body; + const ct = 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 = 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 }); } diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts index 0e10ac49..a753c7e6 100644 --- a/tests/api-handler.test.ts +++ b/tests/api-handler.test.ts @@ -10,13 +10,21 @@ * all behavior is tested indirectly through handleApiRoute with a mocked * ViteDevServer. */ -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { PassThrough } from "node:stream"; import http from "node:http"; +vi.mock("../packages/vinext/src/server/instrumentation.js", () => ({ + reportRequestError: vi.fn(() => Promise.resolve()), +})); import { handleApiRoute } from "../packages/vinext/src/server/api-handler.js"; +import { reportRequestError } from "../packages/vinext/src/server/instrumentation.js"; import type { Route } from "../packages/vinext/src/routing/pages-router.js"; import type { ViteDevServer } from "vite"; +beforeEach(() => { + vi.clearAllMocks(); +}); + // ── Helpers ────────────────────────────────────────────────────────────── /** @@ -66,7 +74,7 @@ function mockReq( * Create a mock ServerResponse that captures status, headers, and body. */ function mockRes(): http.ServerResponse & { - _body: string; + _body: string | Buffer; _headers: Record; _statusCode: number; _ended: boolean; @@ -93,7 +101,7 @@ function mockRes(): http.ServerResponse & { } } }, - end(data?: string) { + end(data?: string | Buffer) { if (data !== undefined) { res._body = data; } @@ -101,7 +109,7 @@ function mockRes(): http.ServerResponse & { res._statusCode = res.statusCode; }, } as unknown as http.ServerResponse & { - _body: string; + _body: string | Buffer; _headers: Record; _statusCode: number; _ended: boolean; @@ -181,11 +189,8 @@ describe("handleApiRoute", () => { expect(capturedBody).toEqual({ name: "Alice", age: 30 }); }); - it("falls back to raw string for malformed JSON", async () => { - let capturedBody: unknown; - const handler = vi.fn((req: any) => { - capturedBody = req.body; - }); + it("returns 400 for malformed JSON", async () => { + const handler = vi.fn(); const server = mockServer({ default: handler }); const req = mockReq("POST", "/api/users", "{not json", { "content-type": "application/json", @@ -194,7 +199,11 @@ describe("handleApiRoute", () => { await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); - expect(capturedBody).toBe("{not json"); + expect(handler).not.toHaveBeenCalled(); + expect(res._statusCode).toBe(400); + expect(res._body).toBe("Invalid JSON"); + expect(server.ssrFixStacktrace).not.toHaveBeenCalled(); + expect(reportRequestError).not.toHaveBeenCalled(); }); it("parses application/x-www-form-urlencoded body", async () => { @@ -213,6 +222,25 @@ describe("handleApiRoute", () => { expect(capturedBody).toEqual({ name: "Alice", role: "admin" }); }); + it("preserves repeated 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", { + "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", + }); + }); + it("returns raw string for unknown content-type", async () => { let capturedBody: unknown; const handler = vi.fn((req: any) => { @@ -418,7 +446,7 @@ describe("handleApiRoute", () => { expect(res._statusCode).toBe(201); expect(res._headers["content-type"]).toBe("application/json"); - expect(JSON.parse(res._body)).toEqual({ ok: true }); + expect(JSON.parse(res._body as string)).toEqual({ ok: true }); }); }); @@ -434,7 +462,7 @@ describe("handleApiRoute", () => { await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); expect(res._headers["content-type"]).toBe("application/json"); - expect(JSON.parse(res._body)).toEqual({ message: "hello" }); + expect(JSON.parse(res._body as string)).toEqual({ message: "hello" }); }); it("serializes nested objects", async () => { @@ -448,7 +476,7 @@ describe("handleApiRoute", () => { await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); - expect(JSON.parse(res._body)).toEqual(data); + expect(JSON.parse(res._body as string)).toEqual(data); }); }); @@ -464,7 +492,7 @@ describe("handleApiRoute", () => { await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); expect(res._headers["content-type"]).toBe("application/json"); - expect(JSON.parse(res._body)).toEqual({ key: "value" }); + expect(JSON.parse(res._body as string)).toEqual({ key: "value" }); }); it("sends string data as text/plain", async () => { @@ -481,6 +509,22 @@ describe("handleApiRoute", () => { expect(res._body).toBe("hello world"); }); + it("sends Buffer data as application/octet-stream bytes", async () => { + const handler = vi.fn((_req: any, res: any) => { + res.send(Buffer.from([1, 2, 3])); + }); + const server = mockServer({ default: handler }); + const req = mockReq("GET", "/api/users"); + const res = mockRes(); + + await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); + + expect(res._headers["content-type"]).toBe("application/octet-stream"); + expect(res._headers["content-length"]).toBe("3"); + expect(Buffer.isBuffer(res._body)).toBe(true); + expect((res._body as Buffer).equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + it("sends number data as text/plain string", async () => { const handler = vi.fn((_req: any, res: any) => { res.send(42); diff --git a/tests/fixtures/pages-basic/pages/api/echo-body.ts b/tests/fixtures/pages-basic/pages/api/echo-body.ts new file mode 100644 index 00000000..548eec44 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/api/echo-body.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + res.status(200).json({ body: req.body }); +} diff --git a/tests/fixtures/pages-basic/pages/api/send-buffer.ts b/tests/fixtures/pages-basic/pages/api/send-buffer.ts new file mode 100644 index 00000000..97ee7107 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/api/send-buffer.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(_req: NextApiRequest, res: NextApiResponse) { + res.status(200).send(Buffer.from([1, 2, 3])); +} diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index c3949321..ce449063 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -187,6 +187,43 @@ 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`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: "a=1&a=2&b=3", + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toEqual({ body: { a: ["1", "2"], b: "3" } }); + }); + + it("returns 400 for malformed JSON API bodies", async () => { + const res = await fetch(`${baseUrl}/api/echo-body`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: "{invalid json", + }); + + expect(res.status).toBe(400); + expect(await res.text()).toBe("Invalid JSON"); + }); + + it("sends Buffer payloads from res.send() as raw bytes", async () => { + const res = await fetch(`${baseUrl}/api/send-buffer`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/octet-stream"); + expect(res.headers.get("content-length")).toBe("3"); + + const body = Buffer.from(await res.arrayBuffer()); + expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + it("returns 404 for non-existent API routes", async () => { const res = await fetch(`${baseUrl}/api/nonexistent`); expect(res.status).toBe(404); @@ -1797,6 +1834,43 @@ describe("Production server middleware (Pages Router)", () => { expect(body.equals(Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0x61, 0x62, 0x63]))).toBe(true); }); + it("preserves repeated urlencoded API body keys in production", async () => { + const res = await fetch(`${prodUrl}/api/echo-body`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: "a=1&a=2&b=3", + }); + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data).toEqual({ body: { a: ["1", "2"], b: "3" } }); + }); + + it("returns 400 for malformed JSON API bodies in production", async () => { + const res = await fetch(`${prodUrl}/api/echo-body`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: "{invalid json", + }); + + expect(res.status).toBe(400); + expect(await res.text()).toBe("Invalid JSON"); + }); + + it("sends Buffer payloads from res.send() as raw bytes in production", async () => { + const res = await fetch(`${prodUrl}/api/send-buffer`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/octet-stream"); + expect(res.headers.get("content-length")).toBe("3"); + + const body = Buffer.from(await res.arrayBuffer()); + expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + it("defaults to application/octet-stream for API routes without Content-Type", async () => { const res = await fetch(`${prodUrl}/api/no-content-type`); expect(res.status).toBe(200);