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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/runtime/webcore/Blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions src/runtime/webcore/Body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
robobun marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

impl Value {
Expand Down Expand Up @@ -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,
}));
}
Comment thread
robobun marked this conversation as resolved.
let owned = internal_blob.to_owned_slice();
*self = Value::Blob(Blob::init(owned, global_this));
}
Expand Down
33 changes: 12 additions & 21 deletions src/runtime/webcore/Request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }),
Expand Down
37 changes: 18 additions & 19 deletions src/runtime/webcore/Response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)?;
}
}

Comment thread
robobun marked this conversation as resolved.
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion test/js/bun/util/inspect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions test/js/web/fetch/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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<string>();
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();
}
});
});
3 changes: 2 additions & 1 deletion test/vendor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
]
Loading