Skip to content

Request: take a method from RequestInit only when it has one - #41994

Open
robobun wants to merge 4 commits into
mainfrom
robobun/22e74334/request-init-method
Open

robobun wants to merge 4 commits into
mainfrom
robobun/22e74334/request-init-method

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #42759

Problem

Fix

Background

  • RequestInit is a WebIDL dictionary: one Get per member, and undefined means absent. A Response as init contributes headers and body (its getters), nothing else.
  • construct_into visits [init?, input?] in that order and takes each field from the first candidate that has it. A default out of init shadows the real value on input.
  • A pristine Request/Response wrapper (as_direct) is copied from internal state without getters. Any other object has its members read as properties, also as input (a Bun extension, test "Creating Request from existing Request #874").
Notes

Repro (bun 1.4.3 vs node v26.3.0/undici):

const put = new Request("http://h.example/x", { method: "PUT", body: "a", headers: { "x-k": "1" } });
const r = new Request(put, new Response("b", { headers: { "x-r": "2" } }));
console.log(r.method, r.url, await r.text());
// bun before: GET http://h.example/x b
// bun after:  PUT http://h.example/x b
// undici: TypeError (duplex option is required when sending a body). It reads init.body and
// init.headers through the Response's getters; with duplex it keeps PUT.

Repro from #42759. A Request look-alike is an object that is not a native Request, but whose prototype chain reaches Request.prototype. srvx hands Node.js requests to fetch handlers in this form. Node 26 throws a TypeError for all three lines, because the object fails the Request brand check.

function requestLike() {
  const backing = new Request("http://localhost/a", { method: "POST", body: "{}" });
  class RequestLike {}
  for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(Request.prototype))) {
    if (key === "constructor") continue;
    Object.defineProperty(RequestLike.prototype, key, desc.get
      ? { get() { return backing[key]; } }
      : { value: (...args) => backing[key](...args) });
  }
  Object.setPrototypeOf(RequestLike.prototype, Request.prototype);
  return new RequestLike();
}
console.log(new Request(requestLike()).method);
console.log(new Request(requestLike(), {}).method);
console.log(new Request(requestLike(), { headers: { "x-a": "1" } }).method);
// main (7e56b402b0): POST GET GET
// this branch:       POST POST POST

Relation to #42761: that PR fixed the #42759 face alone. It made explicit_check apply to init for every kind of input. It is closed in favor of this PR. Its two tests are now RequestInit method presence > a non-native input in request.test.ts, and the original two tests of #42761 also pass on this branch as written. On the code of #42761, 4 of the cases in this file still fail: the two Response-as-init cases, the double read of an init.method getter, and status read from a RequestInit.

Other faces of the same cause, all fixed here (method was GET before, stays PUT now, as in undici): new Request(put, new Response(null)), new Request(put, new Proxy({}, {})), new Request(put, []), new Request(put, function(){}), new Request(put, new Headers({a: "1"})), new Request(put, new Blob([])), new Request(put, new URL("http://z/")), new Request({ url, method: "PUT" }, { headers }). On 1.4.3 fetch(put, init) already sends PUT for all of these (fetch.rs reads method by presence), while fetch(new Request(put, init)) failed with fetch() request with GET/HEAD method cannot have body.

Also changed as a consequence:

  • A method getter on init is read once (it was read twice when explicit_check applied).
  • new Request(url, { status: 0 }) no longer throws RangeError: The status provided (0) must be 101 or in the range of [200, 599]. status and statusText are not RequestInit members and are not read anymore.
  • new Request(url, new Response(null, { method: "DELETE" })).method is GET, not DELETE. The method a ResponseInit can carry in Bun is internal Response state (HTMLRewriter uses it), not a property.

Deliberately not changed here:

Sequencing: #41841 (Init::init_without_status) and #33469 (Init::init_for_request) each add a Request-specific variant of Init::init for this call site. With this change the call site is gone, so those hunks become unnecessary. #40411 rewrites the same block mechanically (bail! to ?); the conflict is textual.

Suites run on the debug build: test/js/web/request/ (all four files), test/js/web/fetch/{fetch -t "Request|Response|Headers",body,body-clone,body-mixin-errors,body-stream,fetch-args,response,headers,headers-case,client-fetch,request-cyclic-reference}, test/js/web/html/FormData, test/js/workerd/html-rewriter, test/js/deno/fetch/{request,body}, test/js/bun/globals, test/js/bun/util/filesystem_router, test/regression/issue/{02368,2993}. Pre-existing under the debug ASAN build with or without this change: the utf16 ... (with gc) cases in fetch.test.ts and the loops in request-clone-leak.test.ts / request-method-getter.test.ts exceed the 5 s default timeout; their assertions pass with a longer timeout.

Run again on the debug build after the merge of main (7e56b40): test/js/web/request/{request,request-subclass}, test/js/web/fetch/{fetch-args,body,body-clone,body-mixin-errors,request-cyclic-reference,headers,headers-case,response}, test/js/workerd/html-rewriter, test/js/deno/fetch/request. All pass, except that response.test.ts "handle stack overflow" (no init involved) takes 7.5 s under the debug ASAN build and exceeds the 5 s default timeout.


[human-review] gate passed · iteration 3 · 3 files touched

fails on main (without fix)
ASAN without fix: 13 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/request/request.test.ts
bun test v1.4.3 (09bb54630)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [3.61ms]
(pass) request can receive undefined signal [29.23ms]
(pass) request can receive null signal [9.32ms]
(pass) clone() does not lock original body when body was accessed before clone [14.98ms]
85 |   const xHeaders = (r: Request) => Object.fromEntries([...r.headers].filter(([name]) => name.startsWith("x-")));
86 | 
87 |   test("a Response passed as init contributes headers and body, not a method", async () => {
88 |     // A Response is not a RequestInit, but it is an object with `headers` and `body`.
89 |     const r = new Request(put(), new Response("b", { headers: { "x-r": "2" } }));
90 |     expect(r.method).toBe("PUT");
                          ^
error: expect(received).toBe(expected)

Expected: "PUT"
Received: "GET"

      at <anonymous> (/workspace/bun/test/js/web/request/request.test.ts:90:22)
      at <anonymous> (/workspace/bun/test/js/web/request/request.test.ts:87:80)
(fail) RequestI
... (truncated)

release without fix: 13 FAILED
bun test v1.4.3-canary.1 (09bb54630)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [0.08ms]
(pass) request can receive undefined signal [0.23ms]
(pass) request can receive null signal [0.12ms]
(pass) clone() does not lock original body when body was accessed before clone [0.39ms]
85 |   const xHeaders = (r: Request) => Object.fromEntries([...r.headers].filter(([name]) => name.startsWith("x-")));
86 | 
87 |   test("a Response passed as init contributes headers and body, not a method", async () => {
88 |     // A Response is not a RequestInit, but it is an object with `headers` and `body`.
89 |     const r = new Request(put(), new Response("b", { headers: { "x-r": "2" } }));
90 |     expect(r.method).toBe("PUT");
                          ^
error: expect(received).toBe(expected)

Expected: "PUT"
Received: "GET"

      at <anonymous> (/workspace/bun/test/js/web/request/request.test.ts:90:22)
(fail) RequestInit method presence > a Response passed as init contributes headers and body, not a method [0.35ms]
94 |     expect(await r.text()).toBe("b");
95 |   });
96 | 
97 |   test("a Response with a null body passed as init keeps the input Request's
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/web/request/request.test.ts
bun test v1.4.3 (09bb54630)

test/js/web/request/request.test.ts:
(pass) undefined args don't throw [6.62ms]
(pass) request can receive undefined signal [39.64ms]
(pass) request can receive null signal [12.51ms]
(pass) clone() does not lock original body when body was accessed before clone [23.34ms]
(pass) RequestInit method presence > a Response passed as init contributes headers and body, not a method [28.51ms]
(pass) RequestInit method presence > a Response with a null body passed as init keeps the input Request's method [8.19ms]
(pass) RequestInit method presence > Proxy init without a method keeps the input Request's method [14.90ms]
(pass) RequestInit method presence > array init without a method keeps the input Request's method [6.94ms]
(pass) RequestInit method presence > function init without a method keeps the input Request's method [5.84ms]
(pass) RequestInit method presence > class instance init without a method keeps the input Request's method [9.01ms]
(pass) RequestInit method presence >
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 772ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/124] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/124] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m std v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std)
�[1m�[92m   Compiling�[0m proc_macro v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/proc_macro)
�[1m�[92m   Compiling�[0m memchr v2.8.0
�[1m�[92m   Compiling�[0m libc v0.2.186
�[1m�[92m   Compiling�[0m allocator-api2 v0.2.21
�[1m�[92m   Compiling�[0m bun_opaque v0.0.0 (/workspace/bun/src/opaque)
�[1m�[92m   Compiling�[0m itoa v1.0.18
�[1m�[92m   Compiling�[0m bun_mimalloc_sys v0.0.0 (/workspace/bun/src/mimalloc_sys)
�[1m�[92m   Compiling�[0m konst_macro_rules v0.2.19
�[1m�[92m   Compiling�[0m thiserror v2.0.18
�[1m�[92m   Compiling�[0m konst v0.2.20
�[1m�[92m   Compiling�[0m strum v0.26.3
�[1m�[92m   Compiling�[0m bun_out
... (truncated)
diff hotspot
src/runtime/webcore/Request.rs      |  64 +++++++-------
 src/runtime/webcore/Response.rs     |  38 ++++-----
 test/js/web/request/request.test.ts | 161 ++++++++++++++++++++++++++++++++++++
 3 files changed, 210 insertions(+), 53 deletions(-)

gate history · 2 passed · 0 rejected · iteration 3

evidence per changed file
file                                 reads  edits  tests
src/runtime/webcore/Request.rs           4      8     16
src/runtime/webcore/Response.rs          5      5     16
test/js/web/request/request.test.ts      3      3     16

The Request constructor parsed its init object with the ResponseInit
parser. That parser always yields a method (GET when the member is
absent), and also reads status and statusText. A plain-object init was
special-cased to check for an own method first. Every other init object
(a Response, a Proxy, an array, a function, a Headers, a URL) reset the
input Request's method to GET.

Read the headers and method members of the init object directly, and
apply a method only when the member is present. A Response matched by
the internal fast path contributes its method only as input, never as
init, since a Response has no method property.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f9da3d1c-3a80-4f0e-8f94-40c05b5514aa

📥 Commits

Reviewing files that changed from the base of the PR and between e09d807 and cbd1424.

📒 Files selected for processing (2)
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/Response.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

Changes

The change updates Request initialization to preserve input methods when init.method is absent. It separates internal field copying from generic initialization and adds shared header conversion. Tests cover native and object-like inputs.

Request initialization

Layer / File(s) Summary
Shared header conversion
src/runtime/webcore/Response.rs
HeadersRef::from_init_value handles empty and non-empty FetchHeaders values and delegates other values to create_from_js. Init::init uses the shared helper.
Candidate field copying and generic initialization
src/runtime/webcore/Request.rs
Request::construct_into tracks the original candidate and copied internal fields. Generic headers and method values are read only when internal fields were not copied.
Request method presence coverage
test/js/web/request/request.test.ts
Tests cover omitted and explicit methods across native, non-native, inherited, proxy, array, class, and plain-object inputs.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to cbd14

The constructor change preserves request methods and applies explicit initialization values without an identified merge-blocking regression.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #42759 requires new Request(input, init) to preserve the input method when init omits method, including non-native Request-like inputs and other init members. The PR changes method extract…
Out of Scope Changes check ✅ Passed The changes stay within issue #42759. The HeadersRef::from_init_value helper removes duplicated header conversion needed by the constructor change. The additional request tests verify the required W…
Title check ✅ Passed The title clearly and concisely describes the main change: Request uses the method from RequestInit only when the member is present.
Description check ✅ Passed The description explains the problem, implementation, scope, related issues, and verification results. It does not use the template headings exactly, but it provides the required information and is co…

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Also fixes #42759.

Reproduced on bun 1.4.3 with the cases in test/js/web/request/request.test.ts ("RequestInit method presence"): USE_SYSTEM_BUN=1 bun test test/js/web/request/request.test.ts fails 13 of them (the method is GET where undici keeps the method of input), and bun bd test test/js/web/request/request.test.ts passes all 34 with this branch.

Latest push (cbd1424) only shortens code comments that the comment check flagged. The four review threads are resolved. Needs a maintainer review.

@claude claude Bot left a comment

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.

I reviewed this and found no bugs. It restructures the Request constructor's init handling with several intentional observable changes and documented sequencing against four other open PRs on the same block, so a human look at the coordination and spec-divergence choices would still be worthwhile.

What was reviewed:

  • is_input derivation in construct_into — traced all four values_to_try shapes (URL/object input × with/without init); the flag is correct in each.
  • copied_internal_fields gate — for a pristine Request as init, method/headers are already copied from internal state so skipping fast_get preserves prior behaviour; for a subclassed Response, as_direct fails so the getter path still runs (matches the WithMethod test).
  • Exception propagation — every fast_get / from_js / from_init_value result is matched with Err(e) => bail! before use.
  • HeadersRef::from_init_value — verbatim extraction of the Init::init block including the empty-→-None and adopt(+1) semantics; both call sites now share it.
Extended reasoning...

Overview

The PR changes how new Request(input, init) decides which candidate contributes method and headers. In src/runtime/webcore/Request.rs::construct_into, the explicit_check heuristic (which compared values_to_try JSTypes to guess whether init was a plain object) and the call into response::Init::init() are removed. Each candidate is now probed directly with fast_get(Headers) / fast_get(Method), gated by a copied_internal_fields flag (set when a pristine Request/Response DOM wrapper already had its internal state copied) and an is_input flag distinguishing the input argument from the init dictionary. A pristine Response in the init position no longer contributes its internal method. The Headers-object-vs-HeadersInit branch is factored into HeadersRef::from_init_value() in src/runtime/webcore/Response.rs and reused from Init::init. Twelve new test cases in test/js/web/request/request.test.ts cover the input-shape matrix.

Security risks

None identified. The change reads fewer properties from the init object than before (status/statusText are no longer touched), which reduces the observable surface. All user-controlled property reads go through fast_get with exception propagation on every arm; no new unsafe is introduced beyond the moved Self::adopt(p) call, which carries the same +1 ownership invariant as the original site.

Level of scrutiny

Moderate-to-high. Request::construct_into is a spec-defined Web API constructor on a hot path, and the change is a control-flow restructuring rather than a local guard. I traced the values_to_try_ construction against all four (input-is-URL × init-is-object) combinations to confirm is_input is correct in each; checked that copied_internal_fields = true on the pristine-Request branch does not lose any read that the removed Init::init call previously performed (that call's Request fast-path would have returned the same values, and Fields::Method/Fields::Headers gating already short-circuited it); and confirmed the extracted from_init_value is byte-equivalent to what was inline in Init::init, including returning None for an empty Headers object.

Other factors

The PR description enumerates several intentional behaviour changes beyond the headline fix (method getter now read once, status/statusText no longer read, a Response's Bun-internal method no longer leaks into init), plus three deliberately-unchanged divergences from undici (empty headers still treated as absent, body: null, unrecognized method → GET) and textual/semantic overlap with four other open PRs on this same loop (#41841, #33469, #40411, #41902). None of those are defects in this diff, but the sequencing plan and the "empty headers counts as absent" spec-divergence decision are the kind of thing a maintainer should ratify rather than an automated reviewer. The new tests use test.each matrices, exact-value assertions, and no timing dependencies; they are placed in the existing request.test.ts per convention. No CODEOWNERS entry covers the changed paths.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review found no defects. It defers two decisions to a maintainer, and both are spelled out under Notes in the PR body:

CI so far: the red entries are test-crypto-dh-leak.js on x64-asan (also red on main) and the R2 large-upload timeouts in s3.test.ts on Windows x64. Neither goes through the Request constructor.

Add the two cases from #42759: a Request look-alike (an object whose
prototype chain reaches Request.prototype) and a plain { url, method,
body, headers } object, each passed as input with an init that sets no
method. The copy keeps the method, the headers and the body of the input,
and init.headers or init.method still override them.

The plain object case replaces "an object input keeps its method when
init has none", which it covers. Three @ts-expect-error directives that
tsc reports as unused are removed.
Comment thread src/runtime/webcore/Request.rs Resolved
Comment thread src/runtime/webcore/Request.rs Resolved
Comment thread src/runtime/webcore/Response.rs Resolved
Comment thread src/runtime/webcore/Response.rs Resolved
@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

This PR now also fixes #42759, and the body links it. #42761 fixed that issue alone, on the same explicit_check lines. It is closed in favor of this PR.

Pushed:

Checked on a debug build of this branch: the snippet from #42759 prints POST, POST, POST, and the tests of #42761 pass as written. On the code of #42761, 4 cases of request.test.ts still fail: the two Response-as-init cases, the double read of an init.method getter, and status read from a RequestInit.

@robobun

robobun commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:55 AM PT - Sep 15th, 2026

✅ @robobun, your commit cbd1424372135c9065d9a87b93315d3939c68762 passed in Build #115852! 🎉


🧪   To try this PR locally:

bunx bun-pr 41994

That installs a local version of the PR into your bun-41994 executable, so you can run:

bun-41994 --bun

@robobun

robobun commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

The comment check flagged four multi-line code comments in Request.rs and Response.rs. cbd1424 cuts three of them to one line and removes the fourth. No code changed. The four threads are resolved.

@claude claude Bot left a comment

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.

Code review found no issues

No high-confidence issues detected in this change.

AlphaLawless added a commit to Greens-Organization/eco-system that referenced this pull request Oct 13, 2026
CI runs turbo check:svelte build (build depends on test), so an import
only Vite fails to resolve breaks the gate. A Biome GritQL plugin
rejects new Request(request, { headers }): under Bun an empty headers
init keeps the original headers (oven-sh/bun#41994), which once let a
spoofed client IP through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

new Request(input, init) drops the input's method when input is not a native Request

1 participant