Skip to content

πŸ› Bug Report β€” Runtime APIs: expectedLength on a default ReadableStream source no longer sets Content-Length with typescript_implemented_streamsΒ #8040

Closed

Description

@james-elicx

Description

With typescript_implemented_streams, an expectedLength on a default (non-byte) ReadableStream source no longer becomes the body's Content-Length. The subrequest is sent with Transfer-Encoding: chunked instead. With the C++ streams implementation, the same stream is sent with Content-Length.

Code that relies on the declared length then changes behaviour, for example uploads to APIs that need a known length, such as R2 put(), and requests to origins that reject chunked bodies. The C++ implementation also enforced the declared length: a stream that delivered fewer bytes failed the request. Under the flag the declaration is silently ignored.

expectedLength on a byte source (type: "bytes"), on a TransformStream transformer, and FixedLengthStream all still set Content-Length. Only the default source drops it. src/tests/streams/readable/AGENTS.md ledger row 34 describes expectedLength on a default source as unused by both implementations, but the C++ implementation does use it, as the repro shows. The rsRequest test in #4418 also uses a default source with expectedLength.

Reproduction

worker.js:

export default {
  async test(ctrl, env) {
    const body = new ReadableStream({
      expectedLength: 5,
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hello"));
        controller.close();
      },
    });
    const response = await env.UPSTREAM.fetch("http://upstream/", { method: "POST", body });
    const contentLength = await response.text();
    console.log(`upstream saw content-length: ${contentLength}`);
    if (contentLength !== "5") throw new Error("expectedLength did not become the Content-Length");
  },
};

upstream.js:

export default {
  async fetch(request) {
    await request.arrayBuffer();
    return new Response(String(request.headers.get("content-length")));
  },
};

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):

upstream saw content-length: null
[ FAIL ] main

With compatibilityFlags = [] on both workers (C++ streams):

upstream saw content-length: 5
[ PASS ] main

Expected behavior

A default source's expectedLength sets the body's Content-Length, as it does with the C++ implementation and as it still does for byte sources and TransformStream.

Activity

  1. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    Two more places this shows up, both because expectedLength on a default source no longer reaches tryGetLength():

    R2 put() rejects a stream it used to accept. R2 requires a known length. With the C++ implementation, expectedLength satisfied that check. With the flag, put() throws before sending anything:

    export default {
      async test(ctrl, env) {
        const body = new ReadableStream({
          expectedLength: 4,
          start(controller) {
            controller.enqueue(new TextEncoder().encode("four"));
            controller.close();
          },
        });
        try {
          await env.BUCKET.put("key", body);
          console.log("put() resolved");
        } catch (error) {
          console.log(`put() rejected: ${error}`);
        }
      },
    };

    Here BUCKET is an r2Bucket binding pointed at a stub worker that logs what it receives. Without the flag:

    bucket received a PUT with 47 bytes
    put() rejected: Error: put: Unspecified error (0)
    

    The error comes from the stub; the upload was sent. With typescript_implemented_streams:

    put() rejected: TypeError: Provided readable stream must have a known length (request/response body or readable half of FixedLengthStream)
    

    With type: "bytes" and expectedLength, both implementations send it.

    cache.put() stores the entry without Content-Length. For new Response(new ReadableStream({ expectedLength: 4, ... })), the cache PUT payload starts with HTTP/1.1 200 OK\r\nContent-Length: 4 under C++ and HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked under the flag. So cache.match() returns the entry with no content-length header.

  2. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    A caution for the fix: when restoring the Content-Length derived from expectedLength, don't restore the C++ behaviour with automatic response compression.

    When a Worker returns an unencoded body with Content-Encoding: gzip (or br), the C++ implementation compresses it on the way out. For a FixedLengthStream body it drops Content-Length and sends chunked, which is correct. For a source with expectedLength, it keeps Content-Length set to the uncompressed size with a compressed body, so the client receives a truncated response it can't decode:

    export default {
      fetch() {
        const bytes = new TextEncoder().encode("compress me please ".repeat(2000));
        const body = new ReadableStream({
          type: "bytes",
          expectedLength: bytes.length,
          start(c) { c.enqueue(bytes); c.close(); },
        });
        return new Response(body, { headers: { "content-encoding": "gzip" } });
      },
    };
    Headers Body
    C++ Content-Length: 38000, Content-Encoding: gzip 10 bytes, gzip EOFError
    TypeScript (today, because of this issue) Transfer-Encoding: chunked, Content-Encoding: gzip 146 bytes, decodes to 38000

    The FixedLengthStream behaviour (no Content-Length once the body gets encoded) is the one to match.

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