Skip to content

fix(shims): skip body copy for GET/HEAD in NextRequest constructor#887

Open
agm151515 wants to merge 1 commit intocloudflare:mainfrom
agm151515:fix/nextrequest-skip-body-for-get-head
Open

fix(shims): skip body copy for GET/HEAD in NextRequest constructor#887
agm151515 wants to merge 1 commit intocloudflare:mainfrom
agm151515:fix/nextrequest-skip-body-for-get-head

Conversation

@agm151515
Copy link
Copy Markdown

Summary

When NextRequest is constructed from an existing Request, its
constructor forwards body: req.body to super() unconditionally.
The Fetch spec (and workerd) throw
TypeError: Request with a GET or HEAD method cannot have a body.
whenever init.body is non-null and the method is GET/HEAD.

In Cloudflare Workers, incoming GET/HEAD requests expose
request.body as a non-null ReadableStream whenever the request
carries Content-Length or Transfer-Encoding framing. That happens
in the wild — e.g. some email image-proxy fetchers of tracking pixels
send GET with a Content-Length header — so the proxy/middleware
block in the generated RSC entry throws on every affected request,
logs [vinext] Middleware error: TypeError: …, and returns 500.

Reproduced via wrangler dev --local against a minimal worker:

GET / (normal, no framing)         → request.body === null,  NextRequest OK
GET / Content-Length: 5, body=hi   → request.body === stream, NextRequest THREW
HEAD / Transfer-Encoding: chunked  → request.body === stream, NextRequest THREW

Fix gates body/duplex behind a method check. Behavior for
POST/PUT/PATCH/DELETE etc. is unchanged.

Test plan

  • New regression test in tests/shims.test.ts reproduces the bug
    in Node (by overriding body on a normal Request via
    Object.defineProperty) — fails before the fix, passes after.
  • Existing NextRequest tests still pass (pnpm test:unit -t NextRequest → 21 passed).
  • Full unit suite passes (pnpm test:unit → 2971/2971).
  • pnpm fmt:check and pnpm lint clean on modified files.

Downstream context for maintainers: this was observed in production on
scoregap.com with vinext 0.0.43. Filed locally as GitHub issue #35 in
our app repo. Happy to adjust style/commit-msg to match project
conventions.

The NextRequest constructor unconditionally forwarded req.body to super()
when constructing from a Request, which throws "Request with a GET or
HEAD method cannot have a body." whenever the input request's body is
a non-null ReadableStream.

In Cloudflare Workers, incoming GET/HEAD requests expose body as a
non-null stream when the request carries Content-Length or
Transfer-Encoding framing (e.g. some email image-proxy fetchers of
tracking pixels), which made middleware throw and respond 500 on every
affected request.

Gates body/duplex behind a method check. Regression test constructs an
input Request whose body getter returns a non-null stream and asserts
NextRequest wraps it without throwing.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

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

commit: dc04a22

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.

Thanks for the detailed bug report and reproduction steps — this is a real issue and the root-cause analysis is accurate. The fix is minimal and the regression test is good. Unit tests pass locally (vp test run tests/shims.test.ts → 817/817).

A few points:

1. Same bug pattern exists in config/config-matchers.ts — please fix in this PR

applyMiddlewareRequestHeaders in packages/vinext/src/config/config-matchers.ts:496-502 reconstructs the request the exact same way:

request = new Request(request.url, {
  method: request.method,
  headers: nextHeaders,
  body: request.body,
  // @ts-expect-error — duplex needed for streaming request bodies
  duplex: request.body ? "half" : undefined,
});

This has the identical failure mode: an incoming GET/HEAD with Content-Length / Transfer-Encoding framing (which exposes a non-null body ReadableStream in Workers) will throw TypeError: Request with a GET or HEAD method cannot have a body.. It runs on the post-middleware path in both server/prod-server.ts:1500 and deploy.ts:720, so production traffic hitting any route with middleware + config headers/rewrites will 500 for the same email-pixel / tracking GET requests.

Per AGENTS.md ("Always check dev and prod server parity" / "Do not leave known bugs as 'follow-ups' — fix them in the same PR"), this parallel bug site should be fixed here. Suggested patch:

const passBody = request.method !== "GET" && request.method !== "HEAD";
request = new Request(request.url, {
  method: request.method,
  headers: nextHeaders,
  ...(passBody
    ? {
        body: request.body,
        // @ts-expect-error — duplex needed for streaming request bodies
        duplex: request.body ? "half" : undefined,
      }
    : {}),
});

Please also add a matching regression test that exercises applyMiddlewareRequestHeaders with a GET request that has a non-null body.

2. Simpler alternative worth considering (not a blocker)

Next.js itself sidesteps this whole class of bug by just doing super(input, init) directly when input instanceof Request (source). Modern workerd and undici handle new Request(request, init) correctly per the Fetch spec — including the method/body compatibility handoff. The existing "to avoid Node.js undici issues" comment in shims/server.ts predates undici's fix and is now stale.

Switching to Next.js's pattern would cover additional edge cases (e.g. new NextRequest(postRequest, { method: "GET" }), signal forwarding, method normalization) for free. Up to you whether to do it in this PR or follow up separately.

3. Edge case left unfixed (minor, pre-existing)

If a caller does new NextRequest(postRequest, { method: "GET" }), passBody is computed from req.method (POST) so body is still spread in, and then method: "GET" in requestInit overrides and the constructor throws. Pre-existing, not introduced by this PR. Would be resolved automatically by option 2 above.

4. Parity with Next.js test suite

For reference, Next.js's own test/unit/web-runtime/next-request.test.ts confirms that new NextRequest(postRequestWithBody) preserving the body is the expected behavior, so the positive POST test here matches upstream. Consider adding a // Ported from Next.js: ... comment for future maintainers.

On the commit message / style: this already matches the repo's conventional-commit style — no changes needed.

body: req.body,
// @ts-expect-error - duplex is not in RequestInit type but needed for streams
duplex: req.body ? "half" : undefined,
...(passBody ? { body: req.body, duplex: req.body ? "half" : undefined } : {}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: the method comparison is correct because the Request constructor normalizes method to uppercase per spec, so req.method is always "GET" / "HEAD" / etc. Worth a brief inline comment noting that invariant, since the check silently depends on it.

Also: if a caller passes requestInit with method: "GET" while the input Request is POST, passBody will be computed from the POST method (so body is spread in) and then the method: "GET" from requestInit overrides — which would re-trigger the original TypeError. Pre-existing edge case, not introduced here, but worth noting. Computing passBody from requestInit?.method ?? req.method would also cover that case:

Suggested change
...(passBody ? { body: req.body, duplex: req.body ? "half" : undefined } : {}),
...(() => {
// Request constructor normalizes method to uppercase, so comparing
// against "GET" / "HEAD" is spec-safe. Also honor `requestInit.method`
// if present — otherwise an override like `{ method: "GET" }` on a
// POST input would re-trigger the original TypeError.
const method = (requestInit?.method ?? req.method).toUpperCase();
const passBody = method !== "GET" && method !== "HEAD";
return passBody ? { body: req.body, duplex: req.body ? "half" : undefined } : {};
})(),

Comment thread tests/shims.test.ts
Object.defineProperty(req, "body", {
configurable: true,
get: () => new ReadableStream(),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The getter returns a fresh ReadableStream() on every access, so getReq.body !== getReq.body. Harmless for the current assertions, but fragile — a future reader who calls getReq.body more than once in setup will get surprising behavior. Consider a value descriptor instead:

Suggested change
});
const stream = new ReadableStream();
Object.defineProperty(req, "body", {
configurable: true,
value: stream,
});

Comment thread tests/shims.test.ts
const headReq = withFakeBody("HEAD");
const wrappedHead = new NextRequest(headReq);
expect(wrappedHead.body).toBeNull();
expect(wrappedHead.method).toBe("HEAD");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider also asserting the original symptom directly, since the current wrappedGet.body / wrappedHead.body assertions only cover it implicitly (the constructor would have thrown before reaching them):

expect(() => new NextRequest(getReq)).not.toThrow();
expect(() => new NextRequest(headReq)).not.toThrow();

That makes the regression intent match the bug report verbatim and will survive future refactors that change how body is exposed on the wrapped request.

Comment thread tests/shims.test.ts
const post = new Request("http://localhost/x", { method: "POST", body: "hello" });
const wrapped = new NextRequest(post);
expect(wrapped.method).toBe("POST");
expect(await wrapped.text()).toBe("hello");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: this mirrors Next.js's own unit test at test/unit/web-runtime/next-request.test.ts. Per AGENTS.md ("Searching the Next.js Test Suite"), it's worth adding a // Ported from Next.js: ... comment with the link, so future maintainers know the positive-path behavior is tied to upstream parity rather than ad-hoc.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 24, 2026

Review posted successfully: #887 (review)

Summary

Posted a batch review on PR #887 with the following feedback:

Overall verdict: The fix is correct and addresses a real production bug. Unit tests pass locally (817/817 in tests/shims.test.ts).

Key findings:

  1. 🔴 Parallel bug site (should fix in this PR): applyMiddlewareRequestHeaders in packages/vinext/src/config/config-matchers.ts:496-502 has the exact same failure mode — it also does body: request.body unconditionally when reconstructing a Request, so the same GET/HEAD-with-framing requests will still 500 on the post-middleware path used by prod-server.ts:1500 and deploy.ts:720. Per AGENTS.md, parallel bugs should be fixed together.

  2. 💡 Simpler alternative: Next.js itself just does super(input, init) directly when input instanceof Request. The current extraction pattern's "to avoid Node.js undici issues" comment is stale. Adopting the Next.js approach would cover more edge cases (e.g. new NextRequest(postReq, { method: "GET" })) for free.

  3. 🟡 Edge case left unfixed (pre-existing): Method mismatch between input Request and requestInit.method still triggers the same TypeError. Suggested a small refactor that checks requestInit?.method ?? req.method.

  4. Test robustness nits: The get: () => new ReadableStream() descriptor returns a fresh stream on each access — suggested value: descriptor; also suggested adding explicit expect(() => ...).not.toThrow() assertions and a // Ported from Next.js comment to align with AGENTS.md.

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