Repository navigation
Conversation
…and formData() `Response` and `Request` built the result of blob() and formData() for a JS stream body with `new Blob(chunks)`. That converts any value to a string, so a number or an object chunk ended up in the result as "5" or "[object Object]", and a Blob chunk was copied in. text(), json(), bytes() and arrayBuffer() refuse the same chunk with a TypeError. The two Body entry points now pass `BlobChunkPolicy::BytesOrString`. The Blob part walker takes the policy: it throws the TypeError of bytes() for a part that is not a string, an ArrayBuffer or an ArrayBufferView. On a direct stream and an async-iterable body the Array sink's write() throws for such a value, with the error of the byte sink. `Bun.readableStreamToBlob`, `ReadableStream.prototype.blob`, `Bun.readableStreamToFormData` and blob() of node:stream/consumers keep `new Blob(chunks)`.
|
Updated 5:14 PM PT - Oct 11th, 2026
✅ @robobun, your commit f3c6d6f9b459078d6472d1b99f364e0f3cefe0dd passed in 🧪 To try this PR locally: bunx bun-pr 45005That installs a local version of the PR into your bun-45005 --bun |
|
Status Reproduced on bun 1.4.3 and on main ( const body = value => new ReadableStream({ start(c) { c.enqueue(value); c.close(); } });
await (await new Response(body(5)).blob()).text(); // "5"
await (await new Response(body({ a: 1 })).blob()).text(); // "[object Object]"
await new Response(body(5)).text(); // TypeErrorOn this branch, PR: #45005 |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes user-visible fetch body semantics (a Blob/File chunk in a Response/Request stream body now rejects where it previously produced the right bytes) and adds a new Rust/C++ FFI entry into the Blob part walker, a human look at the behavior decision would still be worthwhile.
What was reviewed:
Blob__fromStreamChunks/PartPolicy::BytesOrStringin Blob.rs: the strict walk rejects before converting any part that could run user JS, so skipping the prescan keeps thedetach_lifetimeborrow sound; the heap Blob's +1 ref is handed to the JS wrapper once viaBlobExt::to_js.onReadableStreamToBodyBlobFulfilledand the strict Array sinkwrite(): exception checks follow every throwing call, and the lenientBun.readableStreamToBlob/stream.blob()/node:stream/consumerspaths are unchanged (covered by the new streams.test.js cases).- Candidate concerns ruled out:
write()now returning a number instead ofundefinedon a body's Array sink is intentional and matches the Text sink; chunks retained after a rejected direct-streamblob()are dropped once the promise/stream are unreferenced.
Extended reasoning...
The change threads a BlobChunkPolicy through readableStreamToBlob/FormData and the direct-stream Array sink so that Response/Request blob() and formData() reject non-byte, non-string chunks like the other four body methods, via a new Blob__fromStreamChunks FFI into Blob.rs's part walker with a strict PartPolicy. It touches no auth, crypto, or injection surface, but it does alter observable Web API behavior (Blob/File chunks in a stream body now reject) and modifies an unsafe lifetime argument in the Blob part loop. The source diff is about 200 lines with roughly 400 lines of new matrix tests across body.test.ts, streams.test.js, async-iterator-stream.test.ts and macro-test.test.ts; the bug hunt ran dry with no findings. The deliberate compat tradeoff (rejecting Blob chunks that previously worked) and the bot authorship are what tip this to defer rather than approve.
Problem
blob()andformData()ofResponse/Requestconvert any chunk of a JS stream body to a string:5then{}resolves"5[object Object]". The other four body methods refuse it. No user reported this.readableStreamToBlobgives the chunk array tonew Blob(chunks)(BunStreamConsumers.cpp:1510).Fix
BlobChunkPolicy::BytesOrString. The part loop ofnew Blob()(Blob.rs) then throwsTypeError: Expected an ArrayBuffer, ArrayBufferView, or string chunk. For a direct stream, the Array sink'swrite()throws.Bun.readableStreamToBlob,stream.blob(),Bun.readableStreamToFormDataandnode:stream/consumersblob()keepnew Blob(chunks), as bun-types documents.test/js/web/fetch/body.test.ts(78 new tests, 58 fail on main),streams.test.js,async-iterator-stream.test.ts,macro-test.test.ts.write()(older defect), a debug guard.Background
new Blob(parts)converts a part that is not a BufferSource or a Blob with ToString.write()of a sink.blob()uses an Array sink.new Blob(chunks): one more pass, and no rejection inwrite().Downsides
blob()andformData(). A Blob chunk gave correct bytes before.Bun.readableStreamToBlob: +3 instructions per chunk (499 to 502)..text: +1,792 B.Notes
Scope
fetch/api/response/response-stream-bad-chunk.any.jsstays at 1 of 6, because its bad chunk is a string and Bun takes a string chunk in every consumer on purpose.5and{}there in all six methods.Buffer.fromthere: an array of bytes and a String object resolve. Bun keeps string, ArrayBuffer and ArrayBufferView, astext()already does.new Response(stream)with such a chunk now fails the build. It inlined the string form before (5became"data:;base64,NQ==").Not changed by this PR
bytes()andarrayBuffer()throw synchronously, and do not reject, for a bad chunk on a stream that was filled and closed before the read.response.body.blob()isReadableStream.prototype.blob(). It keepsnew Blob(chunks).textStream()takes BufferSource chunks only, and errors and cancels at the chunk.stack. The error is created in a native promise reaction, as fortext()andbytes()on a stream that is still open.blob()andformData()now reject it, astext()does.bytes()andarrayBuffer()still take it through the native ArrayBufferSink.text(),blob()andformData(). A producer that reuses one buffer gets wrong bytes, on main and on this branch.stackin the part loop ofnew Blob()is never pushed to, so itsreserveis one allocation per multi-part array.One visible edge
write()now returns the size of the chunk as the Text sink does. An empty ArrayBuffer or DataView returns 0. It returned undefined.Related open PRs
readableStreamToBloband stamps the type inonReadableStreamToBlobFulfilled. A body now runsonReadableStreamToBodyBlobFulfilled. The context slot of that reaction is free for the content type.onReadableStreamToBlobFulfilled. For a body, the check belongs inis_bytes_or_stringinBlob.rs.MOVEconst generic and the three wrappers that this diff edits.Blob__fromStreamChunksthen calls the remaining instance.BunStreamConsumers.cpp.Measurements
Release builds on linux x64, main 6c1eb06 against f3c6d6f. An instruction count is a ptrace single-step of the JS thread from entry to return of the named function. perf, valgrind and strace are not installed on this machine.
Response.blob()reaction, binary 1KiB x8192: 498.7 -> 405.7 instructions per chunk, 17 -> 17 mallocs per call. With 16 B chunks: 386.1 -> 293.1 per chunk. String 64KiB x128: 373,020 -> 360,479 per call.Bun.readableStreamToBlob: +4 instructions per call, +3 per chunk (498.8 -> 501.8).controller.write()on the Array sink: body 695.5 -> 155.5 (string) and 770.5 -> 172.5 (Uint8Array) instructions per write. Stream helpers +4. Text sink +1 to +2. ArrayBuffer sink 650.6 -> 649.6 and 304.2 -> 304.2. The sink dispatch has no indirect jump on either build (objdump).new Blob([u8,u8,u8]): 2,694 -> 2,708 instructions, 4 -> 4 mallocs per call.new File([u8],'f'): 599 -> 603.new Response([u8,'x']): 2,093 -> 2,093. The part loop instances are 5,806 B and 6,682 B -> 5,825 B and 6,666 B. The instance count is unchanged..text+1,792 B (size -A). The stripped file size is unchanged. +1 reaction handler (98 -> 99).JSStreamsRuntime936 -> 944 B per global.JSDirectStreamController176 -> 176 B.Response.blob()on a buffered JS stream: Promise 4 -> 4, Blob 1 -> 1. Settle tick: blob 1 -> 1, formData 2 -> 2.Response.blob(): 1,065 MiB -> 1,065 MiB. One 8 MiB chunk keeps its memfd store.toStringcalls perResponse.blob()on a non-byte chunk: 1 -> 0. Async-iterable yields that run after a refused chunk: 2 -> 0. Lenient entries changed: 0 of 4.blob()on a direct body keeps alive while the promise and the stream are referenced: 8/8 -> 2/8. After they are dropped: 0.bench/snippets/webstreams-consumers.mjs, median of 40 interleaved passes, ms per 8 MiB.Response.blobon a direct stream of 1KiB x8192: 3.22 -> 2.40. On an async generator of 1KiB x8192: 8.03 -> 6.24. Binary 1KiB x8192: 5.55 -> 5.37.toBlobbinary 1KiB x8192: 5.46 -> 5.39. The other default-stream rows move -3.2% to +2.7% with an A/A spread up to 3.1%. The untouchedResponse.arrayBuffercolumn moves -1.6% to +2.8%.Self-review items that are not done
readableStreamIntoArray, one check for all six methods with a rejection at the chunk. It changes whentext(),json(),bytes()andarrayBuffer()reject, so it is a separate change.write()for the Text and Array direct sinks. The defect is older, the output is the same on main, and a copy costs about 2x from 1 KiB chunks.bytes(), and the WPT file with its known failures.Suites run on the debug build
body.test.ts,streams.test.js,async-iterator-stream.test.ts,macro-test.test.ts,body-stream.test.ts,body-clone.test.ts,body-async-iterator.test.ts,body-mixin-errors.test.ts,blob.test.ts,blob-array-fast-path.test.ts,stream-fast-path.test.ts,utf8-bom.test.ts,FormData.test.ts,direct-readable-stream.test.tsx,serve-direct-readable-stream.test.ts,test-stream-consumers.js. The new tests also pass withBUN_JSC_validateExceptionChecks=1.Two tests of
body.test.ts("string body consumption does not leak" withjson) time out at 5 s on the debug build of this machine. They time out the same way on main.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js