diff --git a/package.json b/package.json index a2de604..38e8a58 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "type": "module", "exports": { "types": "./source/index.d.ts", + "browser": "./source/exports.js", "default": "./source/index.js" }, "engines": { diff --git a/readme.md b/readme.md index 7f04b94..f328325 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ ## Features -- Works in any JavaScript environment ([Node.js](#nodejs-streams), [browsers](#web-streams), etc.). +- Works in any JavaScript environment ([Node.js](#nodejs-streams), [browsers](#browser-support), etc.). - Supports [text streams](#getstreamstream-options), [binary streams](#getstreamasbufferstream-options) and [object streams](#getstreamasarraystream-options). - Supports [async iterables](#async-iterables). - Can set a [maximum stream size](#maxbuffer). @@ -142,6 +142,16 @@ try { } ``` +## Browser support + +For this module to work in browsers, a bundler must be used that either: +- Supports the [`exports.browser`](https://nodejs.org/api/packages.html#community-conditions-definitions) field in `package.json` +- Strips or ignores `node:*` imports + +Most bundlers (such as [Webpack](https://webpack.js.org/guides/package-exports/#target-environment)) support either of these. + +Additionally, browsers support [web streams](#web-streams) and [async iterables](#async-iterables), but not [Node.js streams](#nodejs-streams). + ## Tips ### Alternatives diff --git a/source/exports.js b/source/exports.js new file mode 100644 index 0000000..43c2dd4 --- /dev/null +++ b/source/exports.js @@ -0,0 +1,5 @@ +export {getStreamAsArray} from './array.js'; +export {getStreamAsArrayBuffer} from './array-buffer.js'; +export {getStreamAsBuffer} from './buffer.js'; +export {getStreamAsString as default} from './string.js'; +export {MaxBufferError} from './contents.js'; diff --git a/source/index.js b/source/index.js index 43c2dd4..61f3ccb 100644 --- a/source/index.js +++ b/source/index.js @@ -1,5 +1,13 @@ -export {getStreamAsArray} from './array.js'; -export {getStreamAsArrayBuffer} from './array-buffer.js'; -export {getStreamAsBuffer} from './buffer.js'; -export {getStreamAsString as default} from './string.js'; -export {MaxBufferError} from './contents.js'; +import {on} from 'node:events'; +import {finished} from 'node:stream/promises'; +import {nodeImports} from './stream.js'; + +Object.assign(nodeImports, {on, finished}); + +export { + default, + getStreamAsArray, + getStreamAsArrayBuffer, + getStreamAsBuffer, + MaxBufferError, +} from './exports.js'; diff --git a/source/stream.js b/source/stream.js index c22e2c3..5f12605 100644 --- a/source/stream.js +++ b/source/stream.js @@ -1,7 +1,7 @@ import {isReadableStream} from 'is-stream'; export const getAsyncIterable = stream => { - if (isReadableStream(stream, {checkOpen: false})) { + if (isReadableStream(stream, {checkOpen: false}) && nodeImports.on !== undefined) { return getStreamIterable(stream); } @@ -14,16 +14,12 @@ export const getAsyncIterable = stream => { // 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) { - await loadNodeImports(); - } - const controller = new AbortController(); const state = {}; handleStreamEnd(stream, controller, state); try { - for await (const [chunk] of nodeImports.events.on(stream, 'data', { + for await (const [chunk] of nodeImports.on(stream, 'data', { signal: controller.signal, highWatermark: stream.readableHighWaterMark, })) { @@ -46,7 +42,7 @@ const getStreamIterable = async function * (stream) { const handleStreamEnd = async (stream, controller, state) => { try { - await nodeImports.streamPromises.finished(stream, {cleanup: true, readable: true, writable: false, error: false}); + await nodeImports.finished(stream, {cleanup: true, readable: true, writable: false, error: false}); } catch (error) { state.error = error; } finally { @@ -54,13 +50,6 @@ const handleStreamEnd = async (stream, controller, state) => { } }; -// Use dynamic imports to support browsers -const loadNodeImports = async () => { - const [events, streamPromises] = await Promise.all([ - import('node:events'), - import('node:stream/promises'), - ]); - nodeImports = {events, streamPromises}; -}; - -let nodeImports; +// Loaded by the Node entrypoint, but not by the browser one. +// This prevents using dynamic imports. +export const nodeImports = {}; diff --git a/test/browser.js b/test/browser.js new file mode 100644 index 0000000..c417064 --- /dev/null +++ b/test/browser.js @@ -0,0 +1,27 @@ +import {execFile} from 'node:child_process'; +import {dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; +import test from 'ava'; +import {fixtureString} from './fixtures/index.js'; + +const pExecFile = promisify(execFile); +const cwd = dirname(fileURLToPath(import.meta.url)); +const nodeStreamFixture = './fixtures/node-stream.js'; +const webStreamFixture = './fixtures/web-stream.js'; +const iterableFixture = './fixtures/iterable.js'; +const nodeConditions = []; +const browserConditions = ['--conditions=browser']; + +const testEntrypoint = async (t, fixture, conditions, expectedOutput = fixtureString) => { + const {stdout, stderr} = await pExecFile('node', [...conditions, fixture], {cwd}); + t.is(stderr, ''); + t.is(stdout, expectedOutput); +}; + +test('Node entrypoint works with Node streams', testEntrypoint, nodeStreamFixture, nodeConditions, `${fixtureString}${fixtureString}`); +test('Browser entrypoint works with Node streams', testEntrypoint, nodeStreamFixture, browserConditions); +test('Node entrypoint works with web streams', testEntrypoint, webStreamFixture, nodeConditions); +test('Browser entrypoint works with web streams', testEntrypoint, webStreamFixture, browserConditions); +test('Node entrypoint works with async iterables', testEntrypoint, iterableFixture, nodeConditions); +test('Browser entrypoint works with async iterables', testEntrypoint, iterableFixture, browserConditions); diff --git a/test/fixtures/iterable.js b/test/fixtures/iterable.js new file mode 100644 index 0000000..c3bf300 --- /dev/null +++ b/test/fixtures/iterable.js @@ -0,0 +1,11 @@ +import process from 'node:process'; +import getStream from 'get-stream'; +import {createStream} from '../helpers/index.js'; +import {fixtureString} from './index.js'; + +const generator = async function * () { + yield fixtureString; +}; + +const stream = createStream(generator); +process.stdout.write(await getStream(stream)); diff --git a/test/fixtures/node-stream.js b/test/fixtures/node-stream.js new file mode 100644 index 0000000..6dba823 --- /dev/null +++ b/test/fixtures/node-stream.js @@ -0,0 +1,8 @@ +import process from 'node:process'; +import getStream from 'get-stream'; +import {createStream} from '../helpers/index.js'; +import {fixtureString} from './index.js'; + +const stream = createStream([fixtureString]); +const [output, secondOutput] = await Promise.all([getStream(stream), getStream(stream)]); +process.stdout.write(`${output}${secondOutput}`); diff --git a/test/fixtures/web-stream.js b/test/fixtures/web-stream.js new file mode 100644 index 0000000..bdfcdcc --- /dev/null +++ b/test/fixtures/web-stream.js @@ -0,0 +1,7 @@ +import process from 'node:process'; +import getStream from 'get-stream'; +import {readableStreamFrom} from '../helpers/index.js'; +import {fixtureString} from './index.js'; + +const stream = readableStreamFrom([fixtureString]); +process.stdout.write(await getStream(stream)); diff --git a/test/helpers/index.js b/test/helpers/index.js index aab1bcf..8525ed9 100644 --- a/test/helpers/index.js +++ b/test/helpers/index.js @@ -4,6 +4,17 @@ 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';