Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/vinext/src/shims/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,16 @@ export class NextRequest extends Request {
// to avoid Node.js undici issues with passing Request objects directly to super()
if (input instanceof Request) {
const req = input;
// Gate body copy on method. Per the Fetch spec, init.body being non-null
// with method GET/HEAD throws. In Cloudflare Workers an incoming GET/HEAD
// can expose `body` as a non-null (often empty) ReadableStream when the
// request carries Content-Length or Transfer-Encoding framing, so an
// unconditional `body: req.body` makes the super() call throw.
const passBody = req.method !== "GET" && req.method !== "HEAD";
super(req.url, {
method: req.method,
headers: req.headers,
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 } : {};
})(),

...requestInit,
});
} else {
Expand Down
41 changes: 41 additions & 0 deletions tests/shims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4963,6 +4963,47 @@ describe("NextURL basePath and locale properties", () => {
expect(req.nextUrl.pathname).toBe("/dashboard");
expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard");
});

// Regression: in Cloudflare Workers, an incoming GET/HEAD request that
// carries Content-Length or Transfer-Encoding framing exposes
// `request.body` as a non-null ReadableStream. Copying that into super()'s
// init made the Request constructor throw
// "Request with a GET or HEAD method cannot have a body.", which broke
// every middleware invocation for affected traffic (e.g. email-pixel GETs).
// We reproduce the same shape in Node.js by overriding the body getter on a
// normal Request instance.
it("NextRequest does not throw when input Request has a non-null body on GET/HEAD", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");

const withFakeBody = (method: "GET" | "HEAD"): Request => {
const req = new Request("http://localhost/x", { method });
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,
});

return req;
};

const getReq = withFakeBody("GET");
expect(getReq.body).not.toBeNull();
const wrappedGet = new NextRequest(getReq);
// Body must not be forwarded into the wrapped request on GET/HEAD.
expect(wrappedGet.body).toBeNull();
expect(wrappedGet.method).toBe("GET");

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.

});

it("NextRequest preserves body for non-GET/HEAD methods", async () => {
const { NextRequest } = await import("../packages/vinext/src/shims/server.js");
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.

});
});

// ---------------------------------------------------------------------------
Expand Down
Loading