From abd872e22a8fc12a6b242ed519a786fac679cdb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:37:50 +0000 Subject: [PATCH 1/7] Copy internal state in new Request(request) instead of calling getters When the input Request's structure is not pristine (a subclass instance, or an instance carrying shadowing own properties), the constructor fell through to the dictionary path and read url, method, headers, body and signal through JS-visible getters. The fetch spec says to copy the input's request state directly, and Node does the same; only the init argument has dictionary (getter) semantics. Also throw the spec's TypeError when the input's body is already disturbed or locked and init supplies no replacement body. --- src/runtime/webcore/Request.rs | 61 ++++++++- test/js/web/fetch/body-clone.test.ts | 14 +- test/js/web/request/request.test.ts | 185 +++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 10 deletions(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 075d91942968..49e18c72a341 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1121,15 +1121,47 @@ impl Request { let values_to_try = &values_to_try_[0..((!is_first_argument_a_url) as usize + (arguments.len() > 1 && arguments[1].is_object()) as usize)]; - for &value in values_to_try { + for (value_index, &value) in values_to_try.iter().enumerate() { let value_type = value.js_type(); + // The last entry carries the constructor's first argument (the + // `input` in `new Request(input, init?)`) whenever that argument + // was an object rather than a URL; any other entry carries `init` + // (dictionary) semantics. + let is_input_argument = + !is_first_argument_a_url && value_index == values_to_try.len() - 1; let explicit_check = values_to_try.len() == 2 && value_type == bun_jsc::JSType::FinalObject && values_to_try[1].js_type() == bun_jsc::JSType::DOMWrapper; if value_type == bun_jsc::JSType::DOMWrapper { - if let Some(request) = value.as_direct::() { - // SAFETY: as_direct returns a live *mut Request payload (m_ctx) + // A Request `input` is copied from internal state without + // consulting JS-visible getters (fetch spec: "Set request to + // input's request"), so subclass instances and inputs with + // shadowing own properties must take this path too; their + // transitioned structures make `as_direct` reject them. A + // Request used as `init` keeps dictionary semantics: only a + // pristine one may skip the getters. + let request_ptr = if is_input_argument { + value.as_::() + } else { + value.as_direct::() + }; + if let Some(request) = request_ptr { + // SAFETY: the cast returns a live *mut Request payload (m_ctx) let request = unsafe { &*request }; + // Fetch spec Request(input): when `init` contributed no + // body, a Request input whose body is disturbed or locked + // is unusable and the constructor throws. + if is_input_argument + && (!fields.contains(Fields::Body) + || matches!(req.body_value(), BodyValue::Null)) + && request.body_stream_check(global_this, |s, g| { + s.is_disturbed(g) || s.is_locked(g) + }) + { + bail!(Err(global_this.throw_type_error(format_args!( + "Cannot construct a Request with a Request object that has already been used." + )))); + } if values_to_try.len() == 1 { match Request::clone_into( request, @@ -1194,6 +1226,29 @@ impl Request { } } } + + if is_input_argument { + // The input's remaining members also come from + // internal state; marking every field consumed keeps + // the dictionary fallbacks below from running JS + // getters against the input. + if !fields.contains(Fields::Url) { + let url = request.url.get(); + if !url.is_empty() { + req.url.set(url.dupe_ref()); + fields.insert(Fields::Url); + } + } + if !fields.contains(Fields::Signal) { + if let Some(signal) = request.signal.get() { + // `AbortSignalRef::clone` is a C++ `ref()`. + req.signal.set(Some(signal.clone())); + } + fields.insert(Fields::Signal); + } + fields.insert(Fields::Headers); + fields.insert(Fields::Body); + } } if let Some(response) = value.as_direct::() { diff --git a/test/js/web/fetch/body-clone.test.ts b/test/js/web/fetch/body-clone.test.ts index 52532679f875..953bcfae6dbd 100644 --- a/test/js/web/fetch/body-clone.test.ts +++ b/test/js/web/fetch/body-clone.test.ts @@ -630,18 +630,18 @@ test.each(["Request", "Response"])( }, ); -// clone()'s usability check now fires before the stream is teed, so the -// readableStreamTee C++ bridge's exception propagation (which used to be -// covered by the test above) is exercised via `new Request(lockedRequest)`, -// which still tees. It must throw a single catchable TypeError, not also -// report it as uncaught (exit code 1) or surface a bogus follow-up error. -test("new Request(request) with a locked stream body throws a catchable TypeError from the tee and does not fail the process", async () => { +// The usability checks in clone() and in Request(input) fire before the +// stream is teed, so the readableStreamTee C++ bridge's exception propagation +// is exercised via a locked Request passed as init (second argument), which +// still tees. It must throw a single catchable TypeError, not also report it +// as uncaught (exit code 1) or surface a bogus follow-up error. +test("new Request(url, lockedRequest) throws a catchable TypeError from the tee and does not fail the process", async () => { const script = ` const stream = new ReadableStream({ start() {} }); const source = new Request("http://example.com/", { method: "POST", body: stream, duplex: "half" }); source.body.getReader(); // lock the body stream try { - new Request(source); + new Request("http://other.example/", source); console.log("no throw"); } catch (e) { console.log("caught " + e.constructor.name + ": " + e.message); diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index 85e42bf8e420..a8a38ed4146f 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -175,3 +175,188 @@ describe("RequestInit signal presence", () => { }); }); }); + +// The fetch spec copies a Request `input`'s internal state ("Set request to +// input's request") without consulting JS-visible getters, even when the input +// is a subclass instance or carries shadowing own properties. Node behaves the +// same. Only the `init` argument has dictionary (getter) semantics. +describe("new Request(request) copies internal state without calling getters", () => { + test("own-property getters on the input are never called", () => { + let getterHits = 0; + const input = new Request("http://localhost/original", { + method: "PUT", + headers: { "x-real": "1" }, + }); + for (const name of ["url", "method", "headers", "body", "signal", "redirect", "cache", "mode"]) { + Object.defineProperty(input, name, { + get() { + getterHits++; + return undefined; + }, + }); + } + + const copy = new Request(input); + expect({ + url: copy.url, + method: copy.method, + headers: [...copy.headers], + redirect: copy.redirect, + getterHits, + }).toEqual({ + url: "http://localhost/original", + method: "PUT", + headers: [["x-real", "1"]], + redirect: "follow", + getterHits: 0, + }); + }); + + test("subclass getter overrides on the input are ignored", () => { + let getterHits = 0; + class MyRequest extends Request { + get url() { + getterHits++; + return "http://localhost/from-getter"; + } + get method() { + getterHits++; + return "DELETE"; + } + get headers() { + getterHits++; + return new Headers({ "x-from-getter": "1" }); + } + } + + const copy = new Request(new MyRequest("http://localhost/original", { headers: { "x-real": "1" } })); + expect({ + url: copy.url, + method: copy.method, + headers: [...copy.headers], + getterHits, + }).toEqual({ + url: "http://localhost/original", + method: "GET", + headers: [["x-real", "1"]], + getterHits: 0, + }); + }); + + test("init members win; everything else comes from the input's internal state", () => { + class MyRequest extends Request { + get url() { + return "http://localhost/from-getter"; + } + get headers() { + return new Headers({ "x-from-getter": "1" }); + } + get redirect() { + return "error"; + } + } + + const input = new MyRequest("http://localhost/original", { headers: { "x-real": "1" } }); + const copy = new Request(input, { method: "POST" }); + expect({ + url: copy.url, + method: copy.method, + headers: [...copy.headers], + redirect: copy.redirect, + }).toEqual({ + url: "http://localhost/original", + method: "POST", + headers: [["x-real", "1"]], + redirect: "follow", + }); + }); + + test("body comes from the input's internal state, not the body getter", async () => { + const make = () => { + const input = new Request("http://localhost/original", { method: "POST", body: "real-body" }); + Object.defineProperty(input, "body", { + get() { + return "getter-body"; + }, + }); + return input; + }; + expect(await new Request(make()).text()).toBe("real-body"); + expect(await new Request(make(), {}).text()).toBe("real-body"); + }); + + test("the input's signal carries over even when its signal getter is shadowed", () => { + const ctl = new AbortController(); + const input = new Request("http://localhost/", { signal: ctl.signal }); + Object.defineProperty(input, "signal", { + get() { + return undefined; + }, + }); + const copy = new Request(input, {}); + ctl.abort(); + expect(copy.signal.aborted).toBe(true); + }); + + test("throws TypeError when the input's body is already used", async () => { + const input = new Request("http://localhost/", { method: "POST", body: "x" }); + await input.text(); + expect(() => new Request(input)).toThrow(TypeError); + for (const init of [undefined, {}, { body: null }] as const) { + expect(() => new Request(input, init)).toThrow( + "Cannot construct a Request with a Request object that has already been used.", + ); + } + + // an init-provided body replaces the input's, so the used input body is + // never read and nothing throws + const replaced = new Request(input, { body: "fresh" }); + expect(await replaced.text()).toBe("fresh"); + }); + + test("throws TypeError when the input's body stream is locked", () => { + const input = new Request("http://localhost/", { + method: "POST", + body: new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([97])); + c.close(); + }, + }), + }); + input.body!.getReader(); + expect(() => new Request(input)).toThrow( + "Cannot construct a Request with a Request object that has already been used.", + ); + }); + + test("an unlocked stream-body input still copies fine", async () => { + const input = new Request("http://localhost/", { + method: "POST", + body: new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode("st")); + c.close(); + }, + }), + }); + expect(await new Request(input).text()).toBe("st"); + }); + + test("a Request passed as init (second argument) keeps dictionary getter semantics", () => { + let getterHits = 0; + const asInit = new Request("http://localhost/original", { method: "PUT" }); + Object.defineProperty(asInit, "method", { + get() { + getterHits++; + return "PATCH"; + }, + }); + const built = new Request("http://localhost/base", asInit); + expect({ url: built.url, method: built.method, getterHits }).toEqual({ + url: "http://localhost/base", + method: "PATCH", + getterHits: 1, + }); + }); +}); From 7f843eff5464f04d3074689dc9969d5c94c97c4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:09:15 +0000 Subject: [PATCH 2/7] Materialize a lazily-built server request URL before the internal-state copy A Bun.serve request's URL is synthesized from the request context on first access, so the input-position copy has to run ensure_url() before reading the internal field, as clone_into() already does. Otherwise the two-argument path fell back to the JS url getter. --- src/runtime/webcore/Request.rs | 3 +++ test/js/web/request/request.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 49e18c72a341..fabfc9ac4a76 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1233,6 +1233,9 @@ impl Request { // the dictionary fallbacks below from running JS // getters against the input. if !fields.contains(Fields::Url) { + // A Bun.serve request materializes its URL lazily + // from the request context. + let _ = request.ensure_url(); let url = request.url.get(); if !url.is_empty() { req.url.set(url.dupe_ref()); diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index a8a38ed4146f..ccf972cb9608 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -343,6 +343,31 @@ describe("new Request(request) copies internal state without calling getters", ( expect(await new Request(input).text()).toBe("st"); }); + test("a Bun.serve request's lazily materialized url is copied, not read via getter", async () => { + using server = Bun.serve({ + port: 0, + fetch(req) { + let hits = 0; + Object.defineProperty(req, "url", { + get() { + hits++; + return "http://127.0.0.1:9/from-getter"; + }, + }); + const copy = new Request(req, { method: "POST" }); + const single = new Request(req); + return Response.json({ hits, url: copy.url, method: copy.method, single: single.url }); + }, + }); + const res = await fetch(`http://localhost:${server.port}/real-path`); + expect(await res.json()).toEqual({ + hits: 0, + url: `http://localhost:${server.port}/real-path`, + method: "POST", + single: `http://localhost:${server.port}/real-path`, + }); + }); + test("a Request passed as init (second argument) keeps dictionary getter semantics", () => { let getterHits = 0; const asInit = new Request("http://localhost/original", { method: "PUT" }); From c22719b074c07fe3fe0494568d29346838a76390 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:11:13 +0000 Subject: [PATCH 3/7] Tighten constructor comments --- src/runtime/webcore/Request.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index fabfc9ac4a76..fa72c5311ea8 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1123,23 +1123,18 @@ impl Request { for (value_index, &value) in values_to_try.iter().enumerate() { let value_type = value.js_type(); - // The last entry carries the constructor's first argument (the - // `input` in `new Request(input, init?)`) whenever that argument - // was an object rather than a URL; any other entry carries `init` - // (dictionary) semantics. + // The last entry is the `input` argument when it was an object + // rather than a URL; other entries are `init` dictionaries. let is_input_argument = !is_first_argument_a_url && value_index == values_to_try.len() - 1; let explicit_check = values_to_try.len() == 2 && value_type == bun_jsc::JSType::FinalObject && values_to_try[1].js_type() == bun_jsc::JSType::DOMWrapper; if value_type == bun_jsc::JSType::DOMWrapper { - // A Request `input` is copied from internal state without - // consulting JS-visible getters (fetch spec: "Set request to - // input's request"), so subclass instances and inputs with - // shadowing own properties must take this path too; their - // transitioned structures make `as_direct` reject them. A - // Request used as `init` keeps dictionary semantics: only a - // pristine one may skip the getters. + // Fetch spec: a Request `input` is copied from internal state, + // never via JS getters, so subclasses and shadowed instances + // (rejected by `as_direct`'s pristine-structure check) must + // match too. A Request `init` keeps dictionary semantics. let request_ptr = if is_input_argument { value.as_::() } else { @@ -1148,9 +1143,8 @@ impl Request { if let Some(request) = request_ptr { // SAFETY: the cast returns a live *mut Request payload (m_ctx) let request = unsafe { &*request }; - // Fetch spec Request(input): when `init` contributed no - // body, a Request input whose body is disturbed or locked - // is unusable and the constructor throws. + // Fetch spec: with no body from `init`, a + // disturbed-or-locked input body is unusable. if is_input_argument && (!fields.contains(Fields::Body) || matches!(req.body_value(), BodyValue::Null)) @@ -1228,10 +1222,8 @@ impl Request { } if is_input_argument { - // The input's remaining members also come from - // internal state; marking every field consumed keeps - // the dictionary fallbacks below from running JS - // getters against the input. + // Mark every remaining field consumed so the dictionary + // fallbacks below never run JS getters against the input. if !fields.contains(Fields::Url) { // A Bun.serve request materializes its URL lazily // from the request context. From a7a80debc8f38872e4f19f5ed86139431b7f9497 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:30:49 +0000 Subject: [PATCH 4/7] Keep an Empty input body non-null in the two-argument copy path An empty-string body extracts to BodyValue::Empty, which is a non-null body. The input-position field marking suppressed the dictionary fallback that used to materialize it, so the copy's body became null. Clone Empty for input copies; init entries keep the fallback. Also pin the used-input + GET-method init precedence with a test. --- src/runtime/webcore/Request.rs | 6 +++++- test/js/web/request/request.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index fa72c5311ea8..03edc57317de 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1208,7 +1208,11 @@ impl Request { if !fields.contains(Fields::Body) { match request.body_value() { - BodyValue::Null | BodyValue::Empty | BodyValue::Used => {} + BodyValue::Null | BodyValue::Used => {} + // `Empty` is a non-null body; an input copy keeps + // it, while an `init` entry leaves it to the + // dictionary fallback. + BodyValue::Empty if !is_input_argument => {} _ => { match request.clone_body_value_via_cached_stream(global_this) { Ok(v) => { diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index ccf972cb9608..ba16512e7262 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -285,6 +285,16 @@ describe("new Request(request) copies internal state without calling getters", ( expect(await new Request(make(), {}).text()).toBe("real-body"); }); + test("an empty-string body input copies as a non-null empty body", async () => { + const make = () => new Request("http://localhost/", { method: "POST", body: "" }); + const single = new Request(make()); + const withInit = new Request(make(), {}); + expect(single.body).not.toBeNull(); + expect(withInit.body).not.toBeNull(); + expect(await single.text()).toBe(""); + expect(await withInit.text()).toBe(""); + }); + test("the input's signal carries over even when its signal getter is shadowed", () => { const ctl = new AbortController(); const input = new Request("http://localhost/", { signal: ctl.signal }); @@ -308,6 +318,15 @@ describe("new Request(request) copies internal state without calling getters", ( ); } + // Node throws "Request with GET/HEAD method cannot have body." here + // because its GET/HEAD-body check precedes the unusable check. Bun has no + // constructor-level GET/HEAD-body check (it enforces at fetch() time), so + // the unusable error fires; adding that check later must consciously flip + // this precedence. + expect(() => new Request(input, { method: "GET" })).toThrow( + "Cannot construct a Request with a Request object that has already been used.", + ); + // an init-provided body replaces the input's, so the used input body is // never read and nothing throws const replaced = new Request(input, { body: "fresh" }); From d7b44a9f0e6eb0ba866a75da5c3c26b12dde2fcd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:14:38 +0000 Subject: [PATCH 5/7] Copy the input body when init passes body: null The unusable-input check already treats a null init body as absent, per the spec's initBody-is-null reading. Apply the same predicate to the body copy so new Request(input, { body: null }) carries the input's body like Node does, instead of throwing about a body it then drops. --- src/runtime/webcore/Request.rs | 11 ++++++----- test/js/web/request/request.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 03edc57317de..f9eb6c107a0b 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1143,11 +1143,12 @@ impl Request { if let Some(request) = request_ptr { // SAFETY: the cast returns a live *mut Request payload (m_ctx) let request = unsafe { &*request }; - // Fetch spec: with no body from `init`, a - // disturbed-or-locked input body is unusable. - if is_input_argument + // Fetch spec: `init.body: null`/absent contributes no + // body, so the input's body applies and must be usable. + let input_body_applies = is_input_argument && (!fields.contains(Fields::Body) - || matches!(req.body_value(), BodyValue::Null)) + || matches!(req.body_value(), BodyValue::Null)); + if input_body_applies && request.body_stream_check(global_this, |s, g| { s.is_disturbed(g) || s.is_locked(g) }) @@ -1206,7 +1207,7 @@ impl Request { } } - if !fields.contains(Fields::Body) { + if !fields.contains(Fields::Body) || input_body_applies { match request.body_value() { BodyValue::Null | BodyValue::Used => {} // `Empty` is a non-null body; an input copy keeps diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index ba16512e7262..447839a2449e 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -285,6 +285,13 @@ describe("new Request(request) copies internal state without calling getters", ( expect(await new Request(make(), {}).text()).toBe("real-body"); }); + test("init body: null contributes no body, so the input's body is copied", async () => { + const input = new Request("http://localhost/", { method: "POST", body: "hello" }); + const copy = new Request(input, { body: null }); + expect(copy.body).not.toBeNull(); + expect(await copy.text()).toBe("hello"); + }); + test("an empty-string body input copies as a non-null empty body", async () => { const make = () => new Request("http://localhost/", { method: "POST", body: "" }); const single = new Request(make()); From 5d018019a54ec57ca73676e8d092661b5dd07b8b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:53:42 +0000 Subject: [PATCH 6/7] Consume the input's url field even when it cannot materialize A Bun.serve request kept past a synchronous response has an empty internal url and a torn-down request context, so ensure_url() cannot materialize it. Leaving Fields::Url unset let the dictionary fallback run the input's url getter. Mark the field consumed unconditionally so the empty url reaches the existing "url is required" throw instead, the same outcome the unshadowed case already had. Also cover two review gaps with tests: redirect/cache/mode copies asserted with non-default values, and init body "" (a real empty body) winning over the input's body where null does not. --- src/runtime/webcore/Request.rs | 7 +++- test/js/web/request/request.test.ts | 61 +++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index f9eb6c107a0b..c553d3dcdff2 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1231,13 +1231,16 @@ impl Request { // fallbacks below never run JS getters against the input. if !fields.contains(Fields::Url) { // A Bun.serve request materializes its URL lazily - // from the request context. + // from the request context; a detached one stays + // empty and reaches the "url is required" throw. + // The field is consumed from internal state either + // way, keeping the getter fallback off. let _ = request.ensure_url(); let url = request.url.get(); if !url.is_empty() { req.url.set(url.dupe_ref()); - fields.insert(Fields::Url); } + fields.insert(Fields::Url); } if !fields.contains(Fields::Signal) { if let Some(signal) = request.signal.get() { diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index 447839a2449e..9ec18a9c54aa 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -244,6 +244,8 @@ describe("new Request(request) copies internal state without calling getters", ( }); test("init members win; everything else comes from the input's internal state", () => { + // Internal values differ from both the defaults and the getter values, so + // this discriminates "getter ignored" and "internal value actually copied". class MyRequest extends Request { get url() { return "http://localhost/from-getter"; @@ -252,22 +254,37 @@ describe("new Request(request) copies internal state without calling getters", ( return new Headers({ "x-from-getter": "1" }); } get redirect() { - return "error"; + return "manual"; + } + get cache() { + return "force-cache"; + } + get mode() { + return "no-cors"; } } - const input = new MyRequest("http://localhost/original", { headers: { "x-real": "1" } }); + const input = new MyRequest("http://localhost/original", { + headers: { "x-real": "1" }, + redirect: "error", + cache: "no-store", + mode: "same-origin", + }); const copy = new Request(input, { method: "POST" }); expect({ url: copy.url, method: copy.method, headers: [...copy.headers], redirect: copy.redirect, + cache: copy.cache, + mode: copy.mode, }).toEqual({ url: "http://localhost/original", method: "POST", headers: [["x-real", "1"]], - redirect: "follow", + redirect: "error", + cache: "no-store", + mode: "same-origin", }); }); @@ -394,6 +411,44 @@ describe("new Request(request) copies internal state without calling getters", ( }); }); + test("a detached Bun.serve request never falls back to its url getter", async () => { + // A handler that responds synchronously without touching req.url leaves + // the internal url empty forever once the request context is torn down. + let saved: Request | undefined; + using server = Bun.serve({ + port: 0, + fetch(req) { + saved = req; + return new Response("ok"); + }, + }); + const res = await fetch(`http://localhost:${server.port}/x`); + expect(await res.text()).toBe("ok"); + + let hits = 0; + Object.defineProperty(saved!, "url", { + get() { + hits++; + return "http://localhost/from-getter"; + }, + }); + expect(() => new Request(saved!, {})).toThrow("url is required"); + expect(hits).toBe(0); + }); + + test('init body: "" is a real empty body and replaces the input body', async () => { + const fresh = new Request("http://localhost/", { method: "POST", body: "hello" }); + const replaced = new Request(fresh, { body: "" }); + expect(replaced.body).not.toBeNull(); + expect(await replaced.text()).toBe(""); + + // a non-null init body means the used input body is never read: no throw + const used = new Request("http://localhost/", { method: "POST", body: "x" }); + await used.text(); + const afterUsed = new Request(used, { body: "" }); + expect(await afterUsed.text()).toBe(""); + }); + test("a Request passed as init (second argument) keeps dictionary getter semantics", () => { let getterHits = 0; const asInit = new Request("http://localhost/original", { method: "PUT" }); From 0855f3fa229ff6cd417d7cb09fa09539fa93f192 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:57:42 +0000 Subject: [PATCH 7/7] Route ensure_url allocation failure to the OOM handler Discarding the AllocError turned an allocation failure into a misleading "url is required" TypeError. Crash controlledly instead, in both the constructor's input copy and clone_into. --- src/runtime/webcore/Request.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index c553d3dcdff2..6135171cb366 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -18,7 +18,7 @@ use crate::webcore::jsc::{ }; use crate::webcore::{AbortSignal, Blob, CookieMap, FetchHeaders, ReadableStream, Response}; use bun_alloc::AllocError; -use bun_core::{Output, fmt as bun_fmt}; +use bun_core::{Output, UnwrapOrOom, fmt as bun_fmt}; use bun_core::{OwnedStringCell, String as BunString, ZigString, strings}; use bun_http_jsc::fetch_enums_jsc::{ fetch_cache_mode_to_js, fetch_redirect_to_js, fetch_request_mode_to_js, @@ -1230,12 +1230,10 @@ impl Request { // Mark every remaining field consumed so the dictionary // fallbacks below never run JS getters against the input. if !fields.contains(Fields::Url) { - // A Bun.serve request materializes its URL lazily - // from the request context; a detached one stays - // empty and reaches the "url is required" throw. - // The field is consumed from internal state either - // way, keeping the getter fallback off. - let _ = request.ensure_url(); + // A Bun.serve request materializes its URL lazily; + // a detached one stays empty and reaches the "url + // is required" throw, with the field consumed. + request.ensure_url().unwrap_or_oom(); let url = request.url.get(); if !url.is_empty() { req.url.set(url.dupe_ref()); @@ -1610,7 +1608,7 @@ impl Request { preserve_url: bool, ) -> JsResult<()> { // allocator param dropped (global mimalloc) - let _ = self.ensure_url(); + self.ensure_url().unwrap_or_oom(); let body_ = self.clone_body_value_via_cached_stream(global_this)?; // BodyValue's Drop frees `body_` on the `?` error path let body = body::hive_alloc(body_);