Skip to content

🐛 Bug Report — Runtime APIs: a string chunk in a fetch() request body silently sends an empty body with typescript_implemented_streams #8041

Closed

Description

@james-elicx

Description

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:

export default {
  async test(ctrl, env) {
    const body = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("ab"));
        controller.enqueue("cd");
        controller.close();
      },
    });
    const response = await env.UPSTREAM.fetch("http://upstream/", { method: "POST", body });
    const received = await response.text();
    console.log(`fetch resolved ${response.status}; upstream received ${JSON.stringify(received)}`);
    if (received !== "abcd") throw new Error("the request body was not delivered");
  },
};

upstream.js:

export default {
  async fetch(request) {
    return new Response(await request.text());
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [(name = "main", worker = .main), (name = "upstream", worker = .upstream)],
  # typescript_implemented_streams only starts with this autogate.
  autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
);

const main :Workerd.Worker = (
  modules = [(name = "worker", esModule = embed "worker.js")],
  compatibilityDate = "2026-02-12",
  compatibilityFlags = ["typescript_implemented_streams"],
  bindings = [(name = "UPSTREAM", service = "upstream")],
);

const upstream :Workerd.Worker = (
  modules = [(name = "upstream", esModule = embed "upstream.js")],
  compatibilityDate = "2026-02-12",
  compatibilityFlags = ["typescript_implemented_streams"],
);
workerd test --experimental config.capnp

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.

Activity

  1. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    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.

    worker.js:

    export default {
      async fetch(request) {
        const body = new ReadableStream({
          start(controller) {
            controller.enqueue(new TextEncoder().encode("data: one\n\n"));
            controller.enqueue("data: two\n\n");
            controller.close();
          },
        });
        if (new URL(request.url).pathname === "/rewriter") {
          return new HTMLRewriter().transform(new Response(body));
        }
        return new Response(body, { headers: { "content-type": "text/event-stream" } });
      },
    };

    config.capnp:

    using Workerd = import "/workerd/workerd.capnp";
    
    const config :Workerd.Config = (
      services = [(name = "main", worker = .main)],
      sockets = [(name = "http", address = "127.0.0.1:8080", http = (), service = "main")],
      # typescript_implemented_streams only starts with this autogate.
      autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
    );
    
    const main :Workerd.Worker = (
      modules = [(name = "worker", esModule = embed "worker.js")],
      compatibilityDate = "2026-02-12",
      compatibilityFlags = ["typescript_implemented_streams"],
    );
    workerd serve --experimental config.capnp
    curl -sS 127.0.0.1:8080/
    curl -sS 127.0.0.1:8080/rewriter

    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.

  2. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    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.

  3. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    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:

    export default {
      async test(ctrl, env) {
        async function* body() {
          yield new TextEncoder().encode("ab");
          yield "cd";
        }
        const response = await env.UPSTREAM.fetch("http://upstream/", { method: "POST", body: body() });
        console.log(`fetch resolved ${response.status}; upstream received ${JSON.stringify(await response.text())}`);
      },
    };

    Run it with the same upstream.js and config as the issue, but with compatibilityDate = "2026-10-27".

    Without the flag:

    fetch resolved 200; upstream received "abcd"
    

    With typescript_implemented_streams:

    fetch resolved 200; upstream received "ab"
    
  4. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    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.
    const strings = () =>
      new ReadableStream({
        start(c) {
          c.enqueue("data: hello\n\n");
          c.close();
        },
      });
    
    export default {
      async test() {
        // EventSource.from() over a stream of strings.
        const events = [];
        const es = EventSource.from(strings());
        es.onmessage = (e) => events.push(e.data);
        await new Promise((resolve) => setTimeout(resolve, 100));
        es.close();
    
        // HTMLRewriter content and input from a stream of strings.
        const page = new Response("<p>old</p>", { headers: { "content-type": "text/html" } });
        const replaced = await new HTMLRewriter()
          .on("p", { element: (el) => el.replace(strings(), { html: true }) })
          .transform(page)
          .text()
          .catch((e) => `rejected ${e}`);
        const input = await new HTMLRewriter()
          .transform(new Response(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."}

    With compatibilityFlags = []:

    {"events":["hello"],"replaced":"data: hello\n\n","input":"data: hello\n\n"}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions