Skip to content

fetch: send text/plain Content-Type for string request bodies - #33677

Open
robobun wants to merge 6 commits into
mainfrom
farm/08ef360e/fetch-string-body-content-type
Open

robobun wants to merge 6 commits into
mainfrom
farm/08ef360e/fetch-string-body-content-type

Conversation

@robobun

@robobun robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

fetch(url, {method: "POST", body: "<string>"}) sends the request with no Content-Type header on the wire. node/undici, deno, and browsers all send text/plain;charset=UTF-8 for the identical call, as the WHATWG fetch spec's "extract a body" step mandates for scalar-value-string bodies.

import net from "node:net";
const srv = net.createServer(s => {
  let buf = Buffer.alloc(0);
  s.on("data", d => {
    buf = Buffer.concat([buf, d]);
    if (buf.indexOf("\r\n\r\n") < 0) return;
    console.log(buf.toString("latin1").split("\r\n").find(x => /^content-type:/i.test(x)) ?? "<no Content-Type>");
    s.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
  });
});
await new Promise(r => srv.listen(0, "127.0.0.1", r));
await (await fetch(`http://127.0.0.1:${srv.address().port}/`, { method: "POST", body: "hello" })).arrayBuffer();
srv.close();

// node v26:  content-type: text/plain;charset=UTF-8
// deno 2.9:  content-type: text/plain;charset=UTF-8
// bun:       <no Content-Type>

The same omission made new Request("http://x/", {method:"POST", body:"hello"}).headers.get("content-type") and new Response("hello").headers.get("content-type") both return null.

Servers that dispatch their body parser on Content-Type (express.text(), Rails, many WAFs and API gateways) see an untyped POST from bun: bodies silently not parsed, or rejected with 415.

Cause

A string body is stored as body::Value::WTFStringImpl (or InternalBlob { was_string: true } after UTF-8 conversion). AnyBlob::content_type() already returns text/plain;charset=utf-8 for these, but every site that copies the body's type into the header list is gated one of two ways:

  • the fetch wire path gates on AnyBlob::has_content_type_from_user(), which returned false for both string variants;
  • the Request and Response constructor / lazy-headers paths match only BodyValue::Blob(_).

So the string body's implicit type was never written to the header list. URLSearchParams and FormData bodies already worked because they are stored as Value::Blob with content_type_was_set.

Fix

Add body::Value::content_type() returning the type the spec assigns to each body variant: a Blob's explicit content type if set, text/plain;charset=utf-8 for WTFStringImpl and InternalBlob { was_string: true }, and None for ArrayBuffer / ReadableStream / null bodies (which the spec assigns no type). Use it at every site that copies the body's type into the header list:

  • Request::construct_into / ensure_fetch_headers / get_content_type
  • Response::constructor / get_or_create_headers / get_content_type

Update AnyBlob::has_content_type_from_user() to return true for WTFStringImpl and InternalBlob { was_string: true } so the fetch wire serializer (from_fetch_headers via any_blob_content_type_opt) and the headers_ref helpers see string bodies as having a type. InternalBlob { was_string: false } (ArrayBuffer / typed-array bodies) keeps returning false, so those still correctly get no Content-Type.

Response.json() previously routed through get_or_create_headers() and then put_default(ContentType, json). With the change above a fresh header list would first receive the string body's text/plain, making the put_default a no-op. construct_json now materializes init.headers itself and writes the JSON type before the body-derived type is considered.

Bun's existing value text/plain;charset=utf-8 (lowercase) is used, matching what Bun already emits on the response wire for string bodies and on the request wire for Bun.file() bodies.

Verification

New tests in test/js/web/fetch/fetch.test.ts under "Content-Type implied by the body init per fetch spec":

  • fetch() with a string body puts text/plain;charset=utf-8 on the wire (captured by a raw TCP server), both with no headers and with unrelated headers; a non-ASCII string body exercises the InternalBlob { was_string } path; an explicit content-type header wins; URLSearchParams still sends urlencoded; ArrayBuffer / Uint8Array bodies send no Content-Type.
  • fetch(new Request(...)) with a string body sends it on the wire.
  • new Request(...) / new Response(...) with a string, non-ASCII string, or URLSearchParams body expose the type on .headers; an explicit header wins; ArrayBuffer / typed-array bodies do not get a type.

Against unmodified main the four string-body tests fail and the URLSearchParams / ArrayBuffer control tests pass; with the fix all six pass. test/js/bun/util/inspect.test.js is updated for the new Response("Hello") headers now including content-type, matching Node. body.test.ts, bun-serve-static.test.ts, fetch-file-upload.test.ts, FormData.test.ts, and the Response.json tests all pass unchanged.

#32913 touches adjacent lines for a different issue (body-read-before-headers ordering for FormData/URLSearchParams/typed Blob) and explicitly left plain strings alone; this PR addresses the string case.

Fixes #17085
Fixes #8530


no test proof · iteration 14 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes how implicit Content-Type is derived from fetch body values, extends Request and Response header handling to use the shared body accessor, and updates tests plus one vendor skip to match the new behavior.

Changes

Implicit Content-Type derivation

Layer / File(s) Summary
Content-type source contract in Blob and Body
src/runtime/webcore/Blob.rs, src/runtime/webcore/Body.rs
Any::has_content_type_from_user() now treats WTFStringImpl and string-backed InternalBlob values as user-provided content types, and Value::content_type() returns implicit Content-Type bytes for Blob, string, and string-backed InternalBlob bodies.
Request header wiring using content_type()
src/runtime/webcore/Request.rs
ensure_fetch_headers, get_content_type, and construct_into now derive Content-Type through body_value().content_type() and seed request headers from that optional value.
Response header wiring using content_type()
src/runtime/webcore/Response.rs
get_or_create_headers, get_content_type, construct_json, and the constructor now use the body accessor to read or write Content-Type, including direct JSON header initialization.
Test and vendor updates
test/js/bun/util/inspect.test.js, test/js/web/fetch/fetch.test.ts, test/vendor.json
The Response inspection snapshot now includes the implied header, fetch tests cover implied and omitted Content-Type cases, and one vendor test is temporarily skipped for the updated Response header behavior.

Possibly related PRs

  • oven-sh/bun#33404: Also propagates derived body Content-Type into response/header state.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding the implicit text/plain content type for string request bodies.
Description check ✅ Passed The description explains the problem, cause, fix, affected behavior, and verification with detailed test coverage.

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

@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Aug 1st, 2026

❌ @robobun, your commit c33c964 has 1 failures in Build #86778 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33677

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

bun-33677 --bun

@github-actions github-actions Bot added the claude label Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Request doesn't set content-type for string body #17085 - Request doesn't set content-type for string body (new Request(url, {method: "POST", body: "Hello World"}).headers.get("content-type") returns null instead of text/plain;charset=UTF-8)
  2. Response with text body should have Content-Type of text/plain #8530 - Response with text body should have Content-Type of text/plain (new Response('yay').headers.get('content-type') returns null instead of text/plain;charset=utf-8)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17085
Fixes #8530

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/webcore/Request.rs (1)

1522-1539: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Confirm the deferred-header case is actually covered end-to-end.

This block only writes Content-Type when req.headers.get().is_some() — i.e., headers were already materialized earlier in construct_into (from an explicit headers init or a cloned Request/Response). When headers are still None here, the implicit content type is deferred to ensure_fetch_headers (lines 250-301), which duplicates this same "only if not already present" logic via a different code path (raw match on body variants vs. content_type()). This is not a bug per se, but it's the second near-duplicate implementation of "derive implicit Content-Type from body and insert if absent" in this file. Worth consolidating into one helper both call, to avoid the two diverging later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/Request.rs` around lines 1522 - 1539, The deferred-header
path and the materialized-header path both implement the same implicit
Content-Type insertion logic in Request::construct_into and
ensure_fetch_headers, so the handling is duplicated and can drift. Extract the
shared “derive Content-Type from body and insert if absent” behavior into one
helper and have both code paths call it, preserving the existing borrowck-safe
handling around req.headers_mut() and body_value().content_type().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Body.rs`:
- Around line 720-735: String body content-type values are hardcoded with the
wrong charset casing, so update both `Body::content_type()` and
`Internal::content_type()` to return the fetch-spec/browser wire value
`text/plain;charset=UTF-8` for string bodies. Make the change in the string-body
paths (`Value::WTFStringImpl` and the corresponding `Internal` logic) together
so exact header comparisons stay consistent.

---

Outside diff comments:
In `@src/runtime/webcore/Request.rs`:
- Around line 1522-1539: The deferred-header path and the materialized-header
path both implement the same implicit Content-Type insertion logic in
Request::construct_into and ensure_fetch_headers, so the handling is duplicated
and can drift. Extract the shared “derive Content-Type from body and insert if
absent” behavior into one helper and have both code paths call it, preserving
the existing borrowck-safe handling around req.headers_mut() and
body_value().content_type().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e6b25484-3ba1-4121-9eea-99d5b7f6bc8c

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5d816 and 9ca7ac3.

📒 Files selected for processing (7)
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Body.rs
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/Response.rs
  • test/js/bun/util/inspect.test.js
  • test/js/web/fetch/fetch.test.ts
  • test/vendor.json

Comment thread src/runtime/webcore/Body.rs
Comment thread src/runtime/webcore/Body.rs
Comment thread src/runtime/webcore/Body.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/webcore/Body.rs (1)

725-735: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the string content type from MimeType::TEXT instead of a duplicated literal.

Line 731 hardcodes b"text/plain;charset=utf-8", while the sibling fix in clone_with_readable_stream (lines 1649-1651) sources the same value from bun_http_types::MimeType::TEXT.value.as_ref(). Keeping two literal copies of this spec string invites drift if the constant ever changes.

♻️ Proposed fix
-            Value::WTFStringImpl(_) => Some(b"text/plain;charset=utf-8"),
+            Value::WTFStringImpl(_) => Some(bun_http_types::MimeType::TEXT.value.as_ref()),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webcore/Body.rs` around lines 725 - 735, The Body::content_type
match arm for Value::WTFStringImpl currently hardcodes the text/plain charset
string, creating a duplicated MIME literal. Update that branch to source the
value from bun_http_types::MimeType::TEXT.value.as_ref() just like
clone_with_readable_stream does, so the string content type stays centralized
and consistent.
test/js/web/fetch/fetch.test.ts (1)

2899-2946: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Socket errors are swallowed instead of rejecting the awaited promise.

socket.on("error", () => {}) discards failures silently, so a connection failure leaves nextHead.promise pending until the suite's default timeout instead of failing fast with a clear error. Also note the connection closure reads the outer, reassignable nextHead/lastHead variables rather than capturing them per-connection — safe today only because calls are sequential and no error/close handling exists yet; adding rejection naively on the shared variable risks a stale connection rejecting a later call's promise.

🔧 Proposed fix
 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;
         lastHead = buf.toString("latin1").split("\r\n\r\n")[0];
         socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
-        nextHead.resolve();
+        resolver.resolve();
       });
-      socket.on("error", () => {});
+      socket.on("error", err => resolver.reject(err));
     });

As per coding guidelines, "Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise — never throw inside event callbacks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/web/fetch/fetch.test.ts` around lines 2899 - 2946, The fetch socket
test currently swallows connection failures because socket.on("error", () => {})
ignores errors and leaves doFetch waiting on nextHead.promise until timeout.
Update the server callback in fetch.test.ts so each connection captures its own
resolver state and wires socket error/close (and any other failure path used by
the test) to reject the current Promise.withResolvers instance instead of
relying on shared outer nextHead/lastHead variables. Keep the existing
assertions in doFetch and contentTypeHeader, but make the awaited promise fail
fast with the actual socket failure for the specific request being tested.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/runtime/webcore/Body.rs`:
- Around line 725-735: The Body::content_type match arm for Value::WTFStringImpl
currently hardcodes the text/plain charset string, creating a duplicated MIME
literal. Update that branch to source the value from
bun_http_types::MimeType::TEXT.value.as_ref() just like
clone_with_readable_stream does, so the string content type stays centralized
and consistent.

In `@test/js/web/fetch/fetch.test.ts`:
- Around line 2899-2946: The fetch socket test currently swallows connection
failures because socket.on("error", () => {}) ignores errors and leaves doFetch
waiting on nextHead.promise until timeout. Update the server callback in
fetch.test.ts so each connection captures its own resolver state and wires
socket error/close (and any other failure path used by the test) to reject the
current Promise.withResolvers instance instead of relying on shared outer
nextHead/lastHead variables. Keep the existing assertions in doFetch and
contentTypeHeader, but make the awaited promise fail fast with the actual socket
failure for the specific request being tested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9df9d7dd-718d-4892-bad3-7d14f2c88887

📥 Commits

Reviewing files that changed from the base of the PR and between 9ca7ac3 and 7b46c52.

📒 Files selected for processing (2)
  • src/runtime/webcore/Body.rs
  • test/js/web/fetch/fetch.test.ts

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the two outside-diff review notes:

  • fetch.test.ts socket error handling: applied in 19bbd10. The server callback now captures the resolver per-connection and wires socket.on('error', ...) to reject it, so a mid-request failure fails fast instead of timing out.
  • Body.rs MimeType::TEXT vs literal: left as-is. Value::content_type() returns Option<&[u8]>, and MimeType::TEXT is a const (not static), so MimeType::TEXT.value.as_ref() borrows a temporary Cow that cannot be returned. The inlined literal is the established pattern for this exact case elsewhere in the file set: Blob.rs:6867, Blob.rs:7008-7015, Blob.rs:7096-7102, and Request.rs:718-722 all carry the same comment explaining the const/static distinction. The clone_with_readable_stream site can use MimeType::TEXT because it feeds std::ptr::from_ref (a raw pointer into the static bytes behind the Cow::Borrowed), not a returned borrow.

Comment thread src/runtime/webcore/Response.rs
@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on every lane that exercises it.

Build 86778 (sha c33c964): 189 passed, one lane (:windows: 2019 x64) failed on test/regression/issue/36577.test.ts (bun install / InstallFailed extracting tarball, ENOENT), unrelated to this change. Flaky-retry bucket (all passed alone or on retry): bun-install-proxy.test.ts, node-module-module.test.js, request-clone-leak.test.ts (test #0 uses an all-ASCII "ahoyhoy" body; this PR's .clone() change only touches the non-ASCII InternalBlob{was_string} path, so the memory profile for that case is unchanged), 23865.test.ts, tty-reopen-after-stdin-eof.test.ts.

Earlier builds on this branch (70344, 70491) completed 281-285 passed with unrelated failures: darwin Puppeteer Chrome DevTools WebSocket launch, a JSC DFG JIT segfault at speculationFromValue, Windows bake render-sentinel timeout, Postgres connection refused, and the debian-asan test-worker-message-port-transfer-terminate race. Pre-rebase builds 69953/69991/70016 each hit the :darwin: 26 aarch64 buildkite-agent artifact download timed out infra issue.

The one failure this diff did cause (vendor/elysia/test/core/elysia.test.ts) was addressed via test/vendor.json and has not recurred.

All 11 new tests (fetch.test.ts -t "Content-Type implied by the body init") pass on every CI lane that ran them. Ready for maintainer review.

Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs
@robobun
robobun force-pushed the farm/08ef360e/fetch-string-body-content-type branch from 166a2f2 to 5a59f26 Compare July 8, 2026 04:44

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review threads are resolved and I found nothing new, but this is a user-visible Web-compat behavior change across the Request/Response/fetch hot paths (plus a deliberate O(M) clone trade-off for non-ASCII string bodies and a vendor-test skip), so a maintainer should sign off.

Extended reasoning...

Overview

This PR makes string request/response bodies emit Content-Type: text/plain;charset=utf-8 per the WHATWG fetch spec's "extract a body" step, matching Node/undici, Deno, and browsers. It touches four core webcore Rust files (Blob.rs, Body.rs, Request.rs, Response.rs) — adding body::Value::content_type(), updating Any::has_content_type_from_user(), rewiring six header-derivation sites in Request/Response, reworking construct_json to avoid the string body's implied type shadowing application/json, and changing clone_with_readable_stream to preserve was_string through .clone(). Tests, an inspect snapshot update, and a vendor-test skip round it out.

Security risks

None. No auth/crypto/permissions surface. The change adds a header value derived from a compile-time constant; no user-controlled bytes reach the header emission path beyond what already did.

Level of scrutiny

High. This is a behavioral change to production-critical Web API surface (fetch, new Request, new Response, Response.json) that every Bun.serve handler and HTTP client touches. It changes what goes on the wire for the most common body type. The change went through four rounds of my own review with substantive reworks (the .clone() fix, then its over-correction on .blob().type, then the current InternalBlob-preserving approach). The final shape carries a deliberate perf trade-off — non-ASCII string bodies now deep-copy on .clone() instead of sharing a refcounted store — which the author has documented and tied to #33128 as the path back to O(1). That trade-off, the two acknowledged deferred follow-ups (empty-string bodies, consume-before-headers order-dependence via #32913), and the elysia vendor-test skip are all reasonable calls, but they're the kind of calls a maintainer should ratify rather than a bot.

Other factors

Test coverage is thorough (wire-level TCP capture, Request/Response .headers, .clone() on both original and clone, .blob().type non-shadowing guards, ArrayBuffer negative controls). CI is reported green on the lanes that exercise the diff. No outstanding unresolved review threads. The lowercase charset=utf-8 casing choice is intentional and consistent with MimeType::TEXT repo-wide. Given the scope and the number of interacting code paths (lazy vs eager header materialization, clone normalization, .blob() precedence, Response.json), this warrants a human look before merge.

@robobun
robobun force-pushed the farm/08ef360e/fetch-string-body-content-type branch from 5a59f26 to 911b2a6 Compare July 8, 2026 12:42
robobun added 4 commits August 1, 2026 05:33
fetch(url, {method: 'POST', body: '<string>'}) was sending the request with
no Content-Type header on the wire, while node/undici, deno, and browsers
all send the spec-mandated text/plain;charset=UTF-8. The same omission made
new Request(...).headers.get('content-type') and new Response('...').headers
return null for string bodies.

The fetch spec's 'extract a body' step assigns text/plain;charset=UTF-8 to
scalar-value-string bodies just as it assigns the urlencoded/multipart types
to URLSearchParams/FormData bodies. Bun's header-building paths gated the
body-derived Content-Type on has_content_type_from_user(), which returned
false for the WTFStringImpl / InternalBlob{was_string} variants a string
body becomes, so nothing was emitted.

Add body::Value::content_type() returning the spec-assigned type for each
body variant and use it at every site that copies the body's type into the
header list (Request construct_into / ensure_fetch_headers / get_content_type,
Response constructor / get_or_create_headers / get_content_type). Update
AnyBlob::has_content_type_from_user() so the fetch wire serializer and
headers_ref helpers see string bodies as having a type. Response.json() now
materializes its headers before the body-derived type is considered so the
JSON type is not overwritten by text/plain.

ArrayBuffer / typed-array bodies (InternalBlob{was_string: false}) keep
returning no type, matching the spec.
elysia 1.4.28's 'automatically handle HEAD request for GET dynamic path'
asserts response.headers.toJSON() is exactly {content-length: '11'} for a
handler returning a plain string. new Response('string').headers now exposes
content-type: text/plain;charset=utf-8 per the fetch spec, matching Node and
browsers, so the assertion sees an extra key.
Value::clone_with_readable_stream converts a non-ASCII string body
(WTFStringImpl -> InternalBlob{was_string:true} -> Blob) without carrying
the was_string bit forward, so the resulting Blob has an empty content_type
and Value::content_type() returns None for both the clone and the mutated
original. Set the Blob's content_type to MimeType::TEXT when was_string,
matching create_with_bytes_and_allocator; dupe_with_content_type copies it
verbatim to the clone.
@robobun
robobun force-pushed the farm/08ef360e/fetch-string-body-content-type branch from 911b2a6 to 6da5581 Compare August 1, 2026 06:14
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Body.rs Outdated
Comment thread src/runtime/webcore/Response.rs Outdated
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (5f7e62d). The only conflict was in test/js/web/fetch/fetch.test.ts where both sides appended tests at the end of the file; kept both.

Verified after rebase:

  • new Response("yay").headers.get("content-type") → text/plain;charset=utf-8
  • The 11 new tests under "Content-Type implied by the body init per fetch spec" pass (6 fail on released bun)
  • body.test.ts, response.test.ts, request.test.ts, FormData.test.ts, fetch-file-upload.test.ts, serve Content-Range, bun-serve-static.test.ts, Response.json, inspect.test.js Blob inspect: all pass

Comment thread src/runtime/webcore/Body.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found on the current revision, but this changes user-visible fetch/Request/Response semantics across four core webcore files, carries a deliberate perf trade-off (deep-copy on .clone() for non-ASCII string bodies), and adds a vendor test skip — worth a maintainer sign-off.

What was reviewed:

  • Value::content_type() and every call site in Request/Response constructors, ensure_fetch_headers, get_content_type, get_or_create_headers — explicit user headers still win; ArrayBuffer/typed-array bodies still get no type.
  • AnyBlob::has_content_type_from_user() — InternalBlob{was_string:false} still returns false, so the fetch wire path doesn't tag binary bodies.
  • clone_with_readable_stream was_string preservation — .blob().type is no longer shadowed by the body-implied type after clone; ArrayBuffer bodies still normalize to a refcounted Blob.
  • Response.json header-materialization reorder — put_default still respects a user-supplied Content-Type from init.headers.
Extended reasoning...

Overview

The PR wires the WHATWG fetch "extract a body" implicit text/plain;charset=utf-8 type for string bodies through every place Bun copies a body's type into the header list: the fetch wire serializer (via AnyBlob::has_content_type_from_user), Request::construct_into/ensure_fetch_headers/get_content_type, and Response::constructor/get_or_create_headers/get_content_type. A new body::Value::content_type() centralizes the per-variant answer. Response.json now materializes init.headers itself so its put_default(ContentType, json) runs before the string-body default would fill the slot. clone_with_readable_stream keeps InternalBlob{was_string:true} as an InternalBlob (byte-cloned) instead of normalizing to Blob, so the type survives .clone() without shadowing an explicit header in .blob().type. Tests cover wire, .headers, .clone(), and .blob().type for ASCII/non-ASCII strings, URLSearchParams (control), and ArrayBuffer/typed-array (negative). One elysia vendor test that asserts an exact header set is skipped; inspect.test.js is updated for the new new Response("Hello") header.

Security risks

None identified. The change only adds a default Content-Type header when the body is a JS string and no explicit header is set; no new parsing of untrusted input, no auth/crypto/permission surface. The header value is a fixed literal.

Level of scrutiny

High. This is production-critical Web API surface (fetch, Request, Response, Bun.serve interop) with subtle cross-cutting interactions — lazy header derivation, .clone() mutating the original's body variant, and .blob() precedence between body-carried and header-carried types. The review history bears that out: four earlier rounds surfaced a real .clone() regression, a .blob().type over-correction, and a deliberate O(M) clone trade-off. All of those threads are resolved and covered by tests, but the shape of the fix (lazy derivation from the body variant rather than eager header materialization) plus two acknowledged follow-ups (empty-string body, consume-before-.headers order dependence) are design calls a maintainer should ratify.

Other factors

  • The non-ASCII-string .clone() path now deep-copies the body Vec<u8> per clone (previously a refcount bump). The trade-off is documented and intended to be revisited once #33128 lands, but it's a maintainer call whether that's acceptable to ship.
  • A vendor test (elysia core/elysia.test.ts) is added to skipTests; the justification is sound (Bun now matches Node/browsers) but skipping vendor coverage is a policy decision.
  • Recent commits since my last inline review are a rebase plus comment-cop trims — no logic changes; the bug-hunt pass on the current head found nothing new.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request doesn't set content-type for string body Response with text body should have Content-Type of text/plain

1 participant