Skip to content

refactor: extract Pages API route runtime#683

Merged
james-elicx merged 3 commits intomainfrom
codex/pages-api-route-runtime
Mar 28, 2026
Merged

refactor: extract Pages API route runtime#683
james-elicx merged 3 commits intomainfrom
codex/pages-api-route-runtime

Conversation

@southpolesteve
Copy link
Copy Markdown
Collaborator

Summary

  • move the Pages Router API route request/body parsing and Node-style req/res facade into typed server helpers
  • keep pages-server-entry.ts focused on generated wiring by delegating API route execution to handlePagesApiRoute
  • add direct helper coverage for duplicate query/body keys, invalid JSON, oversized bodies, and Buffer responses

Verification

  • vp check packages/vinext/src/server/pages-node-compat.ts packages/vinext/src/server/pages-api-route.ts packages/vinext/src/entries/pages-server-entry.ts tests/pages-api-route.test.ts tests/entry-templates.test.ts
  • vp test run tests/pages-api-route.test.ts tests/entry-templates.test.ts
  • vp test run tests/pages-router.test.ts -t "handles API routes returning JSON|handles dynamic API routes with query params|returns 400 for invalid JSON bodies on Pages API routes|preserves duplicate urlencoded body keys on Pages API routes|sends Buffer payloads from res.send\(\) as raw bytes|preserves invalid JSON failures for Pages API routes in production|preserves duplicate urlencoded body keys for Pages API routes in production|parses empty JSON bodies for Pages API routes in production as \{}|sends Buffer payloads from res.send\(\) as raw bytes in production|preserves content-length for getServerSideProps res.end\(\) short-circuit responses in production"
  • vp run vinext#build

Written by Codex.

@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 25, 2026

Open in StackBlitz

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

commit: a8f925e

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

Copy link
Copy Markdown
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.

Review

Good extraction that aligns with the "generated entries should stay thin" principle from AGENTS.md. The entry now delegates to typed, testable helpers, and the new tests cover the key paths. A few things to address:

Behavioral change: writeHead now lowercases non-set-cookie headers

Old code (in the generated entry):

resHeaders[k] = v;

New code (pages-node-compat.ts:182):

resHeaders[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;

The old writeHead stored non-set-cookie header keys as-is (preserving original casing from the caller). The new code lowercases them. This is arguably more correct (and consistent with setHeader which always lowercased), but it is a behavior change. Since end() passes them to new Headers() which is case-insensitive anyway, the practical impact is minimal — but worth noting as intentional.

The old code also stored raw values directly (resHeaders[k] = v), so passing an array for a non-set-cookie header would store the array as-is, which would break new Headers() construction. The new code joins arrays with ", ". This is actually a fix.

Pre-existing bug: setHeader appends to setCookieHeaders instead of replacing

Both old and new code have this issue: setHeader("set-cookie", ...) always appends to setCookieHeaders. In Node.js, res.setHeader() is supposed to replace the previous value for that header name. Calling res.setHeader("set-cookie", "a=1") twice should result in one cookie, not two.

Not introduced by this PR, but worth tracking as a follow-up since this is now the canonical location for this logic.

Fragile error dispatch in pages-api-route.ts:76

if ((error as Error).message === "Request body too large") {
  return new Response("Request body too large", { status: 413 });
}

Matching on error message strings is fragile. parsePagesApiBody already uses PagesApiBodyParseError for structured error dispatch. Consider doing the same for body-too-large — e.g., a typed error class or reusing PagesApiBodyParseError with status 413, thrown from parsePagesApiBody / readPagesRequestBodyWithLimit. This would make the catch clause more robust and avoid accidental matches.

Duplication with api-handler.ts

The dev server (api-handler.ts) has parallel implementations: ApiBodyParseError, getMediaType, isJsonMediaType, parseBody, parseCookies. The new pages-node-compat.ts has equivalent versions for Web Request. They serve different environments (Node.js streams vs Web Request) so they can't be trivially unified, but the media-type helpers and error class could be shared to reduce drift. Not blocking.

Test coverage suggestions

The tests cover the important happy and error paths. A few missing scenarios worth adding given the stated goal of "add direct helper coverage":

  1. match with no default export — the typeof handler !== "function" branch (pages-api-route.ts:51)
  2. match: null — the 404 path
  3. createPagesReqRes directlyres.redirect(), res.setHeader/res.getHeader/res.writeHead, res.send() with object/string data. The Node compat layer is complex enough to warrant direct unit tests.

These are covered by integration tests in pages-router.test.ts, so not blocking, but would strengthen the helper test suite.

Overall

Clean refactor. The API surface is well-typed, the delegation pattern from the generated entry is thin, and the new files are well-structured. The fragile error message matching is the main thing I'd address before merging.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 25, 2026

The review has been posted successfully on PR #683. Here's a summary of the key findings:

Overall: Clean refactor that correctly follows the project's architecture guidelines. The generated entry is now thin, delegating to well-typed helpers in pages-node-compat.ts and pages-api-route.ts.

Key findings:

  1. Fragile error dispatch (main actionable item) — pages-api-route.ts:76 matches on error.message === "Request body too large" instead of using a typed error class. This should use structured error dispatch like the existing PagesApiBodyParseError.

  2. Intentional behavioral improvementwriteHead now lowercases non-set-cookie header keys and joins array values with ", ", which fixes a latent bug in the old code.

  3. Pre-existing bug notedsetHeader("set-cookie", ...) appends instead of replacing, which differs from Node.js semantics. Not introduced by this PR.

  4. Duplication with api-handler.ts — media-type helpers and error classes could be shared to reduce drift. Not blocking.

  5. Test coverage suggestions — missing direct tests for the 404 path, missing-export path, and createPagesReqRes methods (redirect, setHeader, getHeader, writeHead).

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@southpolesteve Hey Steve, just wanna ask, is Vinext dead now that Next is prepping their official adapter for Cloudflare?

@southpolesteve
Copy link
Copy Markdown
Collaborator Author

southpolesteve commented Mar 27, 2026

Not dead at all! They announced the adapters API as stable, but there are still major limitations that adapters cannot address. You are still locked into the Turbopack toolchain, and adapters only cover build and deploy. During development, next dev runs exclusively in Node.js with no way to plug in a different runtime.

From the vinext announcement blog:

Next.js has been working on a first-class adapters API, and we've been collaborating with them on it. It's still an early effort but even with adapters, you're still building on the bespoke Turbopack toolchain. And adapters only cover build and deploy. During development, next dev runs exclusively in Node.js with no way to plug in a different runtime. If your application uses platform-specific APIs like Durable Objects, KV, or AI bindings, you can't test that code in dev without workarounds.

So far, vinext has shown that the ceiling for performance and DX is significantly higher than what the current Next.js setup allows.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

prior feedback responses:

  • writeHead now lowercases non-set-cookie headers -> makes logical sense and an improvement.
  • setHeader appends to setCookieHeaders instead of replacing -> will address in follow-up.
  • Fragile error dispatch in pages-api-route.ts:76 -> will address in follow-up.
  • Duplication with api-handler.ts -> will address in follow-up.

Copy link
Copy Markdown
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.

Review

Good extraction — the entry is now thin and delegates to well-typed helpers, consistent with the AGENTS.md "generated entries should stay thin" principle. The previous review covered the main structural points well. I'll focus on concrete items to address before merging.

1. Fragile "Request body too large" string match (blocking)

pages-api-route.ts:76 catches an error by matching on its .message:

if ((error as Error).message === "Request body too large") {
  return new Response("Request body too large", { status: 413 });
}

But the error thrown at pages-node-compat.ts:86, pages-node-compat.ts:103, and pages-node-compat.ts:109 is a plain Error, not a typed class. Meanwhile PagesApiBodyParseError already exists for structured dispatch. Two options:

Option A (preferred): Throw PagesApiBodyParseError with statusCode: 413 from readPagesRequestBodyWithLimit and the content-length check in parsePagesApiBody. Then the single instanceof PagesApiBodyParseError catch in handlePagesApiRoute handles both 400 and 413 cases. The separate string-match branch disappears entirely.

Option B: Create a dedicated PagesApiBodyTooLargeError class. Less elegant but works.

Either way, the current string match is fragile — any future error with that message text would be silently swallowed as a 413.

2. setHeader("set-cookie", ...) appends instead of replacing (pre-existing, not blocking)

Both setHeader (line 188) and writeHead (line 171) unconditionally append to setCookieHeaders:

setHeader(name, value) {
  if (name.toLowerCase() === "set-cookie") {
    // ...
    setCookieHeaders.push(String(value));  // always appends

In Node.js, res.setHeader() replaces the previous value. Calling res.setHeader("set-cookie", "a=1") twice should result in one cookie, not two. writeHead has the same issue — Node.js writeHead merges with previously set headers but doesn't double-append set-cookie values that were already set via setHeader.

Not introduced by this PR, but since this is now the canonical location for this logic, it's worth a tracking issue. If a Pages API handler does res.setHeader("set-cookie", "session=abc"); res.setHeader("set-cookie", "session=xyz");, the response will have both cookies instead of just the replacement.

3. parsePagesApiBody empty-body branch for form-urlencoded is unreachable (minor)

At pages-node-compat.ts:113-118:

if (!rawBody) {
  return isJsonMediaType(mediaType)
    ? {}
    : mediaType === "application/x-www-form-urlencoded"
      ? decodeQueryString(rawBody)  // rawBody is "" here
      : undefined;
}

When rawBody is "" and media type is application/x-www-form-urlencoded, this calls decodeQueryString("") which returns {}. This works correctly, but the intent is confusing — it looks like it should return undefined or {} for an empty form POST. Consider adding a comment clarifying this is intentional, or simplifying to just {} for both JSON and form-urlencoded empty bodies since decodeQueryString("") always returns {}.

4. res.end() is called unconditionally after the handler (pages-api-route.ts:66-67)

await handler(req, res);
res.end();

This is safe because end() has an if (ended) return; guard. But it means if the handler returns without calling res.end(), res.json(), res.send(), or res.redirect(), the response resolves with whatever status/headers/body were set so far (defaulting to 200, empty body). This is correct and matches the safety-net pattern. Just noting it's intentional behavior.

5. Test coverage gaps (non-blocking, suggestions)

The tests cover the happy paths well. A few missing scenarios that would strengthen the helper test suite:

  • match: null → 404 response — trivial to add, one assertion
  • Missing default export → 500createMatch(undefined as any) or similar
  • res.redirect() — both the 1-arg and 2-arg forms
  • res.writeHead() with headers — verify lowercasing and array joining
  • res.setHeader / res.getHeader round-trip — especially for set-cookie

These are covered by integration tests in pages-router.test.ts, so not blocking.

Overall

Clean, well-structured extraction. The PagesApiBodyParseError string-match issue (item 1) is the only thing I'd address before merging — the rest are suggestions and pre-existing items worth tracking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review posted on PR #683. Here's a summary of the key findings:

Blocking:

  1. Fragile "Request body too large" string matchpages-api-route.ts:76 catches errors by matching on .message text, but the errors thrown are plain Error instances. Should use PagesApiBodyParseError with statusCode: 413 instead, which would let the single instanceof catch handle both 400 and 413 cases and eliminate the fragile string comparison entirely.

Non-blocking observations:
2. setHeader("set-cookie", ...) appends instead of replacing — pre-existing bug, now in its canonical location. Worth a tracking issue.
3. Empty-body form-urlencoded branch — works correctly but the intent is confusing; could use a clarifying comment.
4. res.end() called unconditionally after handler — intentional safety net, correctly guarded.
5. Test coverage suggestionsmatch: null, missing default export, res.redirect(), res.writeHead(), and setHeader/getHeader round-trips would strengthen the helper test suite.

github run

- Replace fragile error.message string matches with typed PagesBodyParseError
  (statusCode: 413) thrown from pages-node-compat.ts and api-handler.ts; the
  existing instanceof catch in handlePagesApiRoute handles both 400 and 413
- Fix res.setHeader('set-cookie') to replace instead of append on repeated
  calls, matching Node.js ServerResponse semantics
- Extract shared getMediaType/isJsonMediaType helpers and the body-parse error
  class into pages-media-type.ts to eliminate drift between api-handler.ts and
  pages-node-compat.ts
- Add unit tests for 404 (null match), missing default export (500), redirect
  (1-arg and 2-arg), writeHead header lowercasing/array joining, setHeader/
  getHeader round-trip, and set-cookie replace-not-append parity
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review

Copy link
Copy Markdown
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.

Review

The fix commit (a8f925e) addresses the two blocking items from the previous reviews cleanly:

  1. Fragile string match → structured error dispatch: readPagesRequestBodyWithLimit and parsePagesApiBody now throw PagesBodyParseError with statusCode: 413, and the catch in handlePagesApiRoute uses a single instanceof PagesBodyParseError branch. The separate string-match branch is gone.

  2. setHeader("set-cookie", ...) replace semantics: setCookieHeaders.length = 0 before push. Verified by the new "res.setHeader replaces set-cookie on repeated calls" test.

The fix commit also added the test coverage gaps identified in previous reviews: match: null → 404, missing default export → 500, res.redirect() (1-arg and 2-arg), res.writeHead() header lowercasing/array joining, res.setHeader/res.getHeader round-trip.

Shared error class consolidation is done well. PagesBodyParseError was a duplicate of PagesApiBodyParseError — the fix extracts both into PagesBodyParseError in pages-media-type.ts and re-exports the old name as a deprecated alias for backwards compat. Both api-handler.ts (Node.js dev path) and pages-node-compat.ts (fetch-based prod path) now import from the shared module.

All 12 helper tests and all 20 entry-template tests pass.

LGTM — this is ready to merge.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

The review has been posted on PR #683 with an approval. Here's a summary:

Verdict: Approved

The fix commit (a8f925e) cleanly addresses all blocking items from the two prior reviews:

  1. Fragile string match eliminatedPagesBodyParseError with statusCode: 413 is now thrown from all body-size-exceeded paths, and the catch clause uses instanceof dispatch. No more string matching.

  2. setHeader("set-cookie") now replacessetCookieHeaders.length = 0 before pushing new values, matching Node.js semantics. Covered by a new test.

  3. Error class consolidatedPagesBodyParseError lives in pages-media-type.ts and is shared by both the Node.js dev path (api-handler.ts) and the fetch-based prod path (pages-node-compat.ts). The old PagesApiBodyParseError name is kept as a deprecated re-export.

  4. Test coverage gaps filled — 6 new test cases covering 404, missing export, redirect, writeHead, setHeader/getHeader round-trip, and set-cookie replace semantics.

All 12 helper tests and 20 entry-template tests pass.

github run

@james-elicx james-elicx merged commit da04d20 into main Mar 28, 2026
31 checks passed
@james-elicx james-elicx deleted the codex/pages-api-route-runtime branch March 28, 2026 09:36
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.

3 participants