webstreams: native CompressionStream/DecompressionStream/TextEncoderStream/TextDecoderStream - #36695
Merged
Jarred-Sumner merged 41 commits intoAug 4, 2026
Conversation
…tream/TextDecoderStream via TransformerKind CompressionStream and DecompressionStream were JS builtins that routed through node:zlib and a duplex adapter. TextEncoderStream and TextDecoderStream were already C++ cells but still looked up .encode/.decode on a held JS wrapper cell per chunk (plus an options object allocation for TextDecoderStream). All four now drive Rust state owned directly by the instance cell (a void* freed in the destructor), dispatched from the existing TransformerKind switch. The transform step is C++ -> extern "C" -> Rust -> JSUint8Array/JSString -> transformStreamDefaultControllerEnqueue, with the stock TransformStream backpressure machinery handling the readable/writable coupling. New src/runtime/webcore/CompressionStreamCoder.rs owns one zlib/brotli/zstd context per stream and handles gzip multi-member (RFC 1952 2.2), zstd multi-frame + skippable frames (RFC 8878 3.1), truncated input on flush, and trailing-junk detection (ERR_TRAILING_JUNK_AFTER_STREAM_END).
Collaborator
Author
…espoke JSSink__writeBytes dispatcher
Comment on lines
+66
to
+72
| // Native byte-producing subclasses only: when `readStreamIntoSink` attaches a | ||
| // native JSSink controller to this transform, the transform arms write coder | ||
| // output straight to `m_nativeSinkPtr` via the Rust SinkHandle dispatcher | ||
| // (Bun__NativeTransformSink__writeBytes) instead of wrapping it in a | ||
| // JSUint8Array and enqueueing on the readable. | ||
| // `m_nativeSinkReadyPromise` is the transform-algorithm result returned on | ||
| // sink backpressure; the sink's onReady resolves it. |
Contributor
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Comment on lines
+467
to
+471
| // Rust-side single dispatch for the native-transform → native-JSSink byte write, routed | ||
| // through SinkHandle::write (src/runtime/webcore/Sink.rs). Returns a negative number for | ||
| // native ByteStream/FileReader sources under backpressure, but a pending JSPromise for the | ||
| // JSController-sourced sinks that readStreamIntoSink attaches (HTTPResponseSink/FileSink). | ||
| // Both mean "suspend" — nativeSinkWriteIsBackpressure below reads either shape. |
Contributor
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Comment on lines
+670
to
+676
| // ────────────────────────────────────────────────────────────────────────── | ||
| // Native-transform → native-sink byte-write dispatch | ||
| // | ||
| // Replaces the generated per-sink `${name}__writeBytes` thunks + the C++ | ||
| // `JSSink__writeBytes` SinkID switch with a single Rust entry point that | ||
| // routes through `SinkHandle::write`. | ||
| // ────────────────────────────────────────────────────────────────────────── |
Contributor
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Comment on lines
+678
to
+687
| /// Map a C++ `WebCore::SinkID` + erased `m_sinkPtr` to a [`SinkHandle`]. | ||
| /// | ||
| /// `ptr` is the `m_sinkPtr` stored on the JS wrapper (a `*mut JSSink<T>` for | ||
| /// the `T` selected by `id`); `JSSink<T>` is `#[repr(transparent)]` over `T`, | ||
| /// so the cast to `*mut T` is an address-preserving no-op. | ||
| /// | ||
| /// # Safety | ||
| /// `ptr` must be a live, properly-aligned pointer to the concrete sink type | ||
| /// that `id` names (the same pointer the generated `${name}__*` thunks | ||
| /// receive), valid for the lifetime of the returned handle. |
Contributor
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Comment on lines
+744
to
+752
| /// Route a borrowed byte chunk from a native transform (`JSTransformStream` | ||
| /// with `m_nativeSinkPtr` attached) into the concrete sink via | ||
| /// [`SinkHandle::write`]. | ||
| /// | ||
| /// Return shape matches [`streams::result::Writable::to_js`] so | ||
| /// `nativeSinkWriteIsBackpressure` reads a negative number / pending promise | ||
| /// exactly as the previous `js_write_bytes` path produced. No | ||
| /// [`JsSinkType::get_pending_error`] guard: every sink uses the trait-default | ||
| /// `None`, so omitting it is behavior-preserving. |
Contributor
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Jarred-Sumner
deleted the
claude/farm/cc1a3bf2/native-compression-text-streams
branch
August 4, 2026 05:28
springmin
pushed a commit
to springmin/bun
that referenced
this pull request
Aug 4, 2026
…tream/TextDecoderStream (oven-sh#36695) Rewrites `CompressionStream`, `DecompressionStream`, `TextEncoderStream`, and `TextDecoderStream` as native C++ `JSTransformStream` subclasses that drive Rust state directly, and adds a native-sink output path so coder output goes straight to a native JSSink (HTTPResponseSink, FileSink, etc.) without a `JSUint8Array` per chunk. - `CompressionStream` / `DecompressionStream`: JS builtins that `require("node:zlib")`, wrap a Node duplex in `newBufferSourceTransformPairFromDuplex`, and round-trip every chunk through the threadpool. - `TextEncoderStream` / `TextDecoderStream`: C++ cells with `TransformerKind` arms, but each chunk did `encoder->get(global, "encode")` + `JSC::call(...)` on a held JS wrapper cell; `TextDecoderStream` also allocated a `{stream: true}` options object per chunk. - `readStreamIntoSink` (every `Response(stream)` → native sink pump) did a prototype `.write()` lookup + `JSC::call` per chunk. **Native cells**: each instance cell IS a `JSTransformStream` (C++ subclass), so `m_readable`/`m_writable`/`m_controller`/`m_backpressure` live on the instance and there is no second cell. `JSTransformStream` stays `JSNonFinalObject`; each subclass registers a per-instance `vm.heap.addFinalizer` only when it actually allocated native state (a utf-8 non-fatal `TextDecoderStream` registers nothing). The coder/decoder is freed eagerly at `ClearAlgorithms` (post-flush / error / cancel) so zlib/brotli/zstd window buffers don't wait for a full GC; an in-use guard (`runNativeArm`) defers that free when `ClearAlgorithms` is reached re-entrantly from user JS inside a transform arm's chunk coercion. `setUpNativeTransformStream()` initializes the subclass in place; the JS-visible prototype chain is unchanged (`instanceof TransformStream` stays false). **Rust coder**: `src/runtime/webcore/CompressionStreamCoder.rs` holds one zlib/brotli/zstd context per stream and implements gzip concatenated members (RFC 1952 2.2), zstd concatenated + skippable frames (RFC 8878 3.1) with cross-chunk magic buffering, truncated-input detection on flush, and trailing-junk rejection (`ERR_TRAILING_JUNK_AFTER_STREAM_END`). zlib `avail_in` is clamped to `u32::MAX` and refilled per loop so a ≥4 GiB chunk is not truncated. **TextDecoderStream utf-8 fast path**: `new TextDecoderStream()` with utf-8 and `fatal:false` (the default) shares `Body.textStream()`'s `streamingUTF8Decode` (simdutf, 4-byte inline state); no Rust `TextDecoder` is allocated. `ignoreBOM` maps to the initial `bomSeen` flag. Other encodings and `fatal:true` use the Rust `TextDecoder`. **TextEncoderStream native-sink path**: when a native sink is attached, the encoder writes into a per-encoder reusable `Vec<u8>` and hands it to `Bun__JSSink__writeBytesById` directly; no `JSUint8Array` per chunk. **Native-sink output path**: `readStreamIntoSink` detects when the readable is a byte-producing `TransformerKind` (Compression/Decompression/TextEncoder) and attaches the native sink (`m_nativeSinkPtr`/`m_nativeSinkId`/`m_nativeSinkCell`) to the transform. The transform arms then run the coder into a `Vec<u8>` and hand those bytes to `Bun__JSSink__writeBytesById` (dispatched by the controller's `SinkID`) before dropping the Vec. Sink backpressure holds the in-flight write via `m_nativeSinkReadyPromise`, resolved by the sink's `onReady`. `rsisDetachNativeTransform` clears the attachment before every sink `end()`/`close()` (including the sink-initiated `onClose` callback). **JSSink `writeBytes`**: each generated sink type gains `${name}__writeBytes(void* sinkPtr, JSGlobalObject*, const uint8_t*, size_t)` (forwards to Rust `JsSinkType::write_bytes`), plus `JSSink__writeBytes(SinkID, ...)` / `Bun__JSSink__writeBytesById` dispatchers. `rsisSinkWrite` uses it directly for `JSArrayBufferView` chunks instead of the prototype `.write()` lookup (benefits every byte stream, not only these four). **readableHighWaterMark = 1** for the native subclasses (not the spec's 0) so a single `await writer.write(x)` completes before any reader is attached, matching Node.js and Chromium. Release build `bb790d282` vs canary main `87e168fb0` (post-SinkHandle), 3-run median, 200 × 64 KB per iteration: | case | main | this PR | Δ | |---|---|---|---| | CompressionStream(gzip) collect | 23.1 MB/s, 1881 ms cpu | 46.3 MB/s, 1359 ms cpu | **2.0x, -28% cpu** | | CompressionStream(gzip) Bun.serve | 23.8 MB/s, 2309 ms cpu | 45.9 MB/s, 1504 ms cpu | **1.9x, -35% cpu** | | DecompressionStream(gzip) collect | 920 MB/s, 99.6 ms cpu | 1046 MB/s, 60.2 ms cpu | **+14%, -40% cpu** | | TextEncoderStream collect | 3147 MB/s | 3214 MB/s | ~same | | TextEncoderStream Bun.serve | 510 MB/s | 503 MB/s | ~same | | TextDecoderStream collect | 1944 MB/s | 1782 MB/s | -8% (noise) | | RSS delta | 120 MB | 90 MB | **-25%** | The compression/decompression speedup is from dropping the `node:zlib` duplex adapter and its per-chunk threadpool round-trip. TextEncoderStream/TextDecoderStream were already mostly native. **Backpressure** (pull source → `CompressionStream` → `Bun.serve`, client reads one chunk then stalls, 500 × 64 KB incompressible): - main: source pulls race to 500 immediately (the `node:zlib` adapter never propagated sink backpressure) - this PR: pulls park at ~44 until the client drains, then complete **Async codec threshold (128 KB)**: single-write `CompressionStream('''gzip''')` latency sweep, 20 iters each (release build): | chunk | JS-thread blocked | throughput | |---|---|---| | 64 KB (sync) | 1.19 ms/write | 52.5 MB/s | | 128 KB (sync) | 2.57 ms/write | 48.6 MB/s | | 129 KB (async) | 0.01 ms/write | 42.6 MB/s | | 256 KB (async) | 0.01 ms/write | 44.6 MB/s | | 2048 KB (async) | 0.01 ms/write | 45.5 MB/s | Above the threshold the JS thread is free in 0.01 ms (vs 2.57 ms blocked at 128 KB sync); the WorkPool round-trip costs ~13% throughput at the boundary, recovering by 256 KB. - `test/js/web/streams/compression.test.ts`: all existing coverage plus per-format `CompressionStream(...) -> native HTTP response sink round-trips` via `Bun.serve`, plus backpressure tests - `test/js/web/encoding/text-{encoder,decoder}-stream.test.ts`: all WPT-derived tests pass; added utf-8 fast-path BOM/replacement/split-BOM tests, utf-8 fatal split/truncated tests, `TextEncoderStream -> HTTPResponseSink` round-trip (incl. dangling-surrogate flush) - Node parallel tests: `test-webstreams-compression-{bad-chunks,buffer-source}.js`, `test-whatwg-webstreams-compression.js`, `test-whatwg-webstreams-encoding.js`, `test-whatwg-webstreams-adapters-*.js` all pass Brotli decode errors keep the `node:zlib`-compatible shape: `TypeError` with own `.code = "ERR_" + BrotliDecoderErrorString()` and a `.cause` carrying the same `.code` (the `compression.test.ts` brotli-error test asserts all four). Zlib/zstd decode errors become `TypeError("inflate failed" / "zstd decode failed")` without the previous library-specific `.code`; Node's own `test-webstreams-compression-bad-chunks.js` (which this PR passes) only asserts `name: 'TypeError'` for those. - TextEncoderStream pure-ASCII zero-copy path (`GCOwned<StringView>` → `Temporary` straight to the sink) - `HTMLRewriter` native-sink path (same shape, follow-up) <!-- robobun:evidence:begin --> --- **no test proof** · iteration 16 · 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 <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Jarred-Sumner
pushed a commit
that referenced
this pull request
Aug 5, 2026
#36924) ## What Adds a regression test pinning that CompressionStream releases its native coder eagerly when the piped source errors, rather than leaving it to the cell's finalizer. ## Why Before the native rewrite in #36695, CompressionStream was a JS builtin wrapping node:zlib, and a pipeline whose source throws released its deflate context (~280 KiB of zlib state) only at finalization. A busy loop of failing pipelines retained every context: Bun.gc(true) could not reclaim them and RSS grew ~280 KiB per iteration until the process went idle. A server gzipping response bodies whose upstreams abort holds hundreds of MB of dead deflate contexts under sustained load, with nothing visible in heapUsed. Repro loop (what the test runs in a child process): ```js let n = 0; const src = new ReadableStream({ pull(c) { if (++n > 5) throw new Error("source failed"); c.enqueue(new Uint8Array(8192).fill(n)); }, }); try { await new Response(src.pipeThrough(new CompressionStream("gzip"))).arrayBuffer(); } catch {} ``` Measured RSS growth over 448 errored pipelines (after full GC): - bun 1.3.14 (node:zlib-backed implementation): 132 MiB; at 3000 iterations RSS reaches ~874 MB and survives Bun.gc(true) - after #36695: 7 MiB release, 8 MiB debug+ASAN #36695 frees the coder at ClearAlgorithms, the shared terminal for post-flush, error, and cancel, leaving the finalizer as a fallback for abandoned streams. Nothing pinned that behavior; this test does, with a 64 MiB bound. ## Verification - Fails on bun 1.3.14: expect(deltaMiB).toBeLessThan(64) receives 132 - Passes on current main: bun bd test test/js/web/streams/compression.test.ts, 36 pass - The child disables the ASAN allocator quarantine (quarantine_size_mb=0, same approach as the shell and archive leak tests) so the delta measures retention rather than quarantined frees <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 1 · 1 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/web/streams/compression.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/web/streams/compression.test.ts bun test v1.4.0 (459064d) test/js/web/streams/compression.test.ts: (pass) TransformStream.prototype getters reject native transform subclasses (0) [16.06ms] (pass) TransformStream.prototype getters reject native transform subclasses (1) [3.59ms] (pass) TransformStream.prototype getters reject native transform subclasses (2) [3.08ms] (pass) TransformStream.prototype getters reject native transform subclasses (3) [2.96ms] (pass) CompressionStream and DecompressionStream > brotli > compresses data with brotli [16.13ms] (pass) CompressionStream and DecompressionStream > brotli > decompresses brotli data [22.86ms] (pass) CompressionStream and DecompressionStream > brotli > round-trip compression with brotli [62.84ms] (pass) CompressionStream and DecompressionStream > zstd > compresses data with zstd [14.54ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses zstd data [21.09ms] (pass) CompressionStream and DecompressionStream > zstd > round-trip compression with zstd [38.53ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream [12.86ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream split across writes (next = zstd frame) [18.29ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses a multi-frame zstd stream split across writes (next = skippable frame) [7.84ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses many concatenated zstd frames larger than one output chunk [15.38ms] (pass) CompressionStream and DecompressionStream > zstd > decompresses a zstd stream with a leading skippable frame [9.97ms] (pass) CompressionStream and DecompressionStream > zstd > rejects trailing garbage after a zstd frame [10.32ms] (pass) CompressionStream and DecompressionStream > all formats > works with ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/web/streams/compression.test.ts | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) ``` </details> **gate history** · 3 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/web/streams/compression.test.ts 2 5 0 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Rewrites
CompressionStream,DecompressionStream,TextEncoderStream, andTextDecoderStreamas native C++JSTransformStreamsubclasses that drive Rust state directly, and adds a native-sink output path so coder output goes straight to a native JSSink (HTTPResponseSink, FileSink, etc.) without aJSUint8Arrayper chunk.Before
CompressionStream/DecompressionStream: JS builtins thatrequire("node:zlib"), wrap a Node duplex innewBufferSourceTransformPairFromDuplex, and round-trip every chunk through the threadpool.TextEncoderStream/TextDecoderStream: C++ cells withTransformerKindarms, but each chunk didencoder->get(global, "encode")+JSC::call(...)on a held JS wrapper cell;TextDecoderStreamalso allocated a{stream: true}options object per chunk.readStreamIntoSink(everyResponse(stream)→ native sink pump) did a prototype.write()lookup +JSC::callper chunk.After
Native cells: each instance cell IS a
JSTransformStream(C++ subclass), som_readable/m_writable/m_controller/m_backpressurelive on the instance and there is no second cell.JSTransformStreamstaysJSNonFinalObject; each subclass registers a per-instancevm.heap.addFinalizeronly when it actually allocated native state (a utf-8 non-fatalTextDecoderStreamregisters nothing). The coder/decoder is freed eagerly atClearAlgorithms(post-flush / error / cancel) so zlib/brotli/zstd window buffers don't wait for a full GC; an in-use guard (runNativeArm) defers that free whenClearAlgorithmsis reached re-entrantly from user JS inside a transform arm's chunk coercion.setUpNativeTransformStream()initializes the subclass in place; the JS-visible prototype chain is unchanged (instanceof TransformStreamstays false).Rust coder:
src/runtime/webcore/CompressionStreamCoder.rsholds one zlib/brotli/zstd context per stream and implements gzip concatenated members (RFC 1952 2.2), zstd concatenated + skippable frames (RFC 8878 3.1) with cross-chunk magic buffering, truncated-input detection on flush, and trailing-junk rejection (ERR_TRAILING_JUNK_AFTER_STREAM_END). zlibavail_inis clamped tou32::MAXand refilled per loop so a ≥4 GiB chunk is not truncated.TextDecoderStream utf-8 fast path:
new TextDecoderStream()with utf-8 andfatal:false(the default) sharesBody.textStream()'sstreamingUTF8Decode(simdutf, 4-byte inline state); no RustTextDecoderis allocated.ignoreBOMmaps to the initialbomSeenflag. Other encodings andfatal:trueuse the RustTextDecoder.TextEncoderStream native-sink path: when a native sink is attached, the encoder writes into a per-encoder reusable
Vec<u8>and hands it toBun__JSSink__writeBytesByIddirectly; noJSUint8Arrayper chunk.Native-sink output path:
readStreamIntoSinkdetects when the readable is a byte-producingTransformerKind(Compression/Decompression/TextEncoder) and attaches the native sink (m_nativeSinkPtr/m_nativeSinkId/m_nativeSinkCell) to the transform. The transform arms then run the coder into aVec<u8>and hand those bytes toBun__JSSink__writeBytesById(dispatched by the controller'sSinkID) before dropping the Vec. Sink backpressure holds the in-flight write viam_nativeSinkReadyPromise, resolved by the sink'sonReady.rsisDetachNativeTransformclears the attachment before every sinkend()/close()(including the sink-initiatedonClosecallback).JSSink
writeBytes: each generated sink type gains${name}__writeBytes(void* sinkPtr, JSGlobalObject*, const uint8_t*, size_t)(forwards to RustJsSinkType::write_bytes), plusJSSink__writeBytes(SinkID, ...)/Bun__JSSink__writeBytesByIddispatchers.rsisSinkWriteuses it directly forJSArrayBufferViewchunks instead of the prototype.write()lookup (benefits every byte stream, not only these four).readableHighWaterMark = 1 for the native subclasses (not the spec's 0) so a single
await writer.write(x)completes before any reader is attached, matching Node.js and Chromium.Benchmark
Release build
bb790d282vs canary main87e168fb0(post-SinkHandle), 3-run median, 200 × 64 KB per iteration:The compression/decompression speedup is from dropping the
node:zlibduplex adapter and its per-chunk threadpool round-trip. TextEncoderStream/TextDecoderStream were already mostly native.Backpressure (pull source →
CompressionStream→Bun.serve, client reads one chunk then stalls, 500 × 64 KB incompressible):node:zlibadapter never propagated sink backpressure)Async codec threshold (128 KB): single-write
CompressionStream('''gzip''')latency sweep, 20 iters each (release build):Above the threshold the JS thread is free in 0.01 ms (vs 2.57 ms blocked at 128 KB sync); the WorkPool round-trip costs ~13% throughput at the boundary, recovering by 256 KB.
Tests
test/js/web/streams/compression.test.ts: all existing coverage plus per-formatCompressionStream(...) -> native HTTP response sink round-tripsviaBun.serve, plus backpressure teststest/js/web/encoding/text-{encoder,decoder}-stream.test.ts: all WPT-derived tests pass; added utf-8 fast-path BOM/replacement/split-BOM tests, utf-8 fatal split/truncated tests,TextEncoderStream -> HTTPResponseSinkround-trip (incl. dangling-surrogate flush)test-webstreams-compression-{bad-chunks,buffer-source}.js,test-whatwg-webstreams-compression.js,test-whatwg-webstreams-encoding.js,test-whatwg-webstreams-adapters-*.jsall passError-message change
Brotli decode errors keep the
node:zlib-compatible shape:TypeErrorwith own.code = "ERR_" + BrotliDecoderErrorString()and a.causecarrying the same.code(thecompression.test.tsbrotli-error test asserts all four). Zlib/zstd decode errors becomeTypeError("inflate failed" / "zstd decode failed")without the previous library-specific.code; Node's owntest-webstreams-compression-bad-chunks.js(which this PR passes) only assertsname: 'TypeError'for those.Not in this PR
GCOwned<StringView>→Temporarystraight to the sink)HTMLRewriternative-sink path (same shape, follow-up)no test proof · iteration 16 · 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