Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix browser support #122

Merged
merged 1 commit into from
Mar 15, 2024
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"concat"
],
"dependencies": {
"@sec-ant/readable-stream": "^0.3.2",
Copy link
Collaborator Author

@ehmicky ehmicky Mar 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dependency is quite small: ~250 lines, no dependencies.

I looked into each implementation, and its behavior is almost identical to both the Node.js implementation, and the 400KB official polyfill for web streams.

"is-stream": "^4.0.1"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions source/stream.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions source/web-stream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const ponyfill = {};
Copy link
Collaborator Author

@ehmicky ehmicky Mar 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sindresorhus Didn't you coin that term? Happy to spread it! 🦄


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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is always false on Node, Firefox and Chrome >124. On those platforms, this file is a noop.

await import('@sec-ant/readable-stream');
ponyfill.asyncIterator = prototype[Symbol.asyncIterator];
delete prototype[Symbol.asyncIterator];
delete prototype.values;
Copy link
Collaborator Author

@ehmicky ehmicky Mar 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not polyfilling ensures we don't get issues for users that use the "official" web streams polyfill. Also, this avoids any global side effects.

The author of the polyfill is working on turning it into a ponyfill. When that happens, we can remove this file entirely.

}
14 changes: 14 additions & 0 deletions test/helpers/index.js
Original file line number Diff line number Diff line change
@@ -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});
3 changes: 1 addition & 2 deletions test/stream.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/web-stream-ponyfill.js
Original file line number Diff line number Diff line change
@@ -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);
});
63 changes: 63 additions & 0 deletions test/web-stream.js
Original file line number Diff line number Diff line change
@@ -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);
});