-
Notifications
You must be signed in to change notification settings - Fork 221
fix: align Pages API body parsing and res.send(Buffer) #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1ca97c9
09d6224
37eb489
b41e76e
f3f03f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dev/prod parity gap: the dev-mode This means the same API handler will return
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dev/prod parity gap: the dev-mode
Suggested change
|
||||||||||||||||
| } 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) { | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same observation as in |
||||||||||||||||
| return new Response(e.message, { status: e.statusCode }); | ||||||||||||||||
| } | ||||||||||||||||
| console.error("[vinext] API error:", e); | ||||||||||||||||
| return new Response("Internal Server Error", { status: 500 }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<unknown> { | |||||||||||||||||
| 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<string, string> = {}; | ||||||||||||||||||
| 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) { | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Next.js's |
||||||||||||||||||
| 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) { | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the previous reviewer and Codex both flagged this, and the test already asserts
Suggested change
Trivial — feel free to ignore. |
||||||||||||||||||
| res.statusCode = e.statusCode; | ||||||||||||||||||
| res.end(e.message); | ||||||||||||||||||
| return true; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| server.ssrFixStacktrace(e as Error); | ||||||||||||||||||
| console.error(e); | ||||||||||||||||||
| reportRequestError( | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good fix. The Minor note: the compressed branch (line 190) still spreads |
||
| res.writeHead(statusCode, { | ||
| ...extraHeaders, | ||
| ...headersWithoutLength, | ||
| "Content-Type": contentType, | ||
| "Content-Length": String(buf.length), | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: in the generated (untyped) code,
statusCodeis assigned as a property on the instance but never declared. This works fine at runtime, but it might be clearer to assign it explicitly:Setting
namemakes stack traces more readable when this error does get logged (e.g., during debugging). Optional.