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: expectedLength on a default ReadableStream source no longer sets Content-Length with typescript_implemented_streamsΒ #8040
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:
exportdefault{asynctest(ctrl,env){constbody=newReadableStream({expectedLength: 5,start(controller){controller.enqueue(newTextEncoder().encode("hello"));controller.close();},});constresponse=awaitenv.UPSTREAM.fetch("http://upstream/",{method: "POST", body });constcontentLength=awaitresponse.text();console.log(`upstream saw content-length: ${contentLength}`);if(contentLength!=="5")thrownewError("expectedLength did not become the Content-Length");},};
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.
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:
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.
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:
exportdefault{fetch(){constbytes=newTextEncoder().encode("compress me please ".repeat(2000));constbody=newReadableStream({type: "bytes",expectedLength: bytes.length,start(c){c.enqueue(bytes);c.close();},});returnnewResponse(body,{headers: {"content-encoding": "gzip"}});},};
Activity
james-elicx commentedon Oct 27, 2026
Two more places this shows up, both because
expectedLengthon a default source no longer reachestryGetLength():R2
put()rejects a stream it used to accept. R2 requires a known length. With the C++ implementation,expectedLengthsatisfied that check. With the flag,put()throws before sending anything:Here
BUCKETis anr2Bucketbinding pointed at a stub worker that logs what it receives. Without the flag:The error comes from the stub; the upload was sent. With
typescript_implemented_streams:With
type: "bytes"andexpectedLength, both implementations send it.cache.put()stores the entry withoutContent-Length. Fornew Response(new ReadableStream({ expectedLength: 4, ... })), the cache PUT payload starts withHTTP/1.1 200 OK\r\nContent-Length: 4under C++ andHTTP/1.1 200 OK\r\nTransfer-Encoding: chunkedunder the flag. Socache.match()returns the entry with nocontent-lengthheader.james-elicx commentedon Oct 27, 2026
A caution for the fix: when restoring the
Content-Lengthderived fromexpectedLength, don't restore the C++ behaviour with automatic response compression.When a Worker returns an unencoded body with
Content-Encoding: gzip(orbr), the C++ implementation compresses it on the way out. For aFixedLengthStreambody it dropsContent-Lengthand sends chunked, which is correct. For a source withexpectedLength, it keepsContent-Lengthset to the uncompressed size with a compressed body, so the client receives a truncated response it can't decode:Content-Length: 38000,Content-Encoding: gzipEOFErrorTransfer-Encoding: chunked,Content-Encoding: gzipThe
FixedLengthStreambehaviour (noContent-Lengthonce the body gets encoded) is the one to match.Pin a declared length on a compressed TypeScript streams response
Honor a default source's expectedLength in TypeScript streams
Pin a declared length on a compressed TypeScript streams response
Honor a default source's expectedLength in TypeScript streams
Pin a declared length on a compressed TypeScript streams response
Honor a default source's expectedLength in TypeScript streams
Pin a declared length on a compressed TypeScript streams response