Skip to content

fix: align Pages API body parsing and res.send(Buffer)#428

Open
JaredStowell wants to merge 2 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-pages-api-body-parity
Open

fix: align Pages API body parsing and res.send(Buffer)#428
JaredStowell wants to merge 2 commits intocloudflare:mainfrom
JaredStowell:jstowell/fix-pages-api-body-parity

Conversation

@JaredStowell
Copy link
Contributor

Summary

Fix three Pages Router API compatibility gaps to match Next.js behavior more closely:

  • res.send(Buffer) now sends raw bytes with application/octet-stream instead of JSON-stringifying Buffer metadata
  • application/x-www-form-urlencoded body parsing now preserves repeated keys as arrays
  • malformed JSON bodies now return 400 Invalid JSON instead of falling through as a raw string

This updates both Pages API paths so dev and production stay in sync:

  • packages/vinext/src/server/api-handler.ts
  • packages/vinext/src/entries/pages-server-entry.ts

Tests

Added coverage for all three cases in:

  • tests/api-handler.test.ts
  • tests/pages-router.test.ts

Added Pages Router fixture routes for integration coverage:

  • tests/fixtures/pages-basic/pages/api/echo-body.ts
  • tests/fixtures/pages-basic/pages/api/send-buffer.ts

Updated:

  • tests/__snapshots__/entry-templates.test.ts.snap

Verification

Ran:

  • pnpm test tests/api-handler.test.ts
  • pnpm test tests/pages-router.test.ts
  • pnpm test tests/entry-templates.test.ts -u
  • pnpm run fmt
  • pnpm run typecheck
  • pnpm test

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@428

commit: af96f33

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good set of fixes — all three changes align vinext closer to Next.js behavior, and the dev/prod parity is maintained across both code paths. Tests are thorough with both unit and integration coverage for each fix.

A few observations below, one minor issue and the rest are nits.

if ((e as Error).message === "Request body too large") {
res.statusCode = 413;
res.end("Request body too large");
if (e instanceof ApiBodyParseError) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor issue: when an ApiBodyParseError is thrown, the code still calls server.ssrFixStacktrace(e) and console.error(e) and reportRequestError(...) before checking the error type. For a routine 400 (malformed JSON from the client), this will:

  1. Log a full stack trace to the console
  2. Report it as a request error to instrumentation

This is noisy for what is really a client error, not a server error. Consider checking for ApiBodyParseError first and returning early before the logging/reporting, or at least downgrading the log level.

Suggested change
if (e instanceof ApiBodyParseError) {
if (e instanceof ApiBodyParseError) {
res.statusCode = e.statusCode;
res.end(e.message);
return true;
}
server.ssrFixStacktrace(e as Error);
console.error(e);
reportRequestError(
e instanceof Error ? e : new Error(String(e)),
{
path: url,
method: req.method ?? "GET",
headers: Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [
k,
Array.isArray(v) ? v.join(", ") : String(v ?? ""),
]),
),
},
{ routerKind: "Pages Router", routePath: match.route.pattern, routeType: "route" },
).catch(() => {
/* ignore reporting errors */
});

res.end();
return await responsePromise;
} catch (e) {
if (e instanceof ApiBodyParseError) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same observation as in api-handler.ts — in the production entry, the ApiBodyParseError check happens after the generic console.error("[vinext] API error:", e) line. A 400 from malformed JSON will get logged as [vinext] API error: which is misleading since it's a client error. Consider reordering the check before the console.error.

@@ -135,6 +140,14 @@ function enhanceApiObjects(
};

apiRes.send = function (data: unknown) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Next.js's res.send() also handles Buffer-like objects via the content-type header and sets the Content-Length header. This implementation doesn't set Content-Length for the Buffer case. Not a blocker — just noting the difference for future reference.

export const vinextConfig = ${vinextConfigJson};

class ApiBodyParseError extends Error {
constructor(message, statusCode) {
Copy link
Contributor

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, statusCode is assigned as a property on the instance but never declared. This works fine at runtime, but it might be clearer to assign it explicitly:

Suggested change
constructor(message, statusCode) {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = 'ApiBodyParseError';
}

Setting name makes stack traces more readable when this error does get logged (e.g., during debugging). Optional.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean PR — all three fixes (Buffer send, urlencoded array preservation, malformed JSON 400) are correct and well-tested with both unit and integration coverage for dev and prod paths.

Key feedback:

  1. Minor issue — noisy error logging for client errors (api-handler.ts:240 and pages-server-entry.ts:1079): When ApiBodyParseError is thrown (malformed JSON from client), the catch block still calls console.error(e), ssrFixStacktrace, and reportRequestError before checking the error type. This logs a full stack trace and reports a client error (400) as if it were a server error. Suggested reordering the instanceof check before the logging/reporting to avoid noise.

  2. Nit — no Content-Length for Buffer (api-handler.ts:142): Next.js sets Content-Length when sending Buffers via res.send(). Not a blocker but noted for future alignment.

  3. Nit — setting name on ApiBodyParseError (pages-server-entry.ts:282): In the generated untyped code, adding this.name = 'ApiBodyParseError' would make stack traces more readable during debugging.

github run

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ca97c9203

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@james-elicx
Copy link
Collaborator

/bigbonk re-review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR — all three fixes are correct and well-tested across both dev and prod paths. One real dev/prod parity gap to address, plus a couple of nits.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev/prod parity gap: the dev-mode api-handler.ts sets Content-Length for Buffer payloads (line 148), but this production path does not. The integration test for production (sends Buffer payloads from res.send() as raw bytes in production in pages-router.test.ts) also doesn't assert content-length, while the dev test does.

This means the same API handler will return Content-Length in dev but not in production — exactly the kind of divergence AGENTS.md warns against. Should be a one-liner:

Suggested change
res.end(data);
if (!resHeaders["content-type"]) resHeaders["content-type"] = "application/octet-stream";
resHeaders["content-length"] = String(data.length);

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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the dev-mode test asserts content-length (line 209 in api-handler.test.ts), this production test should too — otherwise the parity gap mentioned above goes undetected.

Suggested change
expect(res.headers.get("content-type")).toContain("application/octet-stream");
expect(res.headers.get("content-type")).toContain("application/octet-stream");
expect(res.headers.get("content-length")).toBe("3");

method: "POST",
headers: {
"content-type": "application/json",
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here — the dev integration test should also assert content-length to match the unit test:

Suggested change
},
expect(res.headers.get("content-type")).toContain("application/octet-stream");
expect(res.headers.get("content-length")).toBe("3");

await handler(apiReq, apiRes);
return true;
} catch (e) {
if (e instanceof ApiBodyParseError) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 ssrFixStacktrace and reportRequestError are NOT called — but that assertion currently passes only because ApiBodyParseError is caught before those calls. The control flow is correct, but it would be slightly clearer to separate the early-return from the logging block with a blank line, so a future reader immediately sees the intent:

Suggested change
if (e instanceof ApiBodyParseError) {
if (e instanceof ApiBodyParseError) {
res.statusCode = e.statusCode;
res.end(e.message);
return true;
}
server.ssrFixStacktrace(e as Error);

Trivial — feel free to ignore.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review posted. Here's a summary of my feedback:

Overall: Clean PR. All three fixes (Buffer send, urlencoded array preservation, malformed JSON 400) are correct, well-motivated, and thoroughly tested with both unit and integration coverage across dev and prod paths.

Key finding — dev/prod parity gap on Content-Length:

The dev-mode api-handler.ts sets Content-Length when sending a Buffer via res.send() (line 148), but the production pages-server-entry.ts does not. The production integration test also omits the content-length assertion, so this divergence goes undetected. This is exactly the kind of dev/prod mismatch that AGENTS.md warns against. I posted a one-line suggestion fix plus test assertion updates.

Minor nit: Suggested a blank line between the ApiBodyParseError early-return and the error logging block in the dev catch handler for readability — trivial.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants