fetch: derive blob() type and the formData() boundary from the Content-Type header per the spec - #33128
fetch: derive blob() type and the formData() boundary from the Content-Type header per the spec#33128robobun wants to merge 8 commits into
Conversation
|
Updated 3:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 290d9de has some failures in 🧪 To try this PR locally: bunx bun-pr 33128That installs a local version of the PR into your bun-33128 --bun |
cf09069 to
65055cb
Compare
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not duplicates, but both are related:
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds MIME parsing and extraction, carries normalized content type data through Body blob creation, extends ChangesBlob type normalization and multipart boundaries
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/Blob.rs`:
- Around line 2162-2167: The name-handling logic in Blob::get_name is conflating
an explicitly empty filename with the absence of a name, which breaks File
values created with an empty string. Update the Blob.rs name fallback so
BunString::empty() is preserved as a real name for File instances, and only
suppress the store-derived fallback for the plain-Blob path using a distinct
sentinel or a guard such as is_jsdom_file. Keep the behavior consistent with
Body.rs and any structured-clone/multipart code that relies on Blob::get_name
returning an explicit empty string rather than undefined.
In `@test/js/web/fetch/body.test.ts`:
- Around line 741-753: The comment above the `bodyTypes` loop in `body.test.ts`
is too long and mostly historical test provenance instead of a durable
invariant. Trim it to a short spec-facing note near the `describe(...blob())`
test, and remove the PR-history details about Bun behavior and the Node v26
vector generation. Keep only the non-obvious invariant that the test is
verifying, leaving the rationale/history out of the source comment.
🪄 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: 7b5d0f59-3d85-475a-87ea-27e9e9e8c34f
📒 Files selected for processing (19)
packages/bun-types/deprecated.d.tssrc/http_types/lib.rssrc/http_types/mime_sniff.rssrc/js/builtins/ReadableStream.tssrc/jsc/JSGlobalObject.rssrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/webcore/JSReadableStream.cppsrc/jsc/bindings/webcore/ReadableStream.cppsrc/runtime/api/html_rewriter.rssrc/runtime/server/RequestContext.rssrc/runtime/webcore/Blob.rssrc/runtime/webcore/Body.rssrc/runtime/webcore/Request.rssrc/runtime/webcore/Response.rssrc/runtime/webcore/fetch/FetchTasklet.rstest/js/bun/http/form-data-set-append.test.jstest/js/web/fetch/body.test.tstest/js/web/fetch/client-fetch.test.tstest/js/web/html/FormData-multipart-serialization.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/runtime/webcore/Blob.rs:876-883— nit: this comment block is 7 lines, exceeding the repo's 3-line limit (CLAUDE.md rule 13). You already trimmed two similar over-length blocks inbody.test.tsper CodeRabbit feedback in 526d583; this one looks like it was just missed. The lowercase invariant is the durable bit worth keeping — the undici/Gecko comparison and #29630 reference are already in the PR description.Extended reasoning...
What the issue is. The repo's root
CLAUDE.mdrule 13 (line 268) states: "Keep code comments to 3 lines max — Comments must be concise. If the code needs more explanation than that, it belongs in docs." The new comment block aboveBOUNDARY_PREFIXinBlob::from_dom_form_data(Blob.rs:876-882) is 7 lines. The same guidelines elsewhere (and the CodeRabbit feedback already applied in this PR) note that bug history / provenance belongs in the PR description, not source.Why this is worth mentioning here. In commit 526d583 of this same PR, the author responded to CodeRabbit's identical "trim this comment block" feedback on
test/js/web/fetch/body.test.tswith "Trimmed both blocks to three lines in 526d583; the rationale and the vector provenance now live only in the PR description." So the author is actively applying this guideline within the PR — this block inBlob.rswas added in the earlier commit (65055cb) and CodeRabbit's rate-limited review didn't flag it, so it was simply missed rather than intentionally kept long.Step-by-step. Counting the lines at
src/runtime/webcore/Blob.rs:876-882:// Prefix + 32 lowercase-hex chars of a fresh UUID. The boundary must// not contain uppercase ASCII: the File API requires \Blob.type` to`// read back ASCII-lowercased, so an uppercase boundary could never// survive a \request.blob()` -> `fetch(url, { body })` round-trip.`// undici and Gecko generate lowercase-safe boundaries for the same// reason. The leading-dash count and hex suffix are what downstream// multipart parsers actually key on (#29630) and are unchanged.
That's 7 lines vs. the 3-line cap. Lines 1-4 carry the durable non-obvious invariant (why lowercase-only). Lines 5-7 are cross-impl provenance ("undici and Gecko do the same") and bug history ("#29630 … unchanged"), both of which the PR description already covers verbatim under the Multipart boundary section.
Why existing code doesn't already prevent it. Enforcement of this rule is review-based, not lint-based. The same file already has an 8-line
//block immediately below (lines 903-910), so the file itself isn't a clean baseline — but that block is pre-existing and out of scope. What makes this one worth a nit is purely consistency with the author's own trims elsewhere in this PR.Impact. None functional — pure style. This is the lowest-severity finding possible: a comment-length guideline. It's flagged only because (a) the rule is explicit in CLAUDE.md, and (b) the author already applied the same trim to two sibling blocks in this PR, so leaving this one long is an inconsistency they'd likely want to fix before merge rather than an intentional exception.
How to fix. Condense to the invariant and drop the provenance/history, e.g.:
// Prefix + 32 lowercase-hex chars of a fresh UUID. Lowercase-only so the // boundary survives a `request.blob()` -> `fetch({ body })` round-trip // (the File API ASCII-lowercases `Blob.type`). Dash count/hex unchanged. const BOUNDARY_PREFIX: &[u8; 17] = b"----formdata-bun-";
The undici/Gecko comparison and the #29630 reference already live in the PR description ("Node, Gecko, and Deno all generate lowercase-safe boundaries… The leading-dash count and 32-hex suffix, which are what #29630 actually needed, are unchanged") and in
test/js/bun/http/form-data-set-append.test.js's file-level comment, so nothing is lost.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/http_types/mime_sniff.rs`:
- Around line 312-323: The carried charset logic in mime_sniff::parse_mime_type
only updates on a new essence and misses the case where the same essence
supplies a different charset. Adjust the same-essence branch so it also
refreshes the carried charset when parsed.parameter(b"charset") is present,
while still inheriting it only when the charset parameter is absent. Use the
existing essence/charset handling in mime_sniff.rs to keep the final combined
value aligned with the most recent charset for that essence.
In `@src/js/builtins/ReadableStream.ts`:
- Around line 336-350: The readableStreamToBlob API currently accepts non-string
contentType values and only fails later inside setBlobTypeVerbatim, after the
stream may already be consumed. Add upfront validation in readableStreamToBlob
for contentType when it is provided, using the existing typed-argument pattern
($ERR_INVALID_ARG_TYPE or the appropriate validation helper) so non-string
values throw immediately before any stream work starts. Keep the current
behavior unchanged for undefined, and preserve the existing Blob type-setting
path for valid string inputs.
🪄 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: 942d3cce-abb2-4cee-ada2-e90f57341510
📒 Files selected for processing (21)
packages/bun-types/deprecated.d.tssrc/codegen/generate-js2native.tssrc/http_types/lib.rssrc/http_types/mime_sniff.rssrc/js/builtins/ReadableStream.tssrc/jsc/JSGlobalObject.rssrc/jsc/bindings/ZigGlobalObject.hsrc/jsc/bindings/webcore/JSReadableStream.cppsrc/jsc/bindings/webcore/ReadableStream.cppsrc/runtime/api/html_rewriter.rssrc/runtime/server/RequestContext.rssrc/runtime/webcore/Blob.rssrc/runtime/webcore/Body.rssrc/runtime/webcore/Request.rssrc/runtime/webcore/Response.rssrc/runtime/webcore/fetch/FetchTasklet.rstest/js/bun/http/form-data-set-append.test.jstest/js/web/fetch/blob.test.tstest/js/web/fetch/body.test.tstest/js/web/fetch/client-fetch.test.tstest/js/web/html/FormData-multipart-serialization.test.ts
|
Good catch on the comment length, trimmed to three lines in d2b864a (and tightened the new |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/Blob.rs`:
- Around line 89-92: The doc comment for setBlobType in Blob.rs is too long and
exceeds the repository’s 3-line comment limit. Compress the existing explanation
into three lines or fewer while keeping the key points: it applies only to
readableStreamToBlob, it validates and ASCII-lowercases the type like the Blob
constructor, and it must not canonicalize through the interned MIME table. Refer
to the setBlobType comment block directly when editing.
🪄 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: 95213837-c9d3-4aa8-b64e-36cec12783da
📒 Files selected for processing (4)
src/js/builtins/ReadableStream.tssrc/runtime/webcore/Blob.rssrc/runtime/webcore/fetch.rstest/js/web/fetch/body.test.ts
There was a problem hiding this comment.
All my prior findings have been addressed and the latest revision looks clean, but this is a substantial change — a new ~330-line WHATWG MIME parser, a rewrite of the blob() resolution paths across Body/Request/Response, a user-visible multipart boundary prefix change, and a new public contentType parameter on Bun.readableStreamToBlob — so it warrants a human sign-off.
Extended reasoning...
Overview
This PR makes Request/Response.prototype.blob() spec-conformant by deriving the result's type from the header list via WHATWG MIME extraction. It touches 22 files: a new src/http_types/mime_sniff.rs (~330 lines implementing "parse/serialize a MIME type" and Fetch's "extract a MIME type"), a substantial rewrite of Body.rs (into_consumed_blob, normalize_blob_type, Action::GetBlob now carries the normalized type, PendingValue::source_content_type, Value::resolve signature change), Blob.rs (set_blob_type native helper, get_name_string empty-name suppression, multipart boundary prefix changed from ----WebKitFormBoundary to ----formdata-bun-), C++/Rust FFI signature updates for readableStreamToBlob, a new optional contentType parameter on the public Bun.readableStreamToBlob, and ~340 lines of new tests in body.test.ts.
Security risks
Low. The new setBlobType path was hardened during review to validate/lowercase like the Blob constructor (rejecting CR/LF and bytes outside U+0020–U+007E), so user-supplied contentType cannot inject into outgoing Content-Type headers. The MIME parser operates on byte slices with Rust bounds-checked indexing and no unsafe. The boundary change keeps the same entropy (32 hex chars from a fresh UUID). The three Value::resolve call sites that previously discarded errors with let _ = now report them via report_uncaught_exception_from_error, which is strictly an improvement.
Level of scrutiny
High. This is core web-platform behavior on a hot path (fetch, Bun.serve, Body mixin), with multiple resolution paths (direct, JS ReadableStream, native buffering, .body-then-recovery, clone()) that must agree. The PR went through seven rounds of bot review that surfaced real bugs (stale interned-pointer leak, Blob-constructor canonicalization on the stream path, File("") name regression, data:, URL CI failure, .body-before-blob() regression, clone()-after-.body string-body gap, invalid-contentType fast-path leak), each fixed with a targeted commit and test. That iteration history itself argues for a human pass over the final shape.
Other factors
The PR also makes design-level choices a maintainer should ratify: changing the generated multipart boundary prefix (user-visible in wire bytes and downstream parsers), adding a public API parameter to a deprecated helper, and explicitly superseding #32806 while overlapping textually with #32913/#32915/#32430. Test coverage is extensive (102-vector header table verified against Node v26, plus the regression rows added for each review finding), and the latest bug-hunting pass found nothing — but the breadth of the change and the number of subtle interactions uncovered during review put this outside what I'd approve without a human look.
|
CI status on e14cd7e (build 67208): 283 jobs passed, including all 20 ASAN test shards. The LeakSanitizer failure from the previous build is fixed: The two remaining red lanes are pre-existing, tracked CI issues with no overlap with this diff:
Re-running just those BuildKite jobs is enough to get a green check; a fresh build would re-roll all 286 jobs against the same two known-bad lanes. |
Response.prototype.blob() and Request.prototype.blob() now resolve to a plain
Blob whose type is the result of Fetch's "extract a MIME type" over the header
list, serialized per the MIME Sniffing standard and normalized like any Blob
type. The File identity of a body source is dropped, an explicit Content-Type
header wins over the body blob's type, and the type agrees across the direct,
stream-resolved, and Bun.serve resolution paths.
src/http_types/mime_sniff.rs implements the WHATWG parse/serialize algorithms
and Fetch's extraction step (get-decode-split over comma-combined values with
charset carry-over). Action::GetBlob now carries the normalized type, computed
once where the headers are in hand; PendingValue::source_content_type captures
the body-derived type before .body turns the body into a stream, and clone()'s
tee propagates it so clone() after .body keeps the type.
readableStreamToBlob (now in BunStreamConsumers.cpp after the C++ ReadableStream
rewrite) gains an optional contentType that is stamped onto the result via a
Blob__setType FFI, skipping the Blob constructor's interned-MIME
canonicalization. Value::reset releases the pending body's owned payloads so a
Response finalized while Locked does not leak them.
The generated multipart boundary prefix becomes ----formdata-bun-: Blob.type
reads back ASCII-lowercased, so the prior mixed-case prefix could not survive
request.blob() followed by fetch({body}). An empty data: URL mediatype defaults
to text/plain. client-fetch.test.ts restores undici's original expectation.
Fixes #32801
Fixes #19603
e14cd7e to
6749f1d
Compare
BunString::clone_utf8 returns a +1-ref WTFStringImpl and to_js bumps it for the JS side; BunString is Copy with no Drop, so the temporary's +1 was never released. Use BunString::init (a borrow, like the GetFormData arm) and fix the Blob__setType SAFETY comment to describe the actual caller shape.
There was a problem hiding this comment.
All prior review feedback has been addressed and no new issues were found on this revision, but this is a substantial change (a new 328-line MIME parser, Body/Blob ownership plumbing across Rust/C++ FFI, and a user-visible multipart boundary prefix change) that warrants a human sign-off.
Extended reasoning...
Overview
This PR reworks how Response.prototype.blob() / Request.prototype.blob() derive the result Blob's type to match the Fetch spec and Node. It adds a full WHATWG MIME parser (src/http_types/mime_sniff.rs, 328 lines), threads a normalized content type through Action::GetBlob and PendingValue::source_content_type in Body.rs, adds a contentType parameter to readableStreamToBlob across the Rust/C++/TS/type-declaration surfaces, adds a Blob__setType FFI export, changes the multipart boundary prefix from ----WebKitFormBoundary to ----formdata-bun-, and updates ~350 lines of tests. It also removes the headers parameter from Value::resolve and converts three let _ = swallowed-error call sites to report_uncaught_exception_from_error.
Security risks
Low but non-zero. The new MIME parser processes untrusted header bytes; it operates on &[u8] with Rust-bounds-checked slicing and no unsafe, so there is no memory-safety exposure there. The Blob__setType FFI entry point takes a raw pointer/len pair; the SAFETY invariant (single caller passes a stack-owned WTF::CString) is documented and holds. The contentType argument to the public Bun.readableStreamToBlob is validated (non-string rejected, bytes outside U+0020..U+007E dropped) so CR/LF cannot reach an outgoing Content-Type header — this was specifically hardened during review.
Level of scrutiny
High. This touches core webcore body-resolution and Blob ownership paths (Body.rs, Blob.rs, Response.rs, Request.rs, fetch.rs, RequestContext.rs), adds owned payloads to the Action enum and PendingValue struct with corresponding cleanup in Value::reset(), and changes FFI signatures across four languages. The PR went through 8+ review iterations in which real bugs were found each round — a stale-interned-pointer leak, a File("") name regression, a per-call WTFStringImpl leak, a .body→clone()→blob() type-erasure regression, and a data-URL default-mediatype regression that broke CI on all platforms. All of those are now resolved with tests, but the density of issues found is itself a signal that the change surface is subtle.
Other factors
The boundary-prefix change (----WebKitFormBoundary → ----formdata-bun-) is a user-visible behavior change with a well-argued rationale (lowercase-safety for the blob().type → fetch({body}) round-trip), but it's the kind of compatibility-vs-correctness tradeoff a maintainer should explicitly ratify. The PR also supersedes #32806 and interacts with three other open PRs (#32913, #32915, #32430). Test coverage is very thorough (517 tests in body.test.ts, 147 red on release Bun and green on the branch), and the PR description is exceptionally detailed. No outstanding unresolved review threads remain.
|
CI on aaa72a5 (build 70760, the rebase onto main at 4924862): 282 jobs passed including all 20 ASAN test shards, so the rebased ownership paths ( The two red lanes are pre-existing CI flakes with no overlap with this diff:
Neither touches fetch, Body, Blob, or streams. Re-running just those two jobs should be enough to get a green check. |
Blob.type falls back to the store's mime_type when the blob's own type is empty, and from_url_search_params was the one body constructor that wrote it, so a blob() result whose header extracted to nothing (and a slice() of the result) still read back application/x-www-form-urlencoded. The blob's own content_type already carries the value; drop the store write.
There was a problem hiding this comment.
290d9de addresses the last note (the vestigial store.mime_type write in from_url_search_params is gone, and the URLSearchParams + */*/invalid header cases are now pinned in the test). This pass found nothing further; the Bun.file() variant of the same store-fallthrough was also checked and ruled out. Given the scope — a new spec MIME parser, FFI signature changes across the Rust/C++ stream boundary, a new Bun.readableStreamToBlob parameter, and the user-visible multipart boundary prefix change — a human sign-off is still warranted.
Extended reasoning...
Overview
24 files across src/http_types/, src/runtime/webcore/{Body,Blob,Request,Response,FormData,fetch}.rs, the C++ ReadableStream consumers, Bun.serve's RequestContext, html_rewriter, and five test files. The core change routes Request/Response .blob().type and the .formData() boundary through a new WHATWG-conformant extract_mime_type (mime_sniff.rs, ~300 lines), threads the normalized type through Action::GetBlob(Option<BlobContentType>) and a new PendingValue::source_content_type field so every resolution path (direct, native buffered, JS-stream via readableStreamToBlob) agrees, and drops the shared-store mutation that used to retype caller-owned Blobs. It also changes the generated multipart boundary prefix to a lowercase-safe ----formdata-bun- and adds an optional contentType argument to Bun.readableStreamToBlob.
Since the last review
My 2026-08-13 note flagged that into_consumed_blob(_, None) left store.mime_type visible for a URLSearchParams body. 290d9de removes the store.mime_type write from from_url_search_params (now sets only blob.content_type to a Static value) and adds urlSearchParams / urlSearchParamsInvalid / formData / urlSearchParamsSlice rows to the "header wins even when it extracts to nothing" test. 94c861d trimmed the multi-line comments the comment-cop action flagged. Nothing else has changed.
Security risks
The MIME parser runs over untrusted Content-Type header bytes; I traced the token/quoted-string/whitespace predicates and the get_decode_split loop for bounds and termination and found no unbounded growth or index past end. Blob__setType validates its input with is_valid_blob_type (rejects anything outside U+0020–U+007E), so a caller-supplied contentType cannot inject CR/LF into an outgoing Content-Type header — the "a/b\rx: y" test pins this. No auth/crypto/permission surfaces are touched.
Level of scrutiny
High. This reworks spec-mandated behavior on the fetch/Body hot path, changes an FFI signature across four C++ files and one Rust extern block, adds owning fields to PendingValue/Action (with matching releases in Value::reset), removes Value::resolve's headers parameter at three call sites, and makes two user-visible API changes (the boundary prefix, and the new readableStreamToBlob parameter). It has been through several review rounds already (each of which surfaced a real issue that was fixed), CI has been green modulo tracked flakes, and the 641-case test file covers the header/body/stream/clone matrix against Node v26 — but the breadth of behavioral change and the API-surface additions put it outside what an automated review should approve alone.
Other factors
The finder-raised "Bun.file() body's store.mime_type still leaks through when the header extracts to nothing" was examined and refuted this run. All prior inline threads (the clone_utf8 leak, fast-path type shadowing, .body/clone() type loss, the SAFETY comment mismatch, the unused import) are resolved in the current diff.
|
Self-review notes after folding #35901 / #32806 into this branch. Three things survived a closer look; none of them is introduced by the fold itself (the first one is pre-existing accessor behavior that the fold's "last valid value wins" claim runs into), but they affect what the description promises, so listing them here for whoever picks the branch up next.
Verified item 1 against the uws source and the accessor; items 2 and 3 against the current branch head (290d9de). Happy for any of these to be resolved as "documented, not changed" if that is the call, but the description currently reads as if all three paths agree. |
|
#42016 now carries the precedence and plumbing part of this change on current main: the What remains unique to this PR is the WHATWG "extract a MIME type" parser and serializer, the |
|
#41922 carries a narrower version of the |
|
Closing in favor of #42016, which carries the precedence rule and the This branch has conflicted with main since the August refactors. What remains unique to it is the WHATWG "extract a MIME type" parser and serializer (#19603), the |
Response.prototype.blob()andRequest.prototype.blob()must resolve to a plainBlobwhosetypeis the result of extracting a MIME type from the header list, serialized per the MIME Sniffing standard and normalized like anyBlobtype(ASCII-lowercased, or""if any character falls outside U+0020-U+007E).Bun returned the body's own Blob instead: a
Filebody came back as aFile, name and all, its rawtypeshadowed theContent-Typeheader, and when the header was consulted it went throughbun_http_types::MimeType::init, a category lookup table rather than a MIME parser.In a 15-case probe of the body/header matrix, 14 cases diverged from Node (table in the details block below). Beyond the examples above:
Bun.serverequest bodies resolve on a native path that received no headers, sorequest.blob().typewas always"text/plain;charset=utf-8"no matter what the client sent.When the body was a typed
Bloband a differentContent-Typeheader was also given, the blob's type won; the spec says the header does.Content-Type: application/jsonfrom a server was rewritten toapplication/json;charset=utf-8by the lookup table.A
ReadableStreambody never consulted the header at all, so itsblob()had an empty type.formData()read its boundary from the raw combined header value, so a response (orRequest/Responseinit) carrying twoContent-Typelines (boundary=B1, thenboundary=B2) yielded a boundary matching neither framing andformData()rejected withERR_FORMDATA_PARSE_ERROR; Node parses the B2-framed body.The header-derived type was written into the body blob's shared
Store, soblob()could retype an object the caller still holds:Node vs. Bun 1.4.0 across the body/header matrix
File(type:"My/Type; A=B")body"my/type;a=b", plain Blob"my/type; a=b", aFilenamedn.binBlob(type:"My/Type; A=B")body"my/type;a=b""my/type; a=b"TEXT/Plain ; Charset=UTF-8"text/plain;charset=utf-8""TEXT/Plain ; Charset=UTF-8""text/plain;charset=utf-8""text/plain;charset=utf-8"Uint8Arraybody, no header"""text/plain;charset=utf-8"Blob(type:"a/a")body + headerb/b"b/b""a/a"*/*"""application/octet-stream"invalid"""invalid"ReadableStreambody + header"text/plain;charset=utf-8"""Requestwith aFilebody"my/type;a=b", plain Blob"my/type; a=b", aFileRequeststring body + header"text/plain;charset=utf-8""TEXT/Plain ; Charset=UTF-8"fetch()of aTEXT/Plain ; Charset=UTF-8response"text/plain;charset=utf-8""TEXT/Plain ; Charset=UTF-8"Bun.serverequest.blob()(any Content-Type)"text/plain;charset=utf-8"Fix
src/http_types/mime_sniff.rsimplements the WHATWG "parse a MIME type" and "serialize a MIME type" algorithms plus Fetch's "extract a MIME type", including the get-decode-split step over comma-combined values and the charset carry-over. It was validated byte for byte against Node v26 across 102 header vectors; the only intentional divergences are two inputs containing a backtick in a token, where undici's HTTP-token regex is missing U+0060 (`) and the MIME Sniffing standard includes it.Action::GetBlobnow carries the normalized type, computed once inget_blob_with_this_valuewhere the Request/Response and its headers are in hand, so every resolution path agrees:readableStreamToBlob, for a JSReadableStreambody; the builtin gains an optionalcontentTypeparameter, mirroringreadableStreamToFormData. It stamps the type through an internalsetBlobTypenative helper rather thannew Blob(chunks, { type }), because theBlobconstructor canonicalizes well-known essences through the interned MIME table (application/jsonbecomesapplication/json;charset=utf-8, Normalizing blob type with charset causing bug on type validation #15078) and would have put this path out of step with the others. The helper keeps the constructor's validation and ASCII-lowercasing (so CR/LF can never reach aContent-Typeheader through it), and a non-stringcontentTypethrowsERR_INVALID_ARG_TYPE;Value::resolve'sheadersargument. That argument is now unused and removed. TheBun.servecaller passedNonefor it, which is what brokerequest.blob()there.The result blob drops the source body's
Fileidentity (is_jsdom_file,name,lastModified), and nothing writes into the shared store anymore.Blob.typefalls back to the store'smime_typewhen the blob's own type is empty, andBlob::from_url_search_paramswas the one body constructor that set it, so ablob()result whose header extracted to nothing (or aslice()of the result) still read backapplication/x-www-form-urlencoded; that constructor now puts the type on the blob only, like every other one. Bun materializes the body-derivedContent-Typeheader lazily, so for the purposes of extraction the "header list" is the explicit header, else what "extract a body" derives from the body: the blob's owntype, ortext/plain;charset=UTF-8for a string body. A stream the caller provided (or a network body) derives nothing.A string body's identity only lives in the body value, and three value transformations used to erase it there, so
blob()reported""after any of them:.bodyconverts the body into aReadableStreamof plain bytes;to_readable_streamnow captures the derived type on the pending value (PendingValue::source_content_type) andblob()reads it back,to_blob_if_possible, reached byclone()) returned the bytes withwas_stringcleared; it now restores the flag from the captured type,clone()converts anInternalBlobbody into an untyped shared-storeBlob(a never-streamed non-ASCII string body takes this path too); that blob now keeps thetext/plain;charset=utf-8itswas_stringimplied.The pending value owning these captures is torn down by
Value::reset()onResponse::destroy, which never runs drop glue (it resets, then deallocates the allocation raw), soreset()now releases the captured type and a pending action's payload. The CI ASAN lane's LeakSanitizer caught that for responses finalized while still holding their.bodystream, which also covers the pre-existingAction::GetFormDatacase.The three
Value::resolvecall sites whose signature this changes (two inRequestContext.rs, one inhtml_rewriter.rs) each discarded the result with alet _ =, which leaves a thrown exception pending on the VM. They now report it throughreport_uncaught_exception_from_error, the idiomRuntimeTranspilerStorealready uses for an exception escaping a void native callback.Multipart boundary
Making
blob().typeconformant forces one further change. The File API requiresBlob.typeto read back ASCII-lowercased, so a multipart boundary containing uppercase letters can never surviverequest.blob()followed byfetch(url, { body }): the re-postedContent-Typenames a lowercased boundary that the body bytes do not contain. Node, Gecko, and Deno all generate lowercase-safe boundaries, which is the only reason the round-trip works there; an existing test for #21011 exercises exactly that round-trip and would otherwise regress.Bun's generated boundary prefix therefore changes from
----WebKitFormBoundaryto----formdata-bun-. The leading-dash count and 32-hex suffix, which are what #29630 actually needed, are unchanged.formData()
Request/Responseget_form_data_encodingnow go throughform_data::encoding_from_header, which runs the same "extract a MIME type" over the combined header value beforeEncoding::get, so a stackedContent-Typeyields the last valid value's boundary. Boundaries containing non-token characters (=,:, space) come back from the serializer as a quoted parameter, whichget_boundaryalready unquotes; tests pin that.Blob.prototype.formData()is unchanged:Blob.typeis a single normalized value, not a header list.Tests
test/js/web/fetch/body.test.tsgains, for bothRequestandResponse: a table ofContent-Typeheader values and their expectedblob().type, each pair generated by running the same header through Node v26; a second table of stacked (repeated)Content-Typeheaders; the duplicated-boundaryformData()case (B2-framed body parses, B1-framed rejects) and the quoted/non-token boundary cases; the plain-Blob andFile-identity checks; the header-over-body-type precedence; the shared-store non-mutation; the string-body andBufferSourcedefaults; theReadableStreambody; aBun.filebody; the.body-before-blob()cases (typed, untyped, and emptyBlob, string,URLSearchParams,FormData, an explicit header,.headersfirst,clone()after.bodyfor Blob and string bodies,clone()of ASCII and non-ASCII string bodies, and a body built from another body's stream), all verified against Node v26. Over the network: afetch()round trip throughBun.serve, a raw-socket server sending repeatedContent-Typelines (checked throughheaders.get,blob(), andformData()),Bun.serverequest.blob()with a normalized header, and the #32801 repro (a postedFilewith four different types round-trips each one).Bun.readableStreamToBlobis covered with and without the new parameter.The file is 641 tests; all pass with this branch. On the released bun the stacked-header,
formData(), network, and the originalblob()cases fail.Two existing assertions encoded the old behavior and are updated:
test/js/web/fetch/client-fetch.test.tsis restored to undici's original expectation for its upstream test ("application/json"; Bun's port had changed it to"application/json;charset=utf-8").-WebkitFormBoundary{hex}, 1 leading dash) differing from undici and WebKit #29630 boundary-format tests follow the new prefix; the dash-count and consistency properties they exist for are asserted unchanged.Not changed
Content-Type: ""(present but explicitly empty) still falls through to the body-implied type, becauseFetchHeaders::fastGetcannot distinguish an absent header from a present-but-empty one. Pre-existing, not a regression, and it also affectsformData()andWebAssembly.compileStreaming.Response.json()setsapplication/json;charset=utf-8where Node setsapplication/json;blob()now faithfully reflects whichever header is there.new Blob(["x"], { type: "text/plain" }).typereturning"text/plain;charset=utf-8"(Normalizing blob type with charset causing bug on type validation #15078) comes from the Blob constructor's interned MIME table, a separate code path thatblob()no longer goes through.Branch state
origin/main(f426a8e) is merged in. Conflicts were inhtml_rewriter.rs(theRewriterPiperefactor),Body.rs(pub(crate)narrowing) and thebody.test.tsimports. Lints that landed in the meantime required three adjustments, all in the merge commit:mime_sniff.rssearches bytes throughbun_core::strings,Blob__setTypeis apub(crate) unsafeexport built onffi::slice, and the inherentResponse::get_fetch_headers(whose only caller was the removedresolveargument) is deleted, which also letsFetchTasklet::on_body_receivedhold a plain&mutto the body instead of a raw pointer.cargo clippy -p bun_http_types -p bun_runtimeis clean;body.test.ts,blob.test.ts,client-fetch.test.ts, the FormData suites, and the HTMLRewriter suites pass locally.Earlier rebase notes (onto 4924862)
Rebased onto main at 4924862 (was 132 commits behind). Three refactors landed under this branch that forced non-trivial conflict resolution:
src/js/builtins/ReadableStream.ts,src/jsc/bindings/webcore/ReadableStream.cpp, andsrc/jsc/bindings/webcore/JSReadableStream.cpp. ThecontentTypeparameter this PR adds toreadableStreamToBlobnow lives inBunStreamConsumers.cpp/WebStreamsExports.cpp/WebStreamsInternals.h/ the newJSReadableStream.cpp, threaded throughperformPromiseThenWithContext(the patternreadableStreamToFormDataalready uses). The formersetBlobType$newRustFunctionis replaced by a smallBlob__setTypeFFI export that the fulfillment handler calls, sosrc/codegen/generate-js2native.tsis no longer touched.BlobContentTypeenum forBlob.content_typeownership) removedcontent_type_allocatedandfree_content_type().into_consumed_blob,normalize_blob_type, and thesource_content_typecapture now useBlobContentTypedirectly; thedata_url_responsehunk needed the same adjustment.clone()now tees a Locked body before recovery) made the tee propagation ofsource_content_typereachable again; it is back in.The eleven commits are squashed into one on top of main; every change from the prior tip (e14cd7e) is carried forward.
test/js/web/fetch/body.test.tsis 517 tests, 147 red on the released bun and all green with the build; adjacent body, clone, stream, FormData, andreadableStreamToBlobsuites also pass.Related PRs
Value::resolve, fixing only theBun.servesymptom) and fetch: run "extract a MIME type" over Content-Type for blob() and formData() #35901 (theextract_mime_typesubset plus theformData()routing) are superseded by this PR: their distinct pieces, theformData()routing and both sets of tests, are folded in here, and both are closed in favor of this one.get_blob_with_this_value,get_name_string). They are independent fixes and compose with this one; whichever lands second needs a small rebase.Fixes #32801
Fixes #19603
Fixes #35284
no test proof · iteration 11 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/blob.test.ts test/js/web/fetch/body.test.ts test/js/web/html/FormData-multipart-serialization.test.ts