From 1ca97c9203c8cab15444919f37090c07e71724a6 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 18:49:27 -0500 Subject: [PATCH 1/4] Fix Buffer handling in Pages API res --- .../vinext/src/entries/pages-server-entry.ts | 53 ++++++++---- packages/vinext/src/server/api-handler.ts | 42 +++++++--- .../entry-templates.test.ts.snap | 81 ++++++++++++------- tests/api-handler.test.ts | 59 +++++++++++--- .../pages-basic/pages/api/echo-body.ts | 5 ++ .../pages-basic/pages/api/send-buffer.ts | 5 ++ tests/pages-router.test.ts | 72 +++++++++++++++++ 7 files changed, 246 insertions(+), 71 deletions(-) create mode 100644 tests/fixtures/pages-basic/pages/api/echo-body.ts create mode 100644 tests/fixtures/pages-basic/pages/api/send-buffer.ts diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index d41a5e80..f0075528 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,13 @@ 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; + } +} + // ISR cache helpers (inlined for the server entry) async function isrGet(key) { const handler = getCacheHandler(); @@ -631,8 +639,15 @@ 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"; + 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 +1053,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..0976f731 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,15 @@ interface NextApiResponse extends ServerResponse { */ const MAX_BODY_SIZE = 1 * 1024 * 1024; +class ApiBodyParseError extends Error { + constructor( + message: string, + readonly statusCode: number, + ) { + super(message); + } +} + /** * Parse the request body based on content-type. * Enforces a size limit to prevent memory exhaustion attacks. @@ -77,15 +87,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 +140,14 @@ function enhanceApiObjects( }; apiRes.send = function (data: unknown) { + if (Buffer.isBuffer(data)) { + if (!this.getHeader("Content-Type")) { + this.setHeader("Content-Type", "application/octet-stream"); + } + this.end(data); + return; + } + if (typeof data === "object" && data !== null) { this.setHeader("Content-Type", "application/json"); this.end(JSON.stringify(data)); @@ -224,12 +237,17 @@ export async function handleApiRoute( ).catch(() => { /* ignore reporting errors */ }); - if ((e as Error).message === "Request body too large") { - res.statusCode = 413; - res.end("Request body too large"); + if (e instanceof ApiBodyParseError) { + res.statusCode = e.statusCode; + res.end(e.message); } else { - res.statusCode = 500; - res.end("Internal Server Error"); + if ((e as Error).message === "Request body too large") { + res.statusCode = 413; + res.end("Request body too large"); + } else { + res.statusCode = 500; + res.end("Internal Server Error"); + } } return true; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index d5287198..2c720da5 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17508,6 +17508,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"; @@ -17537,6 +17538,13 @@ 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}],"rewrites":{"beforeFiles":[{"source":"/before-rewrite","destination":"/about"},{"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; + } +} + // ISR cache helpers (inlined for the server entry) async function isrGet(key) { const handler = getCacheHandler(); @@ -17622,12 +17630,14 @@ import * as page_28 from "/tests/fixtures/pages-basic/pages/products/[pid] import * as page_29 from "/tests/fixtures/pages-basic/pages/docs/[...slug].tsx"; import * as page_30 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"; @@ -17668,12 +17678,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) { @@ -17963,8 +17975,15 @@ 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"; + 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 }); } @@ -18370,28 +18389,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..70b944a6 100644 --- a/tests/api-handler.test.ts +++ b/tests/api-handler.test.ts @@ -66,7 +66,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 +93,7 @@ function mockRes(): http.ServerResponse & { } } }, - end(data?: string) { + end(data?: string | Buffer) { if (data !== undefined) { res._body = data; } @@ -101,7 +101,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 +181,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 +191,9 @@ 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"); }); it("parses application/x-www-form-urlencoded body", async () => { @@ -213,6 +212,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 +436,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 +452,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 +466,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 +482,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 +499,21 @@ 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(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 3dd65821..9fa624ad 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -187,6 +187,42 @@ 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"); + + 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); @@ -1784,6 +1820,42 @@ 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"); + + 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); From 09d6224ae0c54e755c25094e7ff269fde1392a2e Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Tue, 10 Mar 2026 19:29:53 -0500 Subject: [PATCH 2/4] fix: skip reporting handled Pages API parse errors --- .../vinext/src/entries/pages-server-entry.ts | 1 + packages/vinext/src/server/api-handler.ts | 22 ++++++++++--------- .../entry-templates.test.ts.snap | 1 + tests/api-handler.test.ts | 13 ++++++++++- tests/pages-router.test.ts | 1 + 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index f0075528..f75eeed5 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -282,6 +282,7 @@ class ApiBodyParseError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; + this.name = "ApiBodyParseError"; } } diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 0976f731..6099a3e4 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -46,6 +46,7 @@ class ApiBodyParseError extends Error { readonly statusCode: number, ) { super(message); + this.name = "ApiBodyParseError"; } } @@ -144,6 +145,7 @@ function enhanceApiObjects( if (!this.getHeader("Content-Type")) { this.setHeader("Content-Type", "application/octet-stream"); } + this.setHeader("Content-Length", String(data.length)); this.end(data); return; } @@ -219,6 +221,11 @@ 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( @@ -237,17 +244,12 @@ export async function handleApiRoute( ).catch(() => { /* ignore reporting errors */ }); - if (e instanceof ApiBodyParseError) { - res.statusCode = e.statusCode; - res.end(e.message); + if ((e as Error).message === "Request body too large") { + res.statusCode = 413; + res.end("Request body too large"); } else { - if ((e as Error).message === "Request body too large") { - res.statusCode = 413; - res.end("Request body too large"); - } else { - res.statusCode = 500; - res.end("Internal Server Error"); - } + res.statusCode = 500; + res.end("Internal Server Error"); } return true; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 2c720da5..526603be 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17542,6 +17542,7 @@ class ApiBodyParseError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; + this.name = "ApiBodyParseError"; } } diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts index 70b944a6..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 ────────────────────────────────────────────────────────────── /** @@ -194,6 +202,8 @@ describe("handleApiRoute", () => { 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 () => { @@ -510,6 +520,7 @@ describe("handleApiRoute", () => { 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); }); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 9fa624ad..e4c2a91f 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -218,6 +218,7 @@ describe("Pages Router integration", () => { 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); From b41e76e20448b7abf69802ec30fe2d672d03d593 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 11:02:04 +0000 Subject: [PATCH 3/4] address review: blank line nit, content-length parity for Buffer, fix duplicate Content-Length in sendCompressed --- packages/vinext/src/server/api-handler.ts | 1 + packages/vinext/src/server/prod-server.ts | 5 ++++- tests/pages-router.test.ts | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 6099a3e4..74f9048f 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -226,6 +226,7 @@ export async function handleApiRoute( 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/pages-router.test.ts b/tests/pages-router.test.ts index a3286180..ce449063 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1865,6 +1865,7 @@ describe("Production server middleware (Pages Router)", () => { 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); From f3f03f74d9d654a52b4db9cac8e1065b3cdae329 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Mar 2026 11:12:29 +0000 Subject: [PATCH 4/4] fix: set content-length for Buffer in production res.send() to match dev parity --- packages/vinext/src/entries/pages-server-entry.ts | 1 + tests/__snapshots__/entry-templates.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index f75eeed5..18fb3bc2 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -642,6 +642,7 @@ function createReqRes(request, url, query, body) { 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); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8b0f16f2..b7c21660 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -18059,6 +18059,7 @@ function createReqRes(request, url, query, body) { 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);