fix(server): transfer request bodies into NextRequest instead of teeing - #2741
Conversation
`Request.clone()` tees the body stream, and a tee branch that is never read buffers every chunk the other branch pulls. The NextRequest constructor cloned any body-bearing input, so wrapping the incoming request left an unread branch holding the whole body. A route handler that streams a 128 MiB upload to storage — O(1) memory by design — retained the full 128 MiB instead, letting an unauthenticated client exhaust the process or Worker isolate by repeating the request. Upstream Next.js does `super(input, init)` here, transferring the body rather than branching it. Match that. Callers that genuinely need two live branches (middleware vs. downstream routing) already clone explicitly, so nothing loses the isolation added in cloudflare#1132. The clone also defeated the mitigation added in cloudflare#2026: `executeMiddleware` cancels the middleware body branch in a `finally`, but the constructor's extra tee sat between that cancel and the branch actually accumulating chunks, so the cancel released a branch nobody was filling. With the clone gone the existing cancel reaches the real branch again. Drop the same dead tee from the basePath re-prefix path in `createTrackedAppRouteRequest`, where the source request is replaced outright and its body is never read again. Measured with a 128 MiB streamed body, reading through and discarding: before: arrayBuffers +128.0 MiB (route handler wrap, and middleware that does not touch the body) after: arrayBuffers +0.0 MiB, matching an unwrapped baseline
|
@codex review |
commit: |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Performance benchmarksCompared 1 improved · 0 regressed · 5 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full change: NextRequest transferring the body (matching Next.js super(input, init)), the removal of dead tee branches on the basePath / RSC-normalization paths, and the reworked cancellation ownership in applyAppMiddleware / executeMiddleware.
Correctness looks solid. I traced the App Router body topology end to end:
app-rsc-handler.ts:653creates the single genuine tee (request.clone()) only when middleware will run and a body is present. Branch A →isolatedMiddlewareRequest(middleware), branch B →request(downstream action/route handler at:983). Both branches are consumed, so no branch is left buffering. Verified via the newuses one isolated middleware branch for body-bearing RSC actionstest (exactly one clone, bothbodyUsed).- URL-normalization helpers (
requestWithoutRscSuffix,requestWithoutRscCacheBustingSearchParam,createTrackedAppRouteRequestbasePath re-prefix) now transfer instead of teeing. Since the source alias is discarded immediately, this removes real dead-branch buffering without changing observable behavior. - Cancellation ownership is covered on every early-return path: matcher miss / validation failure (
app-middleware.ts:281finally), middleware-returns-response (:285+app-rsc-handler.ts:681), forwarded-context-applied (:325), and external rewrite (:233). The raw downstream branch is always cancelled byapp-rsc-handler.ts:681for anykind === "response".
The executeMiddleware finally change is the highest-risk part — it flips from NODE_ENV !== "development" to always-cancel (except when the body was transferred into the response). I checked the Pages Router prod and dev callers (pages-request-pipeline.ts:391 → middleware.ts:171): they pass requestBodyAlreadyIsolated: false, so createNextRequest still clones and downstream keeps reading the original request's tee branch. Cancelling the middleware clone branch is safe there (tee source survives while the other branch reads), and in dev it fixes a latent buffering leak. The response?.body !== nextRequest.body guard correctly preserves the new Response(request.body) passthrough case.
Ran locally, all green:
tests/middleware-runtime.test.ts,tests/app-route-handler-runtime.test.ts(32)tests/app-rsc-handler.test.ts,tests/shims.test.ts(1406)tests/entry-templates.test.ts,tests/app-router-middleware-next-request.test.ts,tests/app-post-middleware-context.test.ts(73)vp checkon all 5 changed source files: clean
No blocking issues. Two minor, non-blocking observations below.
| request: userlandRequest, | ||
| }); | ||
| if (middlewareResult.kind === "response") { | ||
| if (request.body && !request.body.locked) { |
There was a problem hiding this comment.
Non-blocking: for a non-RSC request that reaches this path with no suffix and no _rsc param, userlandRequest === request (no normalization Request is constructed). In that case applyAppMiddleware already calls cancelRequestBody(options.request) at app-middleware.ts:285 on the same body, and this block calls .cancel() again. It's harmless — cancel() is idempotent and the rejection is swallowed by .catch(() => {}) — but the double-cancel intent isn't obvious from either site. Consider a short comment noting the two cancel sites can target the same stream when no URL alias was created, so a future reader doesn't try to "fix" one of them.
Also a small consistency nit: this inline if (request.body && !request.body.locked) { void request.body.cancel()... } duplicates the cancelRequestBody helper defined in app-middleware.ts. If a shared helper is reasonable to import/colocate, using it here would keep the cancel semantics in one place.
| // Middleware may transfer its request stream directly into the response. | ||
| // In that case the response owns consumption; cancelling here would | ||
| // disturb the body before the server can send it. | ||
| if (nextRequest.body && response?.body !== nextRequest.body) { |
There was a problem hiding this comment.
The switch from NODE_ENV !== "development" to always-cancel is the behavioral crux of this PR and is correct as far as I can trace, but the original dev exclusion was introduced in #2026 (8b53905) without a stated reason for the dev carve-out. The identity guard response?.body !== nextRequest.body handles the direct-passthrough case, and the new preserves a request body transferred into the middleware response in %s test pins both envs. One case the identity check does not cover: middleware that does new Response(request.clone().body) — response.body is then a tee branch, not identical to nextRequest.body, so this cancels nextRequest.body. With tee semantics the source survives (the response branch is still live), so it's safe, just re-introduces branch buffering for that unusual userland pattern. Worth a one-line comment acknowledging the guard is identity-based, not reachability-based, so the buffering tradeoff for cloned-in-middleware bodies is a documented choice rather than a surprise.
|
Review posted successfully. Here's a summary of my findings. Review SummaryPR #2741 changes Verdict: No blocking issues. The change is well-designed and correct. What I verified
Local validation (all green)
Two non-blocking notes I left inline
|
Overview
RequestNextRequesttransfers the body (super(input, init), matching Next.js) rather thaninput.clone()Why
Request.clone()is specified totee()the body stream, andteeapplies no backpressure to the slower branch: whenever one branch pulls a chunk from the source, that chunk is also enqueued into the other branch's queue. If a branch is never read, its queue grows to the full body size. Dropping the reference does not help, because the tee closure holds both branch controllers and stays reachable from the branch that is being read.The
NextRequestconstructor cloned every body-bearing input. Ordinary App Router route handling wraps the incoming request throughcreateTrackedAppRouteRequest, so the branch left behind on the caller-owned request was never read and never cancelled. A handler that streams an upload straight to storage, which should hold no more than one chunk at a time, instead retained the entire body for the life of the request. That is remotely reachable, unauthenticated on any public body-bearing route, and repeatable until the process or Worker isolate runs out of memory.The clone also disabled an existing mitigation.
executeMiddlewarealready cancels the middleware body branch in afinally(added in #2026). The constructor's clone inserted a second tee between that cancel and the branch actually accumulating chunks, so the cancel released a branch nothing was filling while the real one kept growing one level up. Removing the clone reconnects that cancel to the stream it was written for.Upstream Next.js does
super(input, init)in the same constructor, so transferring is also the parity behaviour. The isolation added in #1132 is preserved: the callers that need the source request to stay readable already clone explicitly before wrapping.NextRequestconstructorcreateTrackedAppRouteRequestbasePath re-prefixexecuteMiddlewareWhat changed
basePathfinallycancelNextResponse.next()new NextRequest(sourceRequest)request.clone()inside a handlerRequesttee semanticsMaintainer review path
packages/vinext/src/shims/server.tsfor the ownership decision in the constructor.packages/vinext/src/server/app-route-handler-runtime.tsfor the dead tee on the basePath path.packages/vinext/src/server/middleware-runtime.ts(unchanged) for the clone that is genuinely needed and thefinallythat cancels it.tests/shims.test.tsandtests/app-route-handler-runtime.test.tsfor the regression proofs.Validation
applyAppMiddlewarefor middleware that does not touch the body.tests/shims.test.tsto pin the constructor as transferring rather than teeing, and added coverage intests/app-route-handler-runtime.test.tsfor the basePath path. Both fail if a clone is reintroduced.tests/shims.test.ts,tests/app-route-handler-runtime.test.ts,tests/app-router-middleware-next-request.test.ts,tests/middleware-runtime.test.ts,tests/app-server-action-execution.test.ts,tests/app-route-handler-execution.test.ts,tests/app-post-middleware-context.test.ts,tests/routing.test.ts,tests/pages-api-route.test.ts,tests/api-handler.test.ts,tests/app-route-handler-dispatch.test.ts,tests/app-route-handler-policy.test.ts,tests/app-router-production-server.test.ts, plusvp checkon the changed files.Measurements
Node 26.5.0, 128 chunks of 1 MiB,
--expose-gc, delta measured around a full streaming read.Through
applyAppMiddleware, downstream draining the request body:A second symptom disappears with the fix. Because a tee only releases its source once every branch has cancelled, cancelling the downstream body while the middleware branch was still live returned a promise that never settled. After the fix it settles and the source stream's
cancel()runs.Risk / compatibility
new NextRequest(request)now disturbs the source request's body, which is what Next.js does. Code that wrapped a request and then read the original is relying on a vinext-only divergence and must clone explicitly. No in-tree caller does this.Non-goals
References
NextRequestsuper(input, init), the behaviour this restoresReadableStreamTee