From 4d233d367bff8bc8b13d5e47cb71309bf51b7004 Mon Sep 17 00:00:00 2001 From: ehmicky Date: Fri, 15 Mar 2024 06:01:45 +0000 Subject: [PATCH] Fix browser support (#122) --- package.json | 1 + readme.md | 2 ++ source/stream.js | 14 +++++++-- source/web-stream.js | 13 ++++++++ test/helpers/index.js | 14 +++++++++ test/stream.js | 3 +- test/web-stream-ponyfill.js | 13 ++++++++ test/web-stream.js | 63 +++++++++++++++++++++++++++++++++++++ 8 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 source/web-stream.js create mode 100644 test/web-stream-ponyfill.js create mode 100644 test/web-stream.js diff --git a/package.json b/package.json index a2de604..9b2786f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "concat" ], "dependencies": { + "@sec-ant/readable-stream": "^0.3.2", "is-stream": "^4.0.1" }, "devDependencies": { diff --git a/readme.md b/readme.md index 7f04b94..e4d3d6b 100644 --- a/readme.md +++ b/readme.md @@ -60,6 +60,8 @@ const {body: readableStream} = await fetch('https://example.com'); console.log(await getStream(readableStream)); ``` +This works in any browser, even [the ones](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#browser_compatibility) not supporting `ReadableStream.values()` yet. + ### Async iterables ```js diff --git a/source/stream.js b/source/stream.js index c22e2c3..dc2dab0 100644 --- a/source/stream.js +++ b/source/stream.js @@ -1,17 +1,25 @@ import {isReadableStream} from 'is-stream'; +import {ponyfill} from './web-stream.js'; export const getAsyncIterable = stream => { if (isReadableStream(stream, {checkOpen: false})) { return getStreamIterable(stream); } - if (typeof stream?.[Symbol.asyncIterator] !== 'function') { - throw new TypeError('The first argument must be a Readable, a ReadableStream, or an async iterable.'); + if (typeof stream?.[Symbol.asyncIterator] === 'function') { + return stream; } - return stream; + // `ReadableStream[Symbol.asyncIterator]` support is missing in multiple browsers, so we ponyfill it + if (toString.call(stream) === '[object ReadableStream]') { + return ponyfill.asyncIterator.call(stream); + } + + throw new TypeError('The first argument must be a Readable, a ReadableStream, or an async iterable.'); }; +const {toString} = Object.prototype; + // The default iterable for Node.js streams does not allow for multiple readers at once, so we re-implement it const getStreamIterable = async function * (stream) { if (nodeImports === undefined) { diff --git a/source/web-stream.js b/source/web-stream.js new file mode 100644 index 0000000..4038d61 --- /dev/null +++ b/source/web-stream.js @@ -0,0 +1,13 @@ +export const ponyfill = {}; + +const {prototype} = ReadableStream; + +// Use this library as a ponyfill instead of a polyfill. +// I.e. avoid modifying global variables. +// We can remove this once https://github.com/Sec-ant/readable-stream/issues/2 is fixed +if (prototype[Symbol.asyncIterator] === undefined && prototype.values === undefined) { + await import('@sec-ant/readable-stream'); + ponyfill.asyncIterator = prototype[Symbol.asyncIterator]; + delete prototype[Symbol.asyncIterator]; + delete prototype.values; +} diff --git a/test/helpers/index.js b/test/helpers/index.js index aab1bcf..353cae1 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -1,9 +1,23 @@ import {Duplex, Readable} from 'node:stream'; +import {finished} from 'node:stream/promises'; export const createStream = streamDef => typeof streamDef === 'function' ? Duplex.from(streamDef) : Readable.from(streamDef); +// @todo Use ReadableStream.from() after dropping support for Node 18 +export const readableStreamFrom = chunks => new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + + controller.close(); + }, +}); + // Tests related to big buffers/strings can be slow. We run them serially and // with a higher timeout to ensure they do not randomly fail. export const BIG_TEST_DURATION = '2m'; + +export const onFinishedStream = stream => finished(stream, {cleanup: true}); diff --git a/test/stream.js b/test/stream.js index b5e30ef..ce6b104 100644 --- a/test/stream.js +++ b/test/stream.js @@ -1,14 +1,13 @@ import {once} from 'node:events'; import {version} from 'node:process'; import {Readable, Duplex} from 'node:stream'; -import {finished} from 'node:stream/promises'; import {scheduler, setTimeout as pSetTimeout} from 'node:timers/promises'; import test from 'ava'; import onetime from 'onetime'; import getStream, {getStreamAsArray, MaxBufferError} from '../source/index.js'; import {fixtureString, fixtureMultiString, prematureClose} from './fixtures/index.js'; +import {onFinishedStream} from './helpers/index.js'; -const onFinishedStream = stream => finished(stream, {cleanup: true}); const noopMethods = {read() {}, write() {}}; // eslint-disable-next-line max-params diff --git a/test/web-stream-ponyfill.js b/test/web-stream-ponyfill.js new file mode 100644 index 0000000..65209dc --- /dev/null +++ b/test/web-stream-ponyfill.js @@ -0,0 +1,13 @@ +import test from 'ava'; + +// Emulate browsers that do not support those methods +delete ReadableStream.prototype.values; +delete ReadableStream.prototype[Symbol.asyncIterator]; + +// Run those tests, but emulating browsers +await import('./web-stream.js'); + +test('Should not polyfill ReadableStream', t => { + t.is(ReadableStream.prototype.values, undefined); + t.is(ReadableStream.prototype[Symbol.asyncIterator], undefined); +}); diff --git a/test/web-stream.js b/test/web-stream.js new file mode 100644 index 0000000..1e94c3d --- /dev/null +++ b/test/web-stream.js @@ -0,0 +1,63 @@ +import test from 'ava'; +import getStream from '../source/index.js'; +import {fixtureString, fixtureMultiString} from './fixtures/index.js'; +import {readableStreamFrom, onFinishedStream} from './helpers/index.js'; + +test('Can use ReadableStream', async t => { + const stream = readableStreamFrom(fixtureMultiString); + t.is(await getStream(stream), fixtureString); + await onFinishedStream(stream); +}); + +test('Can use already ended ReadableStream', async t => { + const stream = readableStreamFrom(fixtureMultiString); + t.is(await getStream(stream), fixtureString); + t.is(await getStream(stream), ''); + await onFinishedStream(stream); +}); + +test('Can use already canceled ReadableStream', async t => { + let canceledValue; + const stream = new ReadableStream({ + cancel(canceledError) { + canceledValue = canceledError; + }, + }); + const error = new Error('test'); + await stream.cancel(error); + t.is(canceledValue, error); + t.is(await getStream(stream), ''); + await onFinishedStream(stream); +}); + +test('Can use already errored ReadableStream', async t => { + const error = new Error('test'); + const stream = new ReadableStream({ + start(controller) { + controller.error(error); + }, + }); + t.is(await t.throwsAsync(getStream(stream)), error); + t.is(await t.throwsAsync(onFinishedStream(stream)), error); +}); + +test('Cancel ReadableStream when maxBuffer is hit', async t => { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(fixtureString); + controller.enqueue(fixtureString); + controller.close(); + }, + cancel() { + canceled = true; + }, + }); + const error = await t.throwsAsync( + getStream(stream, {maxBuffer: 1}), + {message: /maxBuffer exceeded/}, + ); + t.deepEqual(error.bufferedData, fixtureString[0]); + await onFinishedStream(stream); + t.true(canceled); +});