You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
🐛 Bug Report — Runtime APIs: a string chunk in a fetch() request body silently sends an empty body with typescript_implemented_streams #8041
With typescript_implemented_streams, if a ReadableStream used as a fetch() request body enqueues a string, the request is sent with an empty body and fetch() resolves normally. The bytes enqueued before the string are dropped too. The only trace of the error is the TypeError: This ReadableStream did not return bytes. passed to the source's cancel(). The caller never sees it.
With the C++ streams implementation, string chunks were sent UTF-8 encoded, so code that enqueues strings worked. Under the flag that code silently loses its request body. Strings are not valid body chunks, so an error would be reasonable. Per the Fetch spec, a chunk that is not a Uint8Array makes the fetch reject with a TypeError. But succeeding with an empty body hides the data loss.
Response bodies are not affected: reading a response whose stream enqueues a string rejects with that TypeError. (Separately, the C++ implementation already sends an empty body for number and null chunks, so that path has the same silent drop.)
Reproduction
worker.js:
exportdefault{asynctest(ctrl,env){constbody=newReadableStream({start(controller){controller.enqueue(newTextEncoder().encode("ab"));controller.enqueue("cd");controller.close();},});constresponse=awaitenv.UPSTREAM.fetch("http://upstream/",{method: "POST", body });constreceived=awaitresponse.text();console.log(`fetch resolved ${response.status}; upstream received ${JSON.stringify(received)}`);if(received!=="abcd")thrownewError("the request body was not delivered");},};
With the flag (workerd 2026-10-27, @cloudflare/workerd-darwin-arm64@1.20261027.1):
fetch resolved 200; upstream received ""
[ FAIL ] main
With compatibilityFlags = [] on both workers (C++ streams):
fetch resolved 200; upstream received "abcd"
[ PASS ] main
It depends only on the calling worker: with the flag on the caller alone the body is empty, and with it on the upstream alone the body arrives intact.
Expected behavior
Either the body is delivered (as with the C++ implementation) or fetch() rejects with a TypeError. The request should not succeed with the body silently replaced by an empty one.
A correction to "Response bodies are not affected" above: that holds for reading a response in JavaScript (text() etc. reject on both implementations). A Response that the fetch handler returns to the client is affected. With the C++ implementation, string chunks in it are sent UTF-8 encoded. With typescript_implemented_streams, the client gets an empty reply: no status line, and the bytes enqueued before the string are lost too. A string chunk in a stream given to HTMLRewriter.transform() fails the same way.
This hits server-sent-event handlers that do controller.enqueue("data: ...\n\n") without a TextEncoder. Those worked before the flag.
With the flag (workerd 2026-10-27), both requests give:
curl: (52) Empty reply from server
with this logged:
Uncaught exception: ... remote.jsg.TypeError: This ReadableStream did not return bytes.
With compatibilityFlags = [], both requests give:
data: one
data: two
As with the request side, rejecting strings matches the spec. But the C++ behaviour is kept for strings piped into an IdentityTransformStream (piping ledger #1: "strings are UTF-8 encoded and pass through on both"). This sink isn't in the ledgers, so I'm noting it here in case the change is unintended.
More sinks where the C++ implementation UTF-8 encoded string chunks and the flag now fails. In each case only the flag on the worker that owns the stream matters. Each one used new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("ab")); c.enqueue("cd"); c.close(); } }):
sink
C++
typescript_implemented_streams
env.KV.put(key, stream)
stores "abcd"
rejects TypeError: This ReadableStream did not return bytes.
cache.put(url, new Response(stream))
stores "abcd"
rejects with the same TypeError
a stream passed or returned over JS RPC (with workerd-autogate-rpc-externals-hydration)
the receiver reads "abcd"
the receiver's read rejects Error: ReadableStream received over RPC disconnected prematurely.; the TypeError is only visible on the sender
The RPC case is the most confusing one: the error the receiver sees doesn't point at the chunk type.
Writing a string to a WritableStream received over RPC, and piping strings into an IdentityTransformStream/FixedLengthStream, still UTF-8 encode on both implementations.
The same silent loss happens with iterable request bodies (fetch_iterable_type_support, the default from 2026-02-19). Here the bytes before the string do arrive, but the string and everything after it are dropped, and fetch() still resolves:
exportdefault{asynctest(ctrl,env){asyncfunction*body(){yieldnewTextEncoder().encode("ab");yield"cd";}constresponse=awaitenv.UPSTREAM.fetch("http://upstream/",{method: "POST",body: body()});console.log(`fetch resolved ${response.status}; upstream received ${JSON.stringify(awaitresponse.text())}`);},};
Run it with the same upstream.js and config as the issue, but with compatibilityDate = "2026-10-27".
Two more consumers with the same root cause, a ReadableStream whose chunks are strings:
EventSource.from(stream) delivers no message events. The TypeError only appears as an uncaught exception, so the page just sees an empty event stream.
HTMLRewriter rejects This ReadableStream did not return bytes. when the strings are element.replace() content or the transformed body. Without the flag both work. The rejection is handled with .catch() but is still also logged as uncaught, which looks like HTMLRewriter reports handled source-stream errors as uncaught #7240.
conststrings=()=>newReadableStream({start(c){c.enqueue("data: hello\n\n");c.close();},});exportdefault{asynctest(){// EventSource.from() over a stream of strings.constevents=[];constes=EventSource.from(strings());es.onmessage=(e)=>events.push(e.data);awaitnewPromise((resolve)=>setTimeout(resolve,100));es.close();// HTMLRewriter content and input from a stream of strings.constpage=newResponse("<p>old</p>",{headers: {"content-type": "text/html"}});constreplaced=awaitnewHTMLRewriter().on("p",{element: (el)=>el.replace(strings(),{html: true})}).transform(page).text().catch((e)=>`rejected ${e}`);constinput=awaitnewHTMLRewriter().transform(newResponse(strings(),{headers: {"content-type": "text/html"}})).text().catch((e)=>`rejected ${e}`);console.log(JSON.stringify({ events, replaced, input }));},};
With the flag:
{"events":[],"replaced":"rejected TypeError: This ReadableStream did not return bytes.","input":"rejected TypeError: This ReadableStream did not return bytes."}
Activity
james-elicx commentedon Oct 27, 2026
A correction to "Response bodies are not affected" above: that holds for reading a response in JavaScript (
text()etc. reject on both implementations). AResponsethat thefetchhandler returns to the client is affected. With the C++ implementation, string chunks in it are sent UTF-8 encoded. Withtypescript_implemented_streams, the client gets an empty reply: no status line, and the bytes enqueued before the string are lost too. A string chunk in a stream given toHTMLRewriter.transform()fails the same way.This hits server-sent-event handlers that do
controller.enqueue("data: ...\n\n")without aTextEncoder. Those worked before the flag.worker.js:config.capnp:With the flag (workerd 2026-10-27), both requests give:
with this logged:
With
compatibilityFlags = [], both requests give:As with the request side, rejecting strings matches the spec. But the C++ behaviour is kept for strings piped into an
IdentityTransformStream(piping ledger #1: "strings are UTF-8 encoded and pass through on both"). This sink isn't in the ledgers, so I'm noting it here in case the change is unintended.james-elicx commentedon Oct 27, 2026
More sinks where the C++ implementation UTF-8 encoded string chunks and the flag now fails. In each case only the flag on the worker that owns the stream matters. Each one used
new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode("ab")); c.enqueue("cd"); c.close(); } }):typescript_implemented_streamsenv.KV.put(key, stream)"abcd"TypeError: This ReadableStream did not return bytes.cache.put(url, new Response(stream))"abcd"TypeErrorworkerd-autogate-rpc-externals-hydration)"abcd"Error: ReadableStream received over RPC disconnected prematurely.; theTypeErroris only visible on the senderThe RPC case is the most confusing one: the error the receiver sees doesn't point at the chunk type.
Writing a string to a
WritableStreamreceived over RPC, and piping strings into anIdentityTransformStream/FixedLengthStream, still UTF-8 encode on both implementations.james-elicx commentedon Oct 27, 2026
The same silent loss happens with iterable request bodies (
fetch_iterable_type_support, the default from 2026-02-19). Here the bytes before the string do arrive, but the string and everything after it are dropped, andfetch()still resolves:Run it with the same
upstream.jsand config as the issue, but withcompatibilityDate = "2026-10-27".Without the flag:
With
typescript_implemented_streams:james-elicx commentedon Oct 27, 2026
Two more consumers with the same root cause, a
ReadableStreamwhose chunks are strings:EventSource.from(stream)delivers nomessageevents. TheTypeErroronly appears as an uncaught exception, so the page just sees an empty event stream.This ReadableStream did not return bytes.when the strings areelement.replace()content or the transformed body. Without the flag both work. The rejection is handled with.catch()but is still also logged as uncaught, which looks like HTMLRewriter reports handled source-stream errors as uncaught #7240.With the flag:
{"events":[],"replaced":"rejected TypeError: This ReadableStream did not return bytes.","input":"rejected TypeError: This ReadableStream did not return bytes."}With
compatibilityFlags = []:{"events":["hello"],"replaced":"data: hello\n\n","input":"data: hello\n\n"}Encode string chunks as UTF-8 when pumping TypeScript streams
Encode string chunks as UTF-8 when pumping TypeScript streams