From 0c3c263f4889c40e0d4c1cc7a3dd7969f0baf446 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 27 Aug 2026 12:10:42 -0600 Subject: [PATCH 1/2] stream: add drain()/drainSync() for stream/iter Every consumer in node:stream/iter retains what it reads, so there is no way to read a streamable to completion while keeping nothing. To clear a stream, callers write `for await (const _ of source) {}`. This is apparent throughout multiple test suites, including the stream/iter tests themselves. These are especially useful for QUIC where a receiver doesn't want the payload, but still has to read it to completion to relieve backpressure. bytes() does that too, but allocates the whole payload to discard it. drain() pulls every batch and drops it, so peak memory is one batch regardless of volume. It takes the same signal and limit options as the other consumers, rejects if the source errors mid-stream, and fulfills with undefined. drainSync() is the synchronous form. Assisted-by: Claude Opus 5 Signed-off-by: Ethan Arrowood --- doc/api/quic.md | 4 + doc/api/stream_iter.md | 76 ++++ lib/internal/streams/iter/consumers.js | 77 ++++ lib/stream/iter.js | 6 + .../test-stream-iter-consumers-drain.mjs | 405 ++++++++++++++++++ test/parallel/test-stream-iter-namespace.js | 6 + 6 files changed, 574 insertions(+) create mode 100644 test/parallel/test-stream-iter-consumers-drain.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index d90b1b938bd2..c4b22a94af9c 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -343,6 +343,10 @@ Only one async iterator can be obtained per stream. The stream is also compatible with `node:stream/iter` utilities such as `Stream.bytes()`, `Stream.text()`, and `Stream.pipeTo()`. +Consuming a stream is what returns flow-control credit to the peer, so a +stream whose payload is not wanted should still be read to completion. Use +`Stream.drain()` to read the stream without retaining any of it. + ### Datagrams In addition to streams, QUIC supports unreliable datagrams ([RFC 9221][]) for diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index c1ebacd3dfad..a2b002cf9af1 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -1025,6 +1025,81 @@ added: Synchronous version of [`bytes()`][]. +### `drain(source[, options])` + + + +* `source` {AsyncIterable|Iterable} whose chunks must be {Uint8Array\[]} +* `options` {Object} + * `signal` {AbortSignal} + * `limit` {number} Maximum number of bytes to consume. If the total bytes + read exceeds limit, an `ERR_OUT_OF_RANGE` error is thrown +* Returns: {Promise} Fulfills with `undefined`. + +Read a source to completion, discarding every chunk. Unlike the other +consumers, `drain()` retains nothing. Memory tops-out at a single batch no +matter how much data the source produces. + +Use this to consume a stream when the content doesn't matter. For example, A +QUIC stream only returns flow-control credit to the peer as its data is +consumed, so a receiver that does not want the payload must still read it to +completion. + +If the source errors part-way through, the returned promise rejects with that +error. + +There is no default `limit`. `drain()` reads until the source is exhausted +unless a limit is specified. When a limit is configured and the source exceeds +it, the promise rejects and the source is cancelled. A partial drain is never +reported as success. + +```mjs +import { drain, from, pull, tap } from 'node:stream/iter'; + +// Count the bytes flowing through a stream without retaining any of them. +let bytesSeen = 0; +const counter = tap((chunks) => { + for (const chunk of chunks) bytesSeen += chunk.byteLength; +}); + +await drain(pull(from('hello world'), counter)); +console.log(bytesSeen); // 11 +``` + +```cjs +const { drain, from, pull, tap } = require('node:stream/iter'); + +async function run() { + // Count the bytes flowing through a stream without retaining any of them. + let bytesSeen = 0; + const counter = tap((chunks) => { + for (const chunk of chunks) bytesSeen += chunk.byteLength; + }); + + await drain(pull(from('hello world'), counter)); + console.log(bytesSeen); // 11 +} + +run().catch(console.error); +``` + +### `drainSync(source[, options])` + + + +* `source` {Iterable} whose chunks must be {Uint8Array\[]} +* `options` {Object} + * `limit` {number} Maximum number of bytes to consume. If the total bytes + read exceeds limit, an `ERR_OUT_OF_RANGE` error is thrown +* Returns: {undefined} + +Synchronous version of [`drain()`][]. Throws `ERR_INVALID_ARG_TYPE` if `source` +is not synchronously iterable. + ### `text(source[, options])`