Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions doc/api/stream_iter.md
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,81 @@ added:

Synchronous version of [`bytes()`][].

### `drain(source[, options])`

<!-- YAML
added: REPLACEME
-->

* `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])`

<!-- YAML
added: REPLACEME
-->

* `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])`

<!-- YAML
Expand Down Expand Up @@ -2167,6 +2242,7 @@ console.log(textSync(stream)); // 'hello world'
[`array()`]: #arraysource-options
[`arrayBuffer()`]: #arraybuffersource-options
[`bytes()`]: #bytessource-options
[`drain()`]: #drainsource-options
[`from()`]: #frominput
[`fromSync()`]: #fromsyncinput
[`node:zlib/iter`]: zlib.md#iterable-compression
Expand Down
77 changes: 77 additions & 0 deletions lib/internal/streams/iter/consumers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// New Streams API - Consumers & Utilities
//
// bytes(), text(), arrayBuffer() - collect entire stream
// drain() - consume entire stream, retaining nothing
// tap(), tapSync() - observe without modifying
// merge() - temporal combining of sources
// ondrain() - backpressure drain utility
Expand Down Expand Up @@ -217,6 +218,20 @@ function validateSyncConsumerOptions(options) {
validateBaseConsumerOptions(options);
}

function validateSyncDrainOptions(options) {
validateObject(options, 'options');
if (options.limit !== undefined) {
validateInteger(options.limit, 'options.limit', 0);
}
}

function validateDrainOptions(options) {
validateSyncDrainOptions(options);
if (options.signal !== undefined) {
validateAbortSignal(options.signal, 'options.signal');
}
}

// =============================================================================
// Sync Consumers
// =============================================================================
Expand Down Expand Up @@ -272,6 +287,32 @@ function arraySync(source, options = kNullPrototype) {
return collectSync(source, options.limit);
}

/**
* Read a sync source to completion, discarding every chunk.
* @param {Iterable<Uint8Array[]>} source
* @param {{ limit?: number }} [options]
* @returns {undefined}
*/
function drainSync(source, options = kNullPrototype) {
validateSyncDrainOptions(options);

const limit = options.limit;
const normalized = fromSync(source);
let totalBytes = 0;

for (const batch of normalized) {
// With no limit, just iterate through the stream completely.
if (limit === undefined) continue;
// Otherwise, calculate totalBytes and track the limit.
for (let i = 0; i < batch.length; i++) {
totalBytes += TypedArrayPrototypeGetByteLength(batch[i]);
if (totalBytes > limit) {
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
}
}
}
}

// =============================================================================
// Async Consumers
// =============================================================================
Expand Down Expand Up @@ -328,6 +369,40 @@ async function array(source, options = kNullPrototype) {
return collectAsync(source, options.signal, options.limit);
}

/**
* Read an async or sync source to completion, discarding every chunk.
* @param {AsyncIterable<Uint8Array[]>|Iterable<Uint8Array[]>} source
* @param {{ signal?: AbortSignal, limit?: number }} [options]
* @returns {Promise<undefined>}
*/
async function drain(source, options = kNullPrototype) {
validateDrainOptions(options);
const signal = options.signal;
const limit = options.limit;

signal?.throwIfAborted();

const abortableSource = signal && isAsyncIterable(source) ?
yieldAbortable(source, signal) : source;
const normalized = from(abortableSource);
const iterable = signal ? yieldAbortable(normalized, signal) : normalized;

let totalBytes = 0;

for await (const batch of iterable) {
signal?.throwIfAborted();
// With no limit, just iterate through the stream completely.
if (limit === undefined) continue;
// Otherwise, calculate totalBytes and track the limit.
for (let i = 0; i < batch.length; i++) {
totalBytes += TypedArrayPrototypeGetByteLength(batch[i]);
if (totalBytes > limit) {
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
}
}
}
}

// =============================================================================
// Tap Utilities
// =============================================================================
Expand Down Expand Up @@ -585,6 +660,8 @@ module.exports = {
arraySync,
bytes,
bytesSync,
drain,
drainSync,
merge,
ondrain,
tap,
Expand Down
6 changes: 6 additions & 0 deletions lib/stream/iter.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const {
arrayBufferSync,
array,
arraySync,
drain,
drainSync,
Comment thread
Ethan-Arrowood marked this conversation as resolved.
tap,
tapSync,
merge,
Expand Down Expand Up @@ -100,12 +102,14 @@ const Stream = ObjectFreeze({
text,
arrayBuffer,
array,
drain,

// Consumers (sync)
bytesSync,
textSync,
arrayBufferSync,
arraySync,
drainSync,

// Combining
merge,
Expand Down Expand Up @@ -164,12 +168,14 @@ module.exports = {
text,
arrayBuffer,
array,
drain,

// Consumers (sync)
bytesSync,
textSync,
arrayBufferSync,
arraySync,
drainSync,

// Combining
merge,
Expand Down
3 changes: 2 additions & 1 deletion test/parallel/test-quic-cc-algorithm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -39,7 +40,7 @@ for (const cc of ['reno', 'cubic', 'bbr']) {
body: encoder.encode('congestion control test'),
});

for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);
await Promise.all([stream.closed, serverDone.promise]);

// Verify the session stats show congestion control was active.
Expand Down
3 changes: 2 additions & 1 deletion test/parallel/test-quic-datagram-multiple.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { hasQuic, skip, mustCall, mustCallAtLeast } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -72,7 +73,7 @@ for (let i = 0; i < numDatagrams; i++) {
}

// Complete the stream.
for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);
await stream.closed;

// At least some datagrams should have arrived.
Expand Down
3 changes: 2 additions & 1 deletion test/parallel/test-quic-diagnostics-channel-stream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import dc from 'node:diagnostics_channel';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -59,7 +60,7 @@ const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('diagnostics test'),
});

for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);

await Promise.all([stream.closed, serverDone.promise, clientSession.closed]);
await serverEndpoint.close();
3 changes: 2 additions & 1 deletion test/parallel/test-quic-flow-control-blob.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -42,7 +43,7 @@ const stream = await clientSession.createBidirectionalStream({
body: blob,
});

for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);
await Promise.all([stream.closed, serverDone.promise]);
await clientSession.close();
await serverEndpoint.close();
3 changes: 2 additions & 1 deletion test/parallel/test-quic-flow-control-block-resume.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -43,7 +44,7 @@ await clientSession.opened;
const stream = await clientSession.createBidirectionalStream();
stream.setBody(data);

for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);

await Promise.all([stream.closed, serverDone.promise, clientSession.closed]);

Expand Down
7 changes: 4 additions & 3 deletions test/parallel/test-quic-flow-control-params.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -57,13 +58,13 @@ const encoder = new TextEncoder();
for (let offset = 0; offset < expected.byteLength; offset += chunkSize) {
const chunk = expected.slice(offset, offset + chunkSize);
while (!w.writeSync(chunk)) {
const drain = w[dp]();
if (drain) await drain;
const drainPromise = w[dp]();
if (drainPromise) await drainPromise;
}
}
w.endSync();

for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);
await Promise.all([stream.closed, serverDone.promise]);
await clientSession.close();
await serverEndpoint.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { hasQuic, skip } from '../common/index.mjs';
import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand All @@ -33,8 +34,8 @@ const serverMayRead = new Promise((resolve) => { letServerRead = resolve; });
const endpoint = await listen((session) => {
session.onstream = async (stream) => {
await serverMayRead;
// eslint-disable-next-line no-unused-vars
for await (const _ of stream) { /* reading extends the window */ }
// Reading extends the window
await drain(stream);
};
}, {
sni: { '*': { keys: [key], certs: [cert] } },
Expand Down
3 changes: 2 additions & 1 deletion test/parallel/test-quic-key-update-peer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import { drain } from 'node:stream/iter';

if (!hasQuic) {
skip('QUIC is not enabled');
Expand Down Expand Up @@ -41,7 +42,7 @@ await clientSession.opened;
const stream = await clientSession.createBidirectionalStream({
body: encoder.encode('after key update'),
});
for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars
await drain(stream);
await Promise.all([stream.closed, serverDone.promise]);

await clientSession.closed;
Expand Down
Loading
Loading