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

Add stdin: ReadableStream option #615

Merged
merged 2 commits into from
Dec 17, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 9 additions & 8 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type Buffer} from 'node:buffer';
import {type ChildProcess} from 'node:child_process';
import {type Stream, type Readable as ReadableStream, type Writable as WritableStream} from 'node:stream';
import {type Stream, type Readable, type Writable} from 'node:stream';

export type StdioOption =
| 'pipe'
Expand All @@ -17,7 +17,8 @@ export type StdinOption =
| Iterable<string | Uint8Array>
| AsyncIterable<string | Uint8Array>
| URL
| string;
| string
| ReadableStream;

type EncodingOption =
| 'utf8'
Expand Down Expand Up @@ -93,7 +94,7 @@ export type CommonOptions<EncodingType extends EncodingOption = DefaultEncodingO
/**
Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).

It can also be a file path, a file URL, an [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol) or an [`AsyncIterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols), providing neither [`execaSync()`](#execasyncfile-arguments-options), the [`input` option](#input) nor the [`inputFile` option](#inputfile) is used. If the file path is relative, it must start with `.`.
It can also be a file path, a file URL, a web stream ([`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)) an [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol) or an [`AsyncIterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols), providing neither [`execaSync()`](#execasyncfile-arguments-options), the [`input` option](#input) nor the [`inputFile` option](#inputfile) is used. If the file path is relative, it must start with `.`.

@default `inherit` with `$`, `pipe` otherwise
*/
Expand Down Expand Up @@ -291,7 +292,7 @@ export type Options<EncodingType extends EncodingOption = DefaultEncodingOption>

If the input is a file, use the `inputFile` option instead.
*/
readonly input?: string | Uint8Array | ReadableStream;
readonly input?: string | Uint8Array | Readable;

/**
Use a file as input to the the `stdin` of your binary.
Expand Down Expand Up @@ -488,7 +489,7 @@ export type ExecaChildPromise<StdoutStderrType extends StdoutStderrAll> = {
- the `all` option is `false` (the default value)
- both `stdout` and `stderr` options are set to [`'inherit'`, `'ipc'`, `Stream` or `integer`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio)
*/
all?: ReadableStream;
all?: Readable;

catch<ResultType = never>(
onRejected?: (reason: ExecaError<StdoutStderrType>) => ResultType | PromiseLike<ResultType>
Expand All @@ -515,23 +516,23 @@ export type ExecaChildPromise<StdoutStderrType extends StdoutStderrAll> = {
The `stdout` option] must be kept as `pipe`, its default value.
*/
pipeStdout?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
pipeStdout?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
pipeStdout?(target: Writable | string): ExecaChildProcess<StdoutStderrType>;

/**
Like `pipeStdout()` but piping the child process's `stderr` instead.

The `stderr` option must be kept as `pipe`, its default value.
*/
pipeStderr?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
pipeStderr?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
pipeStderr?(target: Writable | string): ExecaChildProcess<StdoutStderrType>;

/**
Combines both `pipeStdout()` and `pipeStderr()`.

Either the `stdout` option or the `stderr` option must be kept as `pipe`, their default value. Also, the `all` option must be set to `true`.
*/
pipeAll?<Target extends ExecaChildPromise<StdoutStderrAll>>(target: Target): Target;
pipeAll?(target: WritableStream | string): ExecaChildProcess<StdoutStderrType>;
pipeAll?(target: Writable | string): ExecaChildProcess<StdoutStderrType>;
};

export type ExecaChildProcess<StdoutStderrType extends StdoutStderrAll = string> = ChildProcess &
Expand Down
5 changes: 3 additions & 2 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Buffer} from 'node:buffer';
// `process.stdin`, `process.stderr`, and `process.stdout`
// to get treated as `any` by `@typescript-eslint/no-unsafe-assignment`.
import * as process from 'node:process';
import {type Readable as ReadableStream} from 'node:stream';
import {type Readable} from 'node:stream';
import {createWriteStream} from 'node:fs';
import {expectType, expectError, expectAssignable} from 'tsd';
import {
Expand All @@ -23,7 +23,7 @@ import {
try {
const execaPromise = execa('unicorns');
execaPromise.cancel();
expectType<ReadableStream | undefined>(execaPromise.all);
expectType<Readable | undefined>(execaPromise.all);

const execaBufferPromise = execa('unicorns', {encoding: 'buffer'});
const writeStream = createWriteStream('output.txt');
Expand Down Expand Up @@ -158,6 +158,7 @@ execa('unicorns', {stdin: 'ipc'});
execa('unicorns', {stdin: 'ignore'});
execa('unicorns', {stdin: 'inherit'});
execa('unicorns', {stdin: process.stdin});
execa('unicorns', {stdin: new ReadableStream()});
execa('unicorns', {stdin: ['']});
execa('unicorns', {stdin: [new Uint8Array(0)]});
execa('unicorns', {stdin: stringGenerator()});
Expand Down
22 changes: 17 additions & 5 deletions lib/stdio.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {createReadStream, readFileSync} from 'node:fs';
import {isAbsolute} from 'node:path';
import {Readable} from 'node:stream';
import {isStream} from 'is-stream';
import {isStream as isNodeStream} from 'is-stream';

const aliases = ['stdin', 'stdout', 'stderr'];

const arrifyStdio = (stdio = []) => Array.isArray(stdio) ? stdio : [stdio, stdio, stdio];

const isIterableStdin = stdinOption => typeof stdinOption === 'object'
&& stdinOption !== null
&& !isStream(stdinOption)
&& !isNodeStream(stdinOption)
&& !isReadableStream(stdinOption)
&& (typeof stdinOption[Symbol.asyncIterator] === 'function' || typeof stdinOption[Symbol.iterator] === 'function');

const getIterableStdin = stdioArray => isIterableStdin(stdioArray[0])
Expand All @@ -25,10 +26,13 @@ const stringIsFilePath = stdioOption => stdioOption.startsWith('.') || isAbsolut
const isFilePath = stdioOption => typeof stdioOption === 'string' && stringIsFilePath(stdioOption);
const isUnknownStdioString = stdioOption => typeof stdioOption === 'string' && !stringIsFilePath(stdioOption) && !KNOWN_STDIO.has(stdioOption);

const isReadableStream = stdioOption => Object.prototype.toString.call(stdioOption) === '[object ReadableStream]';

// Check whether the `stdin` option results in `spawned.stdin` being `undefined`.
// We use a deny list instead of an allow list to be forward compatible with new options.
const cannotPipeStdio = stdioOption => NO_PIPE_STDIO.has(stdioOption)
|| isStream(stdioOption)
|| isNodeStream(stdioOption)
|| isReadableStream(stdioOption)
|| typeof stdioOption === 'number'
|| isIterableStdin(stdioOption)
|| isFileUrl(stdioOption)
Expand Down Expand Up @@ -72,6 +76,10 @@ const getStdioStreams = (stdioArray, {input, inputFile}) => {
return {stdinStream: Readable.from(iterableStdin)};
}

if (isReadableStream(stdioArray[0])) {
return {stdinStream: Readable.fromWeb(stdioArray[0])};
}

if (isFileUrl(stdioArray[0]) || isFilePath(stdioArray[0])) {
return {stdinStream: createReadStream(stdioArray[0])};
}
Expand All @@ -84,7 +92,7 @@ const getStdioStreams = (stdioArray, {input, inputFile}) => {
return {};
}

if (isStream(input)) {
if (isNodeStream(input)) {
return {stdinStream: input};
}

Expand Down Expand Up @@ -136,7 +144,11 @@ const validateInputOptionsSync = (stdioArray, input) => {
throw new TypeError('The `stdin` option cannot be an iterable in sync mode');
}

if (isStream(input)) {
if (isReadableStream(stdioArray[0])) {
throw new TypeError('The `stdin` option cannot be a stream in sync mode');
}

if (isNodeStream(input)) {
throw new TypeError('The `input` option cannot be a stream in sync mode');
}
};
Expand Down
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,12 +559,12 @@ If the input is not a file, use the [`input` option](#input) instead.

#### stdin

Type: `string | number | Stream | undefined | URL | Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>`\
Type: `string | number | stream.Readable | ReadableStream | undefined | URL | Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>`\
Default: `inherit` with [`$`](#command), `pipe` otherwise

Same options as [`stdio`](https://nodejs.org/dist/latest-v6.x/docs/api/child_process.html#child_process_options_stdio).

It can also be a file path, a file URL, an [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol) or an [`AsyncIterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols), providing neither [`execaSync()`](#execasyncfile-arguments-options), the [`input` option](#input) nor the [`inputFile` option](#inputfile) is used. If the file path is relative, it must start with `.`.
It can also be a file path, a file URL, a web stream ([`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)) an [`Iterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol) or an [`AsyncIterable`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols), providing neither [`execaSync()`](#execasyncfile-arguments-options), the [`input` option](#input) nor the [`inputFile` option](#inputfile) is used. If the file path is relative, it must start with `.`.

#### stdout

Expand Down
42 changes: 33 additions & 9 deletions test/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,15 @@ test('input option can be a Buffer', async t => {
t.is(stdout, 'testing12');
});

test('input can be a Stream', async t => {
test('input can be a Node.js Readable', async t => {
const stream = new Stream.PassThrough();
stream.write('howdy');
stream.end();
const {stdout} = await execa('stdin.js', {input: stream});
t.is(stdout, 'howdy');
});

test('input option cannot be a Stream when stdin is set', t => {
test('input option cannot be a Node.js Readable when stdin is set', t => {
t.throws(() => {
execa('stdin.js', {input: new Stream.PassThrough(), stdin: 'ignore'});
}, {message: /`input` and `stdin` options/});
Expand All @@ -231,6 +231,26 @@ test('input option can be used with $', async t => {
t.is(stdout, 'foobar');
});

test('stdin can be a ReadableStream', async t => {
const stdin = Stream.Readable.toWeb(Stream.Readable.from('howdy'));
const {stdout} = await execa('stdin.js', {stdin});
t.is(stdout, 'howdy');
});

test('stdin cannot be a ReadableStream when input is used', t => {
const stdin = Stream.Readable.toWeb(Stream.Readable.from('howdy'));
t.throws(() => {
execa('stdin.js', {stdin, input: 'foobar'});
}, {message: /`input` and `stdin` options/});
});

test('stdin cannot be a ReadableStream when inputFile is used', t => {
const stdin = Stream.Readable.toWeb(Stream.Readable.from('howdy'));
t.throws(() => {
execa('stdin.js', {stdin, inputFile: 'dummy.txt'});
}, {message: /`inputFile` and `stdin` options/});
});

test('stdin can be a file URL', async t => {
const inputFile = tempfile();
fs.writeFileSync(inputFile, 'howdy');
Expand Down Expand Up @@ -334,13 +354,17 @@ test('opts.stdout:ignore - stdout will not collect data', async t => {
t.is(stdout, undefined);
});

test('helpful error trying to provide an input stream in sync mode', t => {
t.throws(
() => {
execaSync('stdin.js', {input: new Stream.PassThrough()});
},
{message: /The `input` option cannot be a stream in sync mode/},
);
test('input cannot be a stream in sync mode', t => {
t.throws(() => {
execaSync('stdin.js', {input: new Stream.PassThrough()});
}, {message: /The `input` option cannot be a stream in sync mode/});
});

test('stdin cannot be a ReadableStream in sync mode', t => {
const stdin = Stream.Readable.toWeb(Stream.Readable.from('howdy'));
t.throws(() => {
execaSync('stdin.js', {stdin});
}, {message: /The `stdin` option cannot be a stream in sync mode/});
});

test('stdin can be a file URL - sync', t => {
Expand Down