* fix(pages-router): support stream proxying in API routes
* fix(pages-router): address review feedback on stream proxying PR
- Narrow auto-end condition to only skip ending when handler returns res
(the destination of req.pipe(...).pipe(res)), preventing hangs from
non-stream return values.
- Replace full-body buffering with a PassThrough-backed PagesResponseStream
that resolves on first write, enabling real streaming responses.
- Add error handling to reject responsePromise when the response stream
errors, preventing silent request hangs.
- Add regression tests: non-stream return auto-end, multi-chunk write,
and response stream error handling.
* fix(pages-router): avoid hanging responses and PassThrough unhandled errors
- Narrow auto-end condition to to prevent non-stream
return values from hanging requests.
- Replace PassThrough + Readable.toWeb() with a direct ReadableStream controller
in PagesResponseStream to eliminate Controller is already closed unhandled
errors during stream proxying.
- Add destroy() override to error/close the stream body when the Node Writable
is destroyed after streaming starts.
- Add rejectResponse callback to PagesResponseStream constructor for proper
error propagation.
- Add regression tests for auto-end, multi-chunk streaming, and destroy error
handling.
* fix(pages-router): destroy Node side when Fetch body is cancelled
- Reject writes in _write() when streamEnded or destroyed to prevent
buffering chunks after cancellation.
- In the ReadableStream cancel handler, clear bufferedChunks and destroy
the Node-compatible PagesResponseStream so upstream writers receive
an error instead of silently accumulating data.
- Add regression test proving writes after body cancellation fail with
an error and do not accumulate in the bridge.
- Rename multi-chunk test to be more accurate about what it verifies.
* fix(pages-router): settle response when res.destroy() is called without error
- In destroy(), if the response has not been resolved yet, resolve the
response promise (with a closed stream) when no error is provided, or
reject it when an error is provided. This prevents the request from
hanging forever when a handler calls res.destroy() before any body
write and returns the res object.
- Add regression test proving res.destroy() without error resolves to an
empty 200 response instead of hanging.
* chore: trigger CI run for flaky E2E test validation
* fix(pages-router): track pipe event and fix request body cancellation
- Add `res.once("pipe", ...)` listener to detect when a handler pipes into the response without returning the writable. Skipping auto-end by return-value only missed the real-world pattern:
(req, res) => { req.pipe(upstream).pipe(res); } // returns undefined
Next.js uses the pipe event signal, not the handler return value.
- Fix request body cancellation: the async generator finally block now calls `reader.cancel()` before `releaseLock()`, restoring the pre-PR behavior where early return from `for await (chunk of req)` cancelled the underlying stream instead of just releasing the lock.
- Add regression tests for both pipe-without-return and pipe-with-return.
* fix(pages-router): drop handlerResult === res auto-end signal, rely on pipe event only
The `handlerResult === res` check was too broad — chainable helpers like
`return res.status(202)` return `this` without implying streaming, and would
cause a hang by skipping auto-end. Now that `res.once("pipe", ...)` provides
the real stream signal, the return-value check is redundant and harmful.
Removes:
- handlerResult variable and === res comparison
- comment about returning the writable
Adds:
- regression test for `return res.status(202)` not hanging
* fix(pages-router): address stream response review
* fix(pages-router): close IncomingMessage parity gaps in API route streams
- Yield Buffer chunks (zero-copy views) from the request readable and opt
out of objectMode so req matches IncomingMessage byte-stream semantics:
per-chunk chunk.toString("utf8") decodes instead of comma-joining byte
values, and setEncoding()/read(n) behave byte-wise.
- Honour config.api.externalResolver: Next.js never auto-ends a response,
and externalResolver: true is the documented contract for handlers that
respond after the handler promise settles (e.g. proxy middleware that
attaches its pipe asynchronously). Skip the auto-end safety net for such
routes so they are not truncated to an empty response.
---------
Co-authored-by: James <james@eli.cx>
Next.js Pages Router API handlers using `export const config = { api:
{ bodyParser: false } }` drain the raw bytes by iterating `req` directly
(`for await (const chunk of req)`), matching Node's IncomingMessage
contract. vinext's Workers-path req was a plain object with `body` set
to the underlying ReadableStream, so the idiom threw with 500 — breaking
webhook handlers and the `middleware-fetches-with-body` deploy suite.
Surface an `[Symbol.asyncIterator]` on the synthetic `PagesReqResRequest`
that proxies the underlying Web ReadableStream reader (with a `return()`
cleanup so `break` propagates a cancel). Leave `req.body` undefined when
`bodyParser: false`, matching Next.js (`parse-body.ts` only assigns
`req.body` when `bodyParser !== false`).
Ports the relevant assertion from
test/e2e/middleware-fetches-with-body/index.test.ts in the Next.js repo.
* fix(security): honor pages API bodyParser config (F-PROD-4)
Pages Router API routes export configuration like:
export const config = { api: { bodyParser: false } };
export const config = { api: { bodyParser: { sizeLimit: '4mb' } } };
vinext silently ignored this, hard-coding a 1 MB limit and always
parsing the body. That created a security downgrade for webhook
handlers (Stripe, GitHub, Slack, etc.) that depend on
'bodyParser: false' to read the raw request stream for HMAC
signature verification. By the time the user handler ran, the body
had already been consumed and parsed into 'req.body', so HMAC
verification would either fail closed (no signature match) or fail
open (handlers that fall back to an empty body for unsupported
content types).
Legitimate large uploads were also rejected with 413 even when the
route declared 'sizeLimit: "4mb"' or higher.
This change introduces a shared helper that resolves the route
module's 'config.api.bodyParser' option and patches both code paths:
- Node/dev path: packages/vinext/src/server/api-handler.ts
- Workers/prod: packages/vinext/src/server/pages-api-route.ts
(+ packages/vinext/src/server/pages-node-compat.ts)
Behaviour now matches Next.js
(packages/next/src/server/api-utils/node/api-resolver.ts):
- bodyParser: false -> skip parsing entirely. Node
handlers receive the raw
IncomingMessage stream;
Workers handlers receive
request.body as a
ReadableStream<Uint8Array>
on 'req.body'.
- bodyParser: { sizeLimit: '4mb' } -> parse with the user-supplied
limit instead of the
hard-coded 1 MB.
- bodyParser: true / undefined -> existing 1 MB default behaviour.
The sizeLimit parser accepts the same suffix grammar Next.js does
('500b', '100kb', '4mb', '1gb'), implemented inline rather than
pulling the 'bytes' package.
Tests cover both Node and Workers paths plus parser-level unit
tests, including:
- bodyParser: false leaves the stream intact for HMAC verification
- sizeLimit '4mb' accepts a 2 MB POST that used to 413
- sizeLimit '4mb' rejects a 5 MB POST with 413
- sizeLimit '100kb' rejects a 200 KB POST with 413
- No config preserves the 1 MB default
config.api.responseLimit is not in scope for this PR and remains a
follow-up.
Reference: Finding F-PROD-4 in SECURITY-AUDIT-2026-05.md
* chore(security): drop unused ResolvedBodyParserConfig export to satisfy knip