From 43d575e48dded1e08fdbaffe377dd42ebf0d80f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:36:16 +0000 Subject: [PATCH 1/4] Extract a by-reference Request body after the other init members are read The Request constructor and fetch() copied a URLSearchParams, FormData, or typed array body as soon as they read the body member. A getter on a member read later (method, headers, signal) that mutates the body object was not part of the body. WebIDL converts every dictionary member first and the fetch spec extracts the body afterwards. Node, Deno, and the browsers reflect the mutation. BodyValue::is_extracted_by_reference names the body kinds that BodyValue::from_js reads by reference. Request::construct_into keeps such a body as a JSValue and extracts it after the URL is validated. fetch() reads and converts the headers member before it extracts such a body, and still applies a Blob or FormData Content-Type after. A body that converts to a string is still converted when the member is read. --- src/runtime/webcore/Body.rs | 28 ++++ src/runtime/webcore/Request.rs | 60 +++++--- src/runtime/webcore/fetch.rs | 228 ++++++++++++++-------------- test/js/web/request/request.test.ts | 193 +++++++++++++++++++++++ 4 files changed, 380 insertions(+), 129 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 5c8656e2d697..eb2e1ad784c8 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -910,6 +910,34 @@ impl Value { Ok(stream_value) } + /// A `body` init value that [`from_js`](Self::from_js) reads by reference: + /// ReadableStream, ArrayBuffer(View), FormData, URLSearchParams, Blob, Bun's + /// Image, and an Array of Blob parts. The fetch spec extracts such a body + /// after every other init member is converted, so a getter on a later + /// member that mutates the object is part of the body. Any other value + /// is converted when the member is read (WebIDL converts it to a string). + pub fn is_extracted_by_reference(value: JSValue) -> bool { + if !value.is_cell() || value.is_string() { + return false; + } + let js_type = value.js_type(); + if js_type.is_typed_array_or_array_buffer() + || matches!(js_type, jsc::JSType::Array | jsc::JSType::DerivedArray) + { + return true; + } + if DOMFormData::from_js(value).is_some() || URLSearchParams::from_js(value).is_some() { + return true; + } + if js_type == jsc::JSType::DOMWrapper + && (value.as_class_ref::().is_some() + || value.as_class_ref::().is_some()) + { + return true; + } + ReadableStream::is_readable_stream(value) + } + pub fn from_js(global_this: &JSGlobalObject, value: JSValue) -> JsResult { value.ensure_still_alive(); diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 3a793e86ba14..fe856824a3ba 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1055,6 +1055,11 @@ impl Request { let values_to_try = &values_to_try_[0..((!is_first_argument_a_url) as usize + init.is_object() as usize)]; + // A `body` member that is read by reference. It is extracted after every + // other member is read and the URL is validated. + let mut body_init: Option = None; + let mut keepalive_with_stream_body = false; + for &value in values_to_try { let value_type = value.js_type(); let explicit_check = values_to_try.len() == 2 @@ -1182,34 +1187,35 @@ impl Request { match value.fast_get(cx.global(), bun_jsc::BuiltinName::Body) { Ok(Some(body_)) => { fields.insert(Fields::Body); - // fetch spec Request(init): `keepalive: true` with a ReadableStream - // body throws before body extraction (Node's message is "keepalive"). - if crate::webcore::ReadableStream::is_readable_stream(body_) { - match value.get(cx.global(), "keepalive") { - Ok(Some(keepalive)) if keepalive.to_boolean() => { - bail!(Err(cx - .global() - .throw_type_error(format_args!("keepalive")))); + if BodyValue::is_extracted_by_reference(body_) { + body_init = Some(body_); + // fetch spec Request(init): `keepalive: true` with a ReadableStream + // body throws before body extraction (Node's message is "keepalive"). + if crate::webcore::ReadableStream::is_readable_stream(body_) { + match value.get(cx.global(), "keepalive") { + Ok(Some(keepalive)) => { + keepalive_with_stream_body = keepalive.to_boolean(); + } + Ok(None) => {} + Err(e) => bail!(Err(e)), + } + } + } else { + match BodyValue::from_js(cx.global(), body_) { + Ok(v) => { + *req.body_value_mut() = v; } - Ok(_) => {} Err(e) => bail!(Err(e)), } - } - match BodyValue::from_js(cx.global(), body_) { - Ok(v) => { - *req.body_value_mut() = v; + // BodyValue::from_js() throws without returning Err; see Blob::from_dom_form_data + if cx.global().has_exception() { + bail!(Err(JsError::Thrown)); } - Err(e) => bail!(Err(e)), } } Ok(None) => {} Err(e) => bail!(Err(e)), } - - // BodyValue::from_js() throws without returning Err; see Blob::from_dom_form_data - if cx.global().has_exception() { - bail!(Err(JsError::Thrown)); - } } if !fields.contains(Fields::Url) { @@ -1379,6 +1385,22 @@ impl Request { req.url.set(href); + if let Some(body_) = body_init { + if keepalive_with_stream_body { + bail!(Err(cx.global().throw_type_error(format_args!("keepalive")))); + } + match BodyValue::from_js(cx.global(), body_) { + Ok(v) => { + *req.body_value_mut() = v; + } + Err(e) => bail!(Err(e)), + } + // BodyValue::from_js() throws without returning Err; see Blob::from_dom_form_data + if cx.global().has_exception() { + bail!(Err(JsError::Thrown)); + } + } + if matches!(req.body_value(), BodyValue::Blob(_)) && req.headers.get().is_some() { if let BodyValue::Blob(blob) = req.body_value() { let ct: &[u8] = blob.content_type_slice(); diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 3a5e8c15af81..b7efb39ff025 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -986,30 +986,113 @@ fn fetch_impl( break 'extract_signal None; }; - // We do this 2nd to last instead of last so that if it's a FormData - // object, we can still insert the boundary. - // - // We must always get the Body before the Headers That way, we can set - // the Content-Type header from the Blob if no Content-Type header is - // set in the Headers - // - // which is important for FormData. - // https://github.com/oven-sh/bun/issues/2264 - // // body: BodyInit | null | undefined; // - let mut body = 'extract_body: { + // The `body` member is read here. A value that converts to a string is + // extracted now. A value that is read by reference (URLSearchParams, + // FormData, a typed array, a Blob, a stream) is extracted after the + // `headers` member is read and converted, as the fetch spec orders it: + // a `headers` getter that mutates the body object is part of the body. + let init_body: Option = 'brk: { if let Some(options) = options_object { if let Some(body__) = options.fast_get(global_this, jsc::BuiltinName::Body)? { if !body__.is_undefined() { - break 'extract_body Some(HTTPRequestBody::from_js( - &ctx.js_thread(context), - body__, - )?); + break 'brk Some(body__); } } } + if let Some(req) = request_init_object { + if let Some(body__) = req.fast_get(global_this, jsc::BuiltinName::Body)? { + if !body__.is_undefined() { + break 'brk Some(body__); + } + } + } + + break 'brk None; + }; + + let eager_body = match init_body { + Some(body__) if !BodyValue::is_extracted_by_reference(body__) => { + Some(HTTPRequestBody::from_js(&ctx.js_thread(context), body__)?) + } + _ => None, + }; + + // HTTPRequestBody::from_js() throws without returning Err; see Blob::from_dom_form_data + if global_this.has_exception() { + return Err(jsc::JsError::Thrown); + } + + // headers: Headers | undefined; + // + // The member is read and converted here, before a by-reference body is + // extracted. A Blob or FormData body sets the Content-Type after the + // body is extracted, when the headers have none (#2264). + // + // Holds the +1 from `create_from_js` until the headers are applied. + let mut created_headers: Option = None; + let fetch_headers: Option<*mut FetchHeaders> = 'brk: { + let init_headers: Option = 'init: { + if let Some(options) = options_object { + if let Some(headers_value) = + options.fast_get(global_this, jsc::BuiltinName::Headers)? + { + if !headers_value.is_undefined() { + break 'init Some(headers_value); + } + } + } + + if let Some(req) = request_ref!() { + if let Some(head) = req.get_fetch_headers_unless_empty() { + break 'brk Some(head.as_ptr()); + } + break 'brk None; + } + + if let Some(options) = request_init_object { + if let Some(headers_value) = + options.fast_get(global_this, jsc::BuiltinName::Headers)? + { + if !headers_value.is_undefined() { + break 'init Some(headers_value); + } + } + } + + break 'init None; + }; + + let Some(headers_value) = init_headers else { + break 'brk None; + }; + + if let Some(headers__) = FetchHeaders::cast(headers_value) { + // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. + if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { + break 'brk None; + } + break 'brk Some(headers__.as_ptr()); + } + + if let Some(headers__) = HeadersRef::create_from_js(ctx, headers_value)? { + break 'brk Some(created_headers.insert(headers__).as_ptr()); + } + + break 'brk None; + }; + + let mut body = 'extract_body: { + if eager_body.is_some() { + break 'extract_body eager_body; + } + + if let Some(body__) = init_body { + break 'extract_body Some(HTTPRequestBody::from_js(&ctx.js_thread(context), body__)?); + } + if let Some(req) = request_ref!() { let body_value = req.get_body_value(); let already_used = match body_value { @@ -1075,17 +1158,6 @@ fn fetch_impl( )); } - if let Some(req) = request_init_object { - if let Some(body__) = req.fast_get(global_this, jsc::BuiltinName::Body)? { - if !body__.is_undefined() { - break 'extract_body Some(HTTPRequestBody::from_js( - &ctx.js_thread(context), - body__, - )?); - } - } - } - break 'extract_body None; } .unwrap_or_default(); @@ -1095,94 +1167,30 @@ fn fetch_impl( return Err(jsc::JsError::Thrown); } - // headers: Headers | undefined; - headers = 'extract_headers: { - // Holds the +1 from `create_from_js` until this block exits. - let mut created_headers: Option = None; - - let fetch_headers: Option<*mut FetchHeaders> = 'brk: { - if let Some(options) = options_object { - if let Some(headers_value) = - options.fast_get(global_this, jsc::BuiltinName::Headers)? - { - if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { - break 'brk None; - } - break 'brk Some(headers__.as_ptr()); - } - - if let Some(headers__) = HeadersRef::create_from_js(ctx, headers_value)? { - break 'brk Some(created_headers.insert(headers__).as_ptr()); - } - - break 'brk None; - } - } - } - - if let Some(req) = request_ref!() { - if let Some(head) = req.get_fetch_headers_unless_empty() { - break 'brk Some(head.as_ptr()); - } - break 'brk None; - } - - if let Some(options) = request_init_object { - if let Some(headers_value) = - options.fast_get(global_this, jsc::BuiltinName::Headers)? - { - if !headers_value.is_undefined() { - if let Some(headers__) = FetchHeaders::cast(headers_value) { - // `FetchHeaders` is an opaque ZST FFI handle (S008) — safe deref. - if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { - break 'brk None; - } - break 'brk Some(headers__.as_ptr()); - } - - if let Some(headers__) = HeadersRef::create_from_js(ctx, headers_value)? { - break 'brk Some(created_headers.insert(headers__).as_ptr()); - } - - break 'brk None; - } - } - } - - break 'extract_headers headers; - }; - - let result = if let Some(headers_) = fetch_headers { - // `headers_` points to a live FetchHeaders (either JS-owned or - // refcounted via `created_headers` above). `FetchHeaders` is - // an opaque ZST FFI handle (S008) — safe `*mut → &mut` deref. - let headers_ref = bun_opaque::opaque_deref_mut(headers_); - if url.is_s3() { - if let Some(range_) = headers_ref.fast_get(HTTPHeaderName::Range) { - range = Some(range_.to_owned_slice_z()); - } + if let Some(headers_) = fetch_headers { + // `headers_` points to a live FetchHeaders (either JS-owned or + // refcounted via `created_headers` above). `FetchHeaders` is + // an opaque ZST FFI handle (S008) — safe `*mut → &mut` deref. + let headers_ref = bun_opaque::opaque_deref_mut(headers_); + if url.is_s3() { + if let Some(range_) = headers_ref.fast_get(HTTPHeaderName::Range) { + range = Some(range_.to_owned_slice_z()); } + } - if let Some(upgrade_) = headers_ref.fast_get(HTTPHeaderName::Upgrade) { - if http::upgrade_header_is_not_h2(upgrade_.to_utf8().slice()) { - upgraded_connection = true; - } + if let Some(upgrade_) = headers_ref.fast_get(HTTPHeaderName::Upgrade) { + if http::upgrade_header_is_not_h2(upgrade_.to_utf8().slice()) { + upgraded_connection = true; } + } - Some(from_fetch_headers( - global_this, - Some(headers_ref), - any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), - )?) - } else { - headers - }; - - break 'extract_headers result; - }; + headers = Some(from_fetch_headers( + global_this, + Some(headers_ref), + any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), + )?); + } + drop(created_headers); if proxy.is_some() && !unix_socket_path.is_empty() { let err = ctx.to_type_error( diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index 85e42bf8e420..d6b6063a8992 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -175,3 +175,196 @@ describe("RequestInit signal presence", () => { }); }); }); + +// WebIDL converts every RequestInit member before the body is extracted. A body that is read by +// reference (URLSearchParams, FormData, a typed array) sees a getter on another member that mutates +// it. A body that converts to a string is converted when `body` is read. +describe("RequestInit body is extracted after the other members are read (#48468)", () => { + function requestWithMutatingGetter(body: BodyInit, mutate: () => void) { + return new Request("https://example.com/", { + body, + get method() { + mutate(); + return "POST"; + }, + }); + } + + test("URLSearchParams", async () => { + const params = new URLSearchParams("a=1"); + const request = requestWithMutatingGetter(params, () => params.append("b", "2")); + expect(request.method).toBe("POST"); + expect(await request.text()).toBe("a=1&b=2"); + }); + + test("FormData", async () => { + const form = new FormData(); + form.append("a", "1"); + const request = requestWithMutatingGetter(form, () => form.append("b", "2")); + expect([...(await request.formData()).keys()]).toEqual(["a", "b"]); + }); + + test("Uint8Array", async () => { + const bytes = new TextEncoder().encode("a=1"); + const request = requestWithMutatingGetter(bytes, () => { + bytes[2] = "2".charCodeAt(0); + }); + expect(await request.text()).toBe("a=2"); + }); + + test("a detached Uint8Array is empty", async () => { + const bytes = new TextEncoder().encode("a=1"); + const request = requestWithMutatingGetter(bytes, () => { + structuredClone(bytes.buffer, { transfer: [bytes.buffer] }); + }); + expect(await request.text()).toBe(""); + }); + + test("an Array of parts", async () => { + const bytes = new TextEncoder().encode("a=1"); + const request = requestWithMutatingGetter([bytes] as unknown as BodyInit, () => { + bytes[2] = "2".charCodeAt(0); + }); + expect(await request.text()).toBe("a=2"); + }); + + test("headers getter", async () => { + const params = new URLSearchParams("a=1"); + const request = new Request("https://example.com/", { + body: params, + get headers() { + params.append("b", "2"); + return { "x-test": "1" }; + }, + }); + expect(request.headers.get("x-test")).toBe("1"); + expect(await request.text()).toBe("a=1&b=2"); + }); + + test("an object body is converted to a string when `body` is read", async () => { + let text = "a=1"; + const request = new Request("https://example.com/", { + body: { toString: () => text } as unknown as BodyInit, + get method() { + text = "mutated"; + return "POST"; + }, + }); + expect(await request.text()).toBe("a=1"); + }); + + test("init body still overrides the input request body", async () => { + const input = new Request("https://example.com/", { method: "POST", body: "old" }); + const params = new URLSearchParams("a=1"); + const request = new Request(input, { + body: params, + get redirect() { + params.append("b", "2"); + return "manual" as const; + }, + }); + expect(request.redirect).toBe("manual"); + expect(await request.text()).toBe("a=1&b=2"); + expect(await input.text()).toBe("old"); + }); + + test("keepalive with a ReadableStream body throws after the other members are read", () => { + let methodReads = 0; + expect( + () => + new Request("https://example.com/", { + get method() { + methodReads++; + return "POST"; + }, + body: new ReadableStream(), + keepalive: true, + }), + ).toThrow("keepalive"); + expect(methodReads).toBe(1); + }); + + test("an invalid URL throws before the body is extracted", () => { + expect(() => new Request("not a url", { method: "POST", body: new ReadableStream(), keepalive: true })).toThrow( + /Invalid URL/, + ); + }); + + test("an invalid header throws before a locked stream body is checked", () => { + const stream = new ReadableStream(); + stream.getReader(); + expect( + () => new Request("https://example.com/", { method: "POST", body: stream, headers: { "a b": "1" } }), + ).toThrow(/header/i); + }); + + describe("fetch()", () => { + async function echo(fn: (url: string) => Promise) { + using server = Bun.serve({ + port: 0, + fetch: async req => new Response(await req.text()), + }); + return await (await fn(server.url.href)).text(); + } + + test("reads init.headers before it extracts a URLSearchParams body", async () => { + const params = new URLSearchParams("a=1"); + const text = await echo(url => + fetch(url, { + method: "POST", + body: params, + get headers() { + params.append("b", "2"); + return {}; + }, + }), + ); + expect(text).toBe("a=1&b=2"); + }); + + test("reads init.headers before it extracts the body of an init object input", async () => { + const params = new URLSearchParams("a=1"); + const text = await echo(url => + fetch({ + url, + method: "POST", + body: params, + get headers() { + params.append("b", "2"); + return {}; + }, + } as unknown as Request), + ); + expect(text).toBe("a=1&b=2"); + }); + + test("converts an object body to a string when `body` is read", async () => { + let value = "a=1"; + const order: string[] = []; + const text = await echo(url => + fetch(url, { + method: "POST", + get body() { + order.push("body"); + return { toString: () => value } as unknown as BodyInit; + }, + get headers() { + order.push("headers"); + value = "mutated"; + return {}; + }, + }), + ); + expect(order).toEqual(["body", "headers"]); + expect(text).toBe("a=1"); + }); + + test("an invalid header rejects before a locked stream body is checked", async () => { + const stream = new ReadableStream(); + stream.getReader(); + await expect( + fetch("http://127.0.0.1:1/", { method: "POST", body: stream, headers: { "a b": "1" } }), + ).rejects.toThrow(/header/i); + }); + }); +}); From fffca9722f3bfe4220ae0db66b474928d6dad6e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:38:32 +0000 Subject: [PATCH 2/4] Keep a Headers init value alive while a by-reference body is extracted fetch() borrows the FetchHeaders of a Headers object passed as init.headers. The deferred body extraction can run user JS, so an EnsureStillAlive guard roots the wrapper until the headers are applied. DataView bodies are extracted by reference like the other views. The fetch() tests move to fetch.test.ts. --- src/runtime/webcore/Body.rs | 2 +- src/runtime/webcore/fetch.rs | 6 ++ test/js/web/fetch/fetch.test.ts | 95 +++++++++++++++++++++++++++++ test/js/web/request/request.test.ts | 78 +++-------------------- 4 files changed, 110 insertions(+), 71 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index eb2e1ad784c8..2618b7f75ec2 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -921,7 +921,7 @@ impl Value { return false; } let js_type = value.js_type(); - if js_type.is_typed_array_or_array_buffer() + if js_type.is_array_buffer_like() || matches!(js_type, jsc::JSType::Array | jsc::JSType::DerivedArray) { return true; diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index b7efb39ff025..5e017b9ef7c9 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1033,6 +1033,10 @@ fn fetch_impl( // // Holds the +1 from `create_from_js` until the headers are applied. let mut created_headers: Option = None; + // A `Headers` object is borrowed, not ref'd. The guard keeps its wrapper + // alive until the headers are applied: the body extraction in between + // can run user JS. + let mut headers_guard: Option = None; let fetch_headers: Option<*mut FetchHeaders> = 'brk: { let init_headers: Option = 'init: { if let Some(options) = options_object { @@ -1074,6 +1078,7 @@ fn fetch_impl( if bun_opaque::opaque_deref_mut(headers__.as_ptr()).is_empty() { break 'brk None; } + headers_guard = Some(jsc::EnsureStillAlive(headers_value)); break 'brk Some(headers__.as_ptr()); } @@ -1190,6 +1195,7 @@ fn fetch_impl( any_blob_content_type_opt(body.get_any_blob().map(|b| &*b)), )?); } + drop(headers_guard); drop(created_headers); if proxy.is_some() && !unix_socket_path.is_empty() { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 6ba59643ea5d..83f01843ac12 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4290,3 +4290,98 @@ it("verbose fetch logging escapes control characters coming from the peer", asyn expect(stderr).not.toMatch(/[\x80-\x9f]/); expect(exitCode).toBe(0); }); + +// fetch() reads and converts init.headers before it extracts a body that is read by reference +// (URLSearchParams, FormData, a typed array), so a `headers` getter that mutates the body object is +// part of the body (#48468). +describe("fetch() extracts a by-reference body after init.headers is read", () => { + async function echo(fn: (url: string) => Promise) { + using server = Bun.serve({ + port: 0, + fetch: async req => new Response(await req.text()), + }); + return await (await fn(server.url.href)).text(); + } + + it("reads init.headers before it extracts a URLSearchParams body", async () => { + const params = new URLSearchParams("a=1"); + const text = await echo(url => + fetch(url, { + method: "POST", + body: params, + get headers() { + params.append("b", "2"); + return {}; + }, + }), + ); + expect(text).toBe("a=1&b=2"); + }); + + it("reads init.headers before it extracts the body of an init object input", async () => { + const params = new URLSearchParams("a=1"); + const text = await echo(url => + fetch({ + url, + method: "POST", + body: params, + get headers() { + params.append("b", "2"); + return {}; + }, + } as unknown as Request), + ); + expect(text).toBe("a=1&b=2"); + }); + + it("converts an object body to a string when `body` is read", async () => { + let value = "a=1"; + const order: string[] = []; + const text = await echo(url => + fetch(url, { + method: "POST", + get body() { + order.push("body"); + return { toString: () => value } as unknown as BodyInit; + }, + get headers() { + order.push("headers"); + value = "mutated"; + return {}; + }, + }), + ); + expect(order).toEqual(["body", "headers"]); + expect(text).toBe("a=1"); + }); + + it("keeps a Headers object from a getter alive while the body is extracted", async () => { + using server = Bun.serve({ + port: 0, + fetch: async req => new Response(`${req.headers.get("x-test")}:${await req.text()}`), + }); + const response = await fetch(server.url, { + method: "POST", + get headers() { + return new Headers({ "x-test": "1" }); + }, + body: [ + { + toString() { + Bun.gc(true); + return "a=1"; + }, + }, + ] as unknown as BodyInit, + }); + expect(await response.text()).toBe("1:a=1"); + }); + + it("an invalid header rejects before a locked stream body is checked", async () => { + const stream = new ReadableStream(); + stream.getReader(); + await expect( + fetch("http://127.0.0.1:1/", { method: "POST", body: stream, headers: { "a b": "1" } }), + ).rejects.toThrow(/header/i); + }); +}); diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index d6b6063a8992..860b4b5365be 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -212,6 +212,14 @@ describe("RequestInit body is extracted after the other members are read (#48468 expect(await request.text()).toBe("a=2"); }); + test("DataView", async () => { + const bytes = new TextEncoder().encode("a=1"); + const request = requestWithMutatingGetter(new DataView(bytes.buffer), () => { + bytes[2] = "2".charCodeAt(0); + }); + expect(await request.text()).toBe("a=2"); + }); + test("a detached Uint8Array is empty", async () => { const bytes = new TextEncoder().encode("a=1"); const request = requestWithMutatingGetter(bytes, () => { @@ -297,74 +305,4 @@ describe("RequestInit body is extracted after the other members are read (#48468 () => new Request("https://example.com/", { method: "POST", body: stream, headers: { "a b": "1" } }), ).toThrow(/header/i); }); - - describe("fetch()", () => { - async function echo(fn: (url: string) => Promise) { - using server = Bun.serve({ - port: 0, - fetch: async req => new Response(await req.text()), - }); - return await (await fn(server.url.href)).text(); - } - - test("reads init.headers before it extracts a URLSearchParams body", async () => { - const params = new URLSearchParams("a=1"); - const text = await echo(url => - fetch(url, { - method: "POST", - body: params, - get headers() { - params.append("b", "2"); - return {}; - }, - }), - ); - expect(text).toBe("a=1&b=2"); - }); - - test("reads init.headers before it extracts the body of an init object input", async () => { - const params = new URLSearchParams("a=1"); - const text = await echo(url => - fetch({ - url, - method: "POST", - body: params, - get headers() { - params.append("b", "2"); - return {}; - }, - } as unknown as Request), - ); - expect(text).toBe("a=1&b=2"); - }); - - test("converts an object body to a string when `body` is read", async () => { - let value = "a=1"; - const order: string[] = []; - const text = await echo(url => - fetch(url, { - method: "POST", - get body() { - order.push("body"); - return { toString: () => value } as unknown as BodyInit; - }, - get headers() { - order.push("headers"); - value = "mutated"; - return {}; - }, - }), - ); - expect(order).toEqual(["body", "headers"]); - expect(text).toBe("a=1"); - }); - - test("an invalid header rejects before a locked stream body is checked", async () => { - const stream = new ReadableStream(); - stream.getReader(); - await expect( - fetch("http://127.0.0.1:1/", { method: "POST", body: stream, headers: { "a b": "1" } }), - ).rejects.toThrow(/header/i); - }); - }); }); From 89f60f12c54025ad1ba5926573b4c3e3fde5bed5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:48:12 +0000 Subject: [PATCH 3/4] Shorten the comments on the deferred body extraction --- src/runtime/webcore/Body.rs | 9 +++------ src/runtime/webcore/Request.rs | 6 ++---- src/runtime/webcore/fetch.rs | 15 ++++----------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 2618b7f75ec2..17a609f7c307 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -910,12 +910,9 @@ impl Value { Ok(stream_value) } - /// A `body` init value that [`from_js`](Self::from_js) reads by reference: - /// ReadableStream, ArrayBuffer(View), FormData, URLSearchParams, Blob, Bun's - /// Image, and an Array of Blob parts. The fetch spec extracts such a body - /// after every other init member is converted, so a getter on a later - /// member that mutates the object is part of the body. Any other value - /// is converted when the member is read (WebIDL converts it to a string). + /// True for a `body` init that [`from_js`](Self::from_js) reads by reference + /// (stream, buffer, FormData, URLSearchParams, Blob, Image, Array of parts). + /// The fetch spec extracts it after the other init members are converted. pub fn is_extracted_by_reference(value: JSValue) -> bool { if !value.is_cell() || value.is_string() { return false; diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index fe856824a3ba..134e78acc74e 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -1055,8 +1055,7 @@ impl Request { let values_to_try = &values_to_try_[0..((!is_first_argument_a_url) as usize + init.is_object() as usize)]; - // A `body` member that is read by reference. It is extracted after every - // other member is read and the URL is validated. + // A by-reference `body`, extracted after the other members and the URL check. let mut body_init: Option = None; let mut keepalive_with_stream_body = false; @@ -1189,8 +1188,7 @@ impl Request { fields.insert(Fields::Body); if BodyValue::is_extracted_by_reference(body_) { body_init = Some(body_); - // fetch spec Request(init): `keepalive: true` with a ReadableStream - // body throws before body extraction (Node's message is "keepalive"). + // `keepalive: true` with a stream body throws "keepalive" (as Node). if crate::webcore::ReadableStream::is_readable_stream(body_) { match value.get(cx.global(), "keepalive") { Ok(Some(keepalive)) => { diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 5e017b9ef7c9..9b5139569f09 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -988,11 +988,7 @@ fn fetch_impl( // body: BodyInit | null | undefined; // - // The `body` member is read here. A value that converts to a string is - // extracted now. A value that is read by reference (URLSearchParams, - // FormData, a typed array, a Blob, a stream) is extracted after the - // `headers` member is read and converted, as the fetch spec orders it: - // a `headers` getter that mutates the body object is part of the body. + // A by-reference body is extracted after `headers` is read and converted. let init_body: Option = 'brk: { if let Some(options) = options_object { if let Some(body__) = options.fast_get(global_this, jsc::BuiltinName::Body)? { @@ -1027,15 +1023,12 @@ fn fetch_impl( // headers: Headers | undefined; // - // The member is read and converted here, before a by-reference body is - // extracted. A Blob or FormData body sets the Content-Type after the - // body is extracted, when the headers have none (#2264). + // Converted here. A Blob or FormData Content-Type is applied after the + // body is extracted (#2264). // // Holds the +1 from `create_from_js` until the headers are applied. let mut created_headers: Option = None; - // A `Headers` object is borrowed, not ref'd. The guard keeps its wrapper - // alive until the headers are applied: the body extraction in between - // can run user JS. + // Roots a borrowed `Headers` wrapper across the body extraction, which can run JS. let mut headers_guard: Option = None; let fetch_headers: Option<*mut FetchHeaders> = 'brk: { let init_headers: Option = 'init: { From f0ee09b74c50aa054b912187b0ca33ebb9ed07ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:02:22 +0000 Subject: [PATCH 4/4] Pin the error messages in the body ordering tests, cover a DataView window --- src/runtime/webcore/Body.rs | 5 ++--- src/runtime/webcore/fetch.rs | 4 +--- test/js/web/fetch/fetch.test.ts | 32 +++++++++++++++-------------- test/js/web/request/request.test.ts | 12 +++++++++-- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 17a609f7c307..910fdb474566 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -910,9 +910,8 @@ impl Value { Ok(stream_value) } - /// True for a `body` init that [`from_js`](Self::from_js) reads by reference - /// (stream, buffer, FormData, URLSearchParams, Blob, Image, Array of parts). - /// The fetch spec extracts it after the other init members are converted. + /// A `body` init that [`from_js`](Self::from_js) reads by reference, so the + /// fetch spec extracts it after the other init members are converted. pub fn is_extracted_by_reference(value: JSValue) -> bool { if !value.is_cell() || value.is_string() { return false; diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 9b5139569f09..218f7833a186 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1023,9 +1023,6 @@ fn fetch_impl( // headers: Headers | undefined; // - // Converted here. A Blob or FormData Content-Type is applied after the - // body is extracted (#2264). - // // Holds the +1 from `create_from_js` until the headers are applied. let mut created_headers: Option = None; // Roots a borrowed `Headers` wrapper across the body extraction, which can run JS. @@ -1165,6 +1162,7 @@ fn fetch_impl( return Err(jsc::JsError::Thrown); } + // A Blob or FormData Content-Type is applied after the body is extracted (#2264). if let Some(headers_) = fetch_headers { // `headers_` points to a live FetchHeaders (either JS-owned or // refcounted via `created_headers` above). `FetchHeaders` is diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 83f01843ac12..4708c1566654 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -4360,21 +4360,23 @@ describe("fetch() extracts a by-reference body after init.headers is read", () = port: 0, fetch: async req => new Response(`${req.headers.get("x-test")}:${await req.text()}`), }); - const response = await fetch(server.url, { - method: "POST", - get headers() { - return new Headers({ "x-test": "1" }); - }, - body: [ - { - toString() { - Bun.gc(true); - return "a=1"; - }, + for (let i = 0; i < 16; i++) { + const response = await fetch(server.url, { + method: "POST", + get headers() { + return new Headers({ "x-test": "1" }); }, - ] as unknown as BodyInit, - }); - expect(await response.text()).toBe("1:a=1"); + body: [ + { + toString() { + Bun.gc(true); + return "a=1"; + }, + }, + ] as unknown as BodyInit, + }); + expect(await response.text()).toBe("1:a=1"); + } }); it("an invalid header rejects before a locked stream body is checked", async () => { @@ -4382,6 +4384,6 @@ describe("fetch() extracts a by-reference body after init.headers is read", () = stream.getReader(); await expect( fetch("http://127.0.0.1:1/", { method: "POST", body: stream, headers: { "a b": "1" } }), - ).rejects.toThrow(/header/i); + ).rejects.toThrow("Invalid header name: 'a b'"); }); }); diff --git a/test/js/web/request/request.test.ts b/test/js/web/request/request.test.ts index 860b4b5365be..f7b9b8ac99e8 100644 --- a/test/js/web/request/request.test.ts +++ b/test/js/web/request/request.test.ts @@ -220,6 +220,14 @@ describe("RequestInit body is extracted after the other members are read (#48468 expect(await request.text()).toBe("a=2"); }); + test("DataView with a byte offset", async () => { + const bytes = new TextEncoder().encode("xa=1y"); + const request = requestWithMutatingGetter(new DataView(bytes.buffer, 1, 3), () => { + bytes[3] = "2".charCodeAt(0); + }); + expect(await request.text()).toBe("a=2"); + }); + test("a detached Uint8Array is empty", async () => { const bytes = new TextEncoder().encode("a=1"); const request = requestWithMutatingGetter(bytes, () => { @@ -294,7 +302,7 @@ describe("RequestInit body is extracted after the other members are read (#48468 test("an invalid URL throws before the body is extracted", () => { expect(() => new Request("not a url", { method: "POST", body: new ReadableStream(), keepalive: true })).toThrow( - /Invalid URL/, + `Failed to construct 'Request': Invalid URL "not a url"`, ); }); @@ -303,6 +311,6 @@ describe("RequestInit body is extracted after the other members are read (#48468 stream.getReader(); expect( () => new Request("https://example.com/", { method: "POST", body: stream, headers: { "a b": "1" } }), - ).toThrow(/header/i); + ).toThrow("Invalid header name: 'a b'"); }); });