diff --git a/doc/api/stream.md b/doc/api/stream.md index 1f7f679e3ba5a4..d2e72c3fba405a 100644 --- a/doc/api/stream.md +++ b/doc/api/stream.md @@ -1830,6 +1830,65 @@ for await (const result of dnsResults) { } ``` +### `readable.forEach(fn[, options])` + + + +> Stability: 1 - Experimental + +* `fn` {Function|AsyncFunction} a function to call on each item of the stream. + * `data` {any} a chunk of data from the stream. + * `options` {Object} + * `signal` {AbortSignal} aborted if the stream is destroyed allowing to + abort the `fn` call early. +* `options` {Object} + * `concurrency` {number} the maximal concurrent invocation of `fn` to call + on the stream at once. **Default:** `1`. + * `signal` {AbortSignal} allows destroying the stream if the signal is + aborted. +* Returns: {Promise} a promise for when the stream has finished. + +This method allows iterating a stream. For each item in the stream the +`fn` function will be called. If the `fn` function returns a promise - that +promise will be `await`ed. + +This method is different from `for... await` loops in that it supports setting +the maximal concurrent invocation of `fn` through the `concurrency` option. It +is also possible to `break` from a `for... await` destroying the stream but +`forEach` is only breakable by passing it a `signal` and aborting the related +`AbortController`. + +This method is different from listening to the [`'data'`][] event in that it +uses the [`readable`][] event in the underlying machinary and can limit the +number of concurrent `fn` calls. + +```mjs +import { Readable } from 'stream'; +import { Resolver } from 'dns/promises'; + +// With a synchronous predicate. +for await (const item of Readable.from([1, 2, 3, 4]).filter((x) => x > 2)) { + console.log(item); // 3, 4 +} +// With an asynchronous predicate, making at most 2 queries at a time. +const resolver = new Resolver(); +const dnsResults = await Readable.from([ + 'nodejs.org', + 'openjsf.org', + 'www.linuxfoundation.org', +]).map(async (domain) => { + const { address } = await resolver.resolve4(domain, { ttl: true }); + return address; +}, { concurrency: 2 }); +await dnsResults.forEach((result) => { + // Logs result, similar to `for await (const result of dnsResults)` + console.log(result); +}); +console.log('done'); // Stream has finished +``` + ### Duplex and transform streams #### Class: `stream.Duplex` diff --git a/lib/internal/streams/operators.js b/lib/internal/streams/operators.js index 267cf53740bd7f..3df9b3f4f4fb0a 100644 --- a/lib/internal/streams/operators.js +++ b/lib/internal/streams/operators.js @@ -147,10 +147,23 @@ async function * map(fn, options) { } } +async function forEach(fn, options) { + if (typeof fn !== 'function') { + throw new ERR_INVALID_ARG_TYPE( + 'fn', ['Function', 'AsyncFunction'], this); + } + async function forEachFn(value, options) { + await fn(value, options); + return kEmpty; + } + // eslint-disable-next-line no-unused-vars + for await (const unused of this.map(forEachFn, options)); +} + async function * filter(fn, options) { if (typeof fn !== 'function') { - throw (new ERR_INVALID_ARG_TYPE( - 'fn', ['Function', 'AsyncFunction'], this)); + throw new ERR_INVALID_ARG_TYPE( + 'fn', ['Function', 'AsyncFunction'], this); } async function filterFn(value, options) { if (await fn(value, options)) { @@ -160,7 +173,9 @@ async function * filter(fn, options) { } yield* this.map(filterFn, options); } + module.exports = { + filter, + forEach, map, - filter }; diff --git a/test/parallel/test-stream-forEach.js b/test/parallel/test-stream-forEach.js new file mode 100644 index 00000000000000..16040ad66c0d73 --- /dev/null +++ b/test/parallel/test-stream-forEach.js @@ -0,0 +1,86 @@ +'use strict'; + +const common = require('../common'); +const { + Readable, +} = require('stream'); +const assert = require('assert'); +const { setTimeout } = require('timers/promises'); + +{ + // forEach works on synchronous streams with a synchronous predicate + const stream = Readable.from([1, 2, 3]); + const result = [1, 2, 3]; + (async () => { + await stream.forEach((value) => assert.strictEqual(value, result.shift())); + })().then(common.mustCall()); +} + +{ + // forEach works an asynchronous streams + const stream = Readable.from([1, 2, 3]).filter(async (x) => { + await Promise.resolve(); + return true; + }); + const result = [1, 2, 3]; + (async () => { + await stream.forEach((value) => assert.strictEqual(value, result.shift())); + })().then(common.mustCall()); +} + +{ + // forEach works on asynchronous streams with a asynchronous forEach fn + const stream = Readable.from([1, 2, 3]).filter(async (x) => { + await Promise.resolve(); + return true; + }); + const result = [1, 2, 3]; + (async () => { + await stream.forEach(async (value) => { + await Promise.resolve(); + assert.strictEqual(value, result.shift()); + }); + })().then(common.mustCall()); +} + +{ + // Concurrency + AbortSignal + const ac = new AbortController(); + let calls = 0; + const forEachPromise = + Readable.from([1, 2, 3, 4]).forEach(async (_, { signal }) => { + calls++; + await setTimeout(100, { signal }); + }, { signal: ac.signal, concurrency: 2 }); + // pump + assert.rejects(async () => { + await forEachPromise; + }, { + name: 'AbortError', + }).then(common.mustCall()); + + setImmediate(() => { + ac.abort(); + assert.strictEqual(calls, 2); + }); +} + +{ + // Error cases + assert.rejects(async () => { + Readable.from([1]).forEach(1); + }, /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); + assert.rejects(async () => { + Readable.from([1]).forEach((x) => x, { + concurrency: 'Foo' + }); + }, /ERR_OUT_OF_RANGE/).then(common.mustCall()); + assert.rejects(async () => { + Readable.from([1]).forEach((x) => x, 1); + }, /ERR_INVALID_ARG_TYPE/).then(common.mustCall()); +} +{ + // Test result is a Promise + const stream = Readable.from([1, 2, 3, 4, 5]).forEach((_) => true); + assert.strictEqual(typeof stream.then, 'function'); +}