diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index fc5508d0c53..afc6c7dd0c9 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -6494,7 +6494,11 @@ impl Any { pub fn has_content_type_from_user(&self) -> bool { match self { Any::Blob(b) => b.has_content_type_from_user(), - Any::WTFStringImpl(_) | Any::InternalBlob(_) => false, + // fetch spec: a USVString body extracts with type + // `text/plain;charset=UTF-8`, the same as URLSearchParams/FormData + // extract with their types; treat it as present for header emission. + Any::WTFStringImpl(_) => true, + Any::InternalBlob(ib) => ib.was_string, } } } diff --git a/src/runtime/webcore/Body.rs b/src/runtime/webcore/Body.rs index 76d07f970a0..b1741b7721d 100644 --- a/src/runtime/webcore/Body.rs +++ b/src/runtime/webcore/Body.rs @@ -698,6 +698,22 @@ impl Value { _ => false, } } + + /// `Content-Type` value the fetch spec's "extract a body" assigns to this + /// body, for appending to the header list when none was explicitly set. + /// `None` for body kinds that carry no implicit type (ArrayBuffer, + /// ReadableStream, null). + pub fn content_type(&self) -> Option<&[u8]> { + match self { + Value::Blob(blob) => { + let ct = blob.content_type_slice(); + (!ct.is_empty()).then_some(ct) + } + Value::WTFStringImpl(_) => Some(b"text/plain;charset=utf-8"), + Value::InternalBlob(ib) if ib.was_string => Some(ib.content_type()), + _ => None, + } + } } impl Value { @@ -1595,6 +1611,16 @@ impl Value { } if let Value::InternalBlob(internal_blob) = self { + // String bodies stay `InternalBlob` so `was_string` survives on + // both the original and the clone; converting to `Blob` here would + // either lose `Value::content_type()`'s `text/plain` or (if + // stamped on the Blob) shadow an explicit header in `.blob().type`. + if internal_blob.was_string { + return Ok(Value::InternalBlob(InternalBlob { + bytes: internal_blob.bytes.clone(), + was_string: true, + })); + } let owned = internal_blob.to_owned_slice(); *self = Value::Blob(Blob::init(owned, global_this)); } diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index 1c7ceaf67f3..b75499020cd 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -263,9 +263,9 @@ impl Request { self.headers.set(Some(HeadersRef::create_empty())); // Snapshot the pointer first; it stays valid across the field borrow. let content_type: Option<*const [u8]> = match self.body_value() { - BodyValue::Blob(blob) => { - Some(std::ptr::from_ref::<[u8]>(blob.content_type_slice())) - } + v @ (BodyValue::Blob(_) + | BodyValue::WTFStringImpl(_) + | BodyValue::InternalBlob(_)) => v.content_type().map(std::ptr::from_ref::<[u8]>), BodyValue::Locked(locked) => match locked.readable.get(global_this) { Some(readable) => match readable.ptr { crate::webcore::readable_stream::Source::Blob(blob) => { @@ -357,11 +357,8 @@ impl Request { } } - if let BodyValue::Blob(blob) = self.body_value() { - let ct = blob.content_type_slice(); - if !ct.is_empty() { - return Ok(Some(bun_core::ZigStringSlice::from_utf8_never_free(ct))); - } + if let Some(ct) = self.body_value().content_type() { + return Ok(Some(bun_core::ZigStringSlice::from_utf8_never_free(ct))); } Ok(None) @@ -1526,19 +1523,13 @@ impl Request { req.url.set(href); - 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(); - if !ct.is_empty() - && !req - .headers_mut() - .as_mut() - .unwrap() - .fast_has(HTTPHeaderName::ContentType) - { - // Reshaped for borrowck — split borrow of req.body and req.headers - let ct_ptr: *const [u8] = ct; - match req.headers_mut().as_mut().unwrap().put( + if req.headers.get().is_some() { + if let Some(ct) = req.body_value().content_type() { + // Reshaped for borrowck — split borrow of req.body and req.headers + let ct_ptr: *const [u8] = ct; + let headers = req.headers_mut().as_mut().unwrap(); + if !headers.fast_has(HTTPHeaderName::ContentType) { + match headers.put( HTTPHeaderName::ContentType, // SAFETY: ct_ptr borrows req.body which is not mutated here. &BunString::ascii(unsafe { &*ct_ptr }), diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs index 1a6df36bb3a..1966a0cac40 100644 --- a/src/runtime/webcore/Response.rs +++ b/src/runtime/webcore/Response.rs @@ -629,15 +629,12 @@ impl Response { if init.headers.is_none() { init.headers = Some(HeadersRef::create_empty()); - if let BodyValue::Blob(blob) = self.body.get().value.get() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() { - init.headers.as_mut().unwrap().put( - HTTPHeaderName::ContentType, - &BunString::ascii(content_type), - global_this, - )?; - } + if let Some(content_type) = self.body.get().value.get().content_type() { + init.headers.as_mut().unwrap().put( + HTTPHeaderName::ContentType, + &BunString::ascii(content_type), + global_this, + )?; } } @@ -657,11 +654,8 @@ impl Response { } } - if let BodyValue::Blob(blob) = self.body.get().value.get() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() { - return Ok(Some(ZigStringSlice::from_utf8_never_free(content_type))); - } + if let Some(content_type) = self.body.get().value.get().content_type() { + return Ok(Some(ZigStringSlice::from_utf8_never_free(content_type))); } Ok(None) @@ -1027,9 +1021,15 @@ impl Response { } } - let headers_ref = response.get_or_create_headers(global_this)?; + // `get_or_create_headers` would copy the string body's `text/plain` + // into a fresh header list, so materialize the headers here and set + // the JSON type before that path sees the body. + let init = response.init_mut(); + if init.headers.is_none() { + init.headers = Some(HeadersRef::create_empty()); + } let json_mime = bun_http_types::MimeType::JSON; - headers_ref.put_default( + init.headers.as_mut().unwrap().put_default( HTTPHeaderName::ContentType, &BunString::ascii(json_mime.value.as_ref()), global_this, @@ -1270,10 +1270,9 @@ impl Response { // Perform the only remaining fallible op BEFORE heap-allocating: // doing it on stack locals lets `?` trigger the scopeguard and // `init`'s drop glue and avoids leaking the heap allocation entirely. - if let BodyValue::Blob(blob) = body.value.get() { + if let Some(content_type) = body.value.get().content_type() { if let Some(headers) = init.headers.as_deref_mut() { - let content_type = blob.content_type_slice(); - if !content_type.is_empty() && !headers.fast_has(HTTPHeaderName::ContentType) { + if !headers.fast_has(HTTPHeaderName::ContentType) { headers.put( HTTPHeaderName::ContentType, &BunString::ascii(content_type), diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index e76766e7f42..9743da01ad9 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -125,7 +125,9 @@ it("Blob inspect", () => { url: "", status: 200, statusText: "", - headers: Headers {}, + headers: Headers { + "content-type": "text/plain;charset=utf-8", + }, redirected: false, bodyUsed: false, Blob (5 bytes) diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 7bf65154a88..0754858c210 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -2939,3 +2939,150 @@ it("an explicit numeric `timeout` extends the socket idle deadline past the defa expect(out.withDefault).toStartWith("ERR:"); expect(exitCode).toBe(0); }, 60_000); + +describe("Content-Type implied by the body init per fetch spec", () => { + // WHATWG fetch "extract a body": a scalar-value-string body extracts with + // type text/plain;charset=UTF-8, URLSearchParams with + // application/x-www-form-urlencoded;charset=UTF-8; that type is appended to + // the header list when no Content-Type was explicitly set. ArrayBuffer / + // typed arrays / ReadableStream carry no type. + const textPlain = /^text\/plain;charset=utf-8$/i; + const urlencoded = /^application\/x-www-form-urlencoded;charset=utf-8$/i; + + it("string body: fetch() sends it on the wire", async () => { + let nextHead = Promise.withResolvers(); + await using server = net.createServer(socket => { + const resolver = nextHead; + let buf = Buffer.alloc(0); + socket.on("data", d => { + buf = Buffer.concat([buf, d]); + if (buf.indexOf("\r\n\r\n") < 0) return; + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + resolver.resolve(buf.toString("latin1").split("\r\n\r\n")[0]); + }); + socket.on("error", err => resolver.reject(err)); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + const contentTypeHeader = (head: string) => head.split("\r\n").find(l => /^content-type:/i.test(l)) ?? null; + + const doFetch = async (init: RequestInit) => { + nextHead = Promise.withResolvers(); + await (await fetch(`http://127.0.0.1:${port}/`, { method: "POST", ...init })).arrayBuffer(); + return contentTypeHeader(await nextHead.promise); + }; + + // Plain string body, no headers. + expect(await doFetch({ body: "hello" })).toMatch(/^content-type:\s*text\/plain;charset=utf-8$/i); + + // Plain string body, with unrelated headers. + expect(await doFetch({ body: "hello", headers: { "x-foo": "bar" } })).toMatch( + /^content-type:\s*text\/plain;charset=utf-8$/i, + ); + + // Non-ASCII string body (exercises the InternalBlob{was_string} path). + expect(await doFetch({ body: "héllo ☃" })).toMatch(/^content-type:\s*text\/plain;charset=utf-8$/i); + + // Explicit Content-Type wins over the implied type. + expect(await doFetch({ body: "hello", headers: { "content-type": "application/json" } })).toMatch( + /^content-type:\s*application\/json$/i, + ); + + // URLSearchParams body (control: already worked). + expect(await doFetch({ body: new URLSearchParams("a=1") })).toMatch( + /^content-type:\s*application\/x-www-form-urlencoded;charset=utf-8$/i, + ); + + // ArrayBuffer / Uint8Array body does NOT get a Content-Type. + expect(await doFetch({ body: new Uint8Array([1, 2, 3]) })).toBeNull(); + expect(await doFetch({ body: new Uint8Array([1, 2, 3]).buffer })).toBeNull(); + }); + + it("string body: fetch(request) sends it on the wire", async () => { + let received: string | null | undefined; + await using server = Bun.serve({ + port: 0, + fetch(req) { + received = req.headers.get("content-type"); + return new Response(); + }, + }); + const req = new Request(server.url, { method: "POST", body: "hello" }); + await (await fetch(req)).arrayBuffer(); + expect(received).toMatch(textPlain); + }); + + it.each([ + ["string", "hello", textPlain], + ["non-ASCII string", "héllo ☃", textPlain], + ["URLSearchParams", new URLSearchParams("a=1"), urlencoded], + ] as const)("%s body: Request/Response .headers expose it", (_, body, expected) => { + const req = new Request("http://x/", { method: "POST", body }); + expect(req.headers.get("content-type")).toMatch(expected); + + const req2 = new Request("http://x/", { method: "POST", body, headers: { "x-foo": "bar" } }); + expect(req2.headers.get("content-type")).toMatch(expected); + + // Explicit header wins. + const req3 = new Request("http://x/", { + method: "POST", + body, + headers: { "content-type": "application/json" }, + }); + expect(req3.headers.get("content-type")).toBe("application/json"); + + const res = new Response(body); + expect(res.headers.get("content-type")).toMatch(expected); + + const res2 = new Response(body, { headers: { "x-foo": "bar" } }); + expect(res2.headers.get("content-type")).toMatch(expected); + + const res3 = new Response(body, { headers: { "content-type": "application/json" } }); + expect(res3.headers.get("content-type")).toBe("application/json"); + }); + + it.each([ + ["string", "hello", textPlain], + ["non-ASCII string", "héllo ☃", textPlain], + ["URLSearchParams", new URLSearchParams("a=1"), urlencoded], + ] as const)("%s body: survives .clone() on both the clone and the original", (_, body, expected) => { + // Cloning before .headers is read exercises the lazy path: the body's + // implied type must not be lost when clone() normalizes the body variant. + const res = new Response(body); + const resClone = res.clone(); + expect(resClone.headers.get("content-type")).toMatch(expected); + expect(res.headers.get("content-type")).toMatch(expected); + + const req = new Request("http://x/", { method: "POST", body }); + const reqClone = req.clone(); + expect(reqClone.headers.get("content-type")).toMatch(expected); + expect(req.headers.get("content-type")).toMatch(expected); + }); + + it.each(["hello", "héllo ☃"] as const)( + "%p body: .clone() does not shadow an explicit Content-Type in .blob().type", + async body => { + // The body-implied text/plain must not override a user-set header on the + // returned Blob, either on the clone or the (mutated) original. + const res = new Response(body, { headers: { "content-type": "text/html" } }); + const clone = res.clone(); + expect((await clone.blob()).type).toMatch(/^text\/html/); + expect((await res.blob()).type).toMatch(/^text\/html/); + + const json = Response.json({ v: body }); + const jsonClone = json.clone(); + expect((await jsonClone.blob()).type).toMatch(/^application\/json/); + expect((await json.blob()).type).toMatch(/^application\/json/); + }, + ); + + it("ArrayBuffer / typed-array body: no implicit Content-Type", () => { + for (const body of [new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]).buffer]) { + const req = new Request("http://x/", { method: "POST", body }); + expect(req.headers.get("content-type")).toBeNull(); + const res = new Response(body); + expect(res.headers.get("content-type")).toBeNull(); + } + }); +}); diff --git a/test/vendor.json b/test/vendor.json index be7f81d00a5..00357cef2db 100644 --- a/test/vendor.json +++ b/test/vendor.json @@ -6,7 +6,8 @@ "skipTests": { "ws*connection.test.ts": "TEMPORARY: elysia 1.4.28 asserts wasClean=false for a server-initiated ws.close(), but that's a clean Close handshake — Bun now reports wasClean=true to match Node/WHATWG (oven-sh/bun#31518). Fixed upstream in elysiajs/elysia#1908; remove this skip on the next elysia bump. (glob * = path separator, so it also matches Windows backslash paths)", "adapter*web-standard*map-response.test.ts": "TEMPORARY: Bun's Response.redirect now puts the WHATWG serialization of the url into Location (oven-sh/bun#33126), matching Node and https://fetch.spec.whatwg.org/#dom-response-redirect, so Response.redirect('https://cunny.school') yields 'https://cunny.school/'. elysia 1.4.28's 'map redirect' test asserts the unserialized string; remove this skip once the upstream assertion expects the trailing slash.", - "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value." + "adapter*web-standard*map-early-response.test.ts": "TEMPORARY: same as adapter*web-standard*map-response.test.ts; its 'map redirect' test asserts the unserialized Response.redirect Location value.", + "core*elysia.test.ts": "TEMPORARY: new Response('string').headers now exposes content-type: text/plain;charset=utf-8 per the fetch spec (oven-sh/bun#17085, oven-sh/bun#8530), matching Node/undici and browsers. elysia 1.4.28's 'automatically handle HEAD request for GET dynamic path' test asserts the HEAD response headers are exactly {content-length: '11'}; remove this skip once the upstream assertion includes content-type." } } ]