Skip to content

Commit 9fbf5ba

Browse files
authored
Generalize {value, input} to any direction-ambiguous stdio value (#1249)
1 parent 4b0edb3 commit 9fbf5ba

14 files changed

Lines changed: 153 additions & 32 deletions

File tree

‎docs/api.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -916,14 +916,14 @@ More info on [available values](output.md), [streaming](streams.md) and [transfo
916916
### options.stdio
917917

918918
_TypeScript:_ [`Options['stdio']`](typescript.md) or [`SyncOptions['stdio']`](typescript.md)\
919-
_Type:_ `string | Array<string | number | stream.Readable | stream.Writable | ReadableStream | WritableStream | TransformStream | URL | {file: string} | Uint8Array | Iterable<string> | Iterable<Uint8Array> | Iterable<unknown> | AsyncIterable<string | Uint8Array | unknown> | GeneratorFunction<string | Uint8Array | unknown> | AsyncGeneratorFunction<string | Uint8Array | unknown> | {transform: GeneratorFunction | AsyncGeneratorFunction | Duplex | TransformStream} | {value: 'pipe'; input?: boolean}>` (or a tuple of those types)\
919+
_Type:_ `string | Array<string | number | stream.Readable | stream.Writable | ReadableStream | WritableStream | TransformStream | URL | {file: string} | Uint8Array | Iterable<string> | Iterable<Uint8Array> | Iterable<unknown> | AsyncIterable<string | Uint8Array | unknown> | GeneratorFunction<string | Uint8Array | unknown> | AsyncGeneratorFunction<string | Uint8Array | unknown> | {transform: GeneratorFunction | AsyncGeneratorFunction | Duplex | TransformStream} | {value: 'pipe' | 'inherit' | number | URL | {file: string} | GeneratorFunction<string | Uint8Array | unknown> | AsyncGeneratorFunction<string | Uint8Array | unknown> | {transform: GeneratorFunction | AsyncGeneratorFunction | Duplex | TransformStream}; input?: boolean}>` (or a tuple of those types)\
920920
_Default:_ `pipe`
921921

922922
Like the [`stdin`](#optionsstdin), [`stdout`](#optionsstdout) and [`stderr`](#optionsstderr) options but for all [file descriptors](https://en.wikipedia.org/wiki/File_descriptor) at once. For example, `{stdio: ['ignore', 'pipe', 'pipe']}` is the same as `{stdin: 'ignore', stdout: 'pipe', stderr: 'pipe'}`.
923923

924924
A single string can be used [as a shortcut](output.md#shortcut).
925925

926-
The array can have more than 3 items, to create [additional file descriptors](output.md#additional-file-descriptors) beyond [`stdin`](#optionsstdin)/[`stdout`](#optionsstdout)/[`stderr`](#optionsstderr). Passing `'pipe'` to an additional file descriptor defaults it to output. To use it for [input](input.md#additional-file-descriptors) instead, pass `{value: 'pipe', input: true}`.
926+
The array can have more than 3 items, to create [additional file descriptors](output.md#additional-file-descriptors) beyond [`stdin`](#optionsstdin)/[`stdout`](#optionsstdout)/[`stderr`](#optionsstderr). Some values have an ambiguous direction on an additional file descriptor (like `'pipe'`, `'inherit'`, a file or a transform), so they default to output. To use one for [input](input.md#additional-file-descriptors) instead, wrap it as `{value, input: true}`.
927927

928928
More info on [available values](output.md), [streaming](streams.md) and [transforms](transform.md).
929929

‎docs/input.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ await execa({
135135
})`npm run build`;
136136
```
137137

138-
When passing `'pipe'` to an additional file descriptor, Execa cannot tell whether it is meant for input or output, so it defaults to output. To use it for input instead, such as when [streaming](streams.md#different-file-descriptor) to it, pass `{value: 'pipe', input: true}`.
138+
Some values have an ambiguous direction on an additional file descriptor: Execa cannot tell whether they are meant for input or output, so they default to output. This applies to `'pipe'`, `'inherit'`, [files](output.md#file-output) and [transforms](transform.md). To use one for input instead, such as when [streaming](streams.md#different-file-descriptor) to it, wrap it as `{value, input: true}`.
139139

140140
```js
141141
import {pipeline} from 'node:stream/promises';

‎lib/stdio/direction.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,18 @@ export const getStreamDirection = (stdioItems, fdNumber, optionName) => {
2222

2323
// `direction` is set when the user explicitly requests it, e.g. `{value: 'pipe', input: true}`.
2424
// It takes precedence over guessing, but not over the fixed direction of `stdin`/`stdout`/`stderr`.
25-
const getStdioItemDirection = ({type, value, direction}, fdNumber) => KNOWN_DIRECTIONS[fdNumber] ?? direction ?? guessStreamDirection[type](value);
25+
const getStdioItemDirection = (stdioItem, fdNumber) => KNOWN_DIRECTIONS[fdNumber] ?? getRequestedDirection(stdioItem);
26+
27+
// The explicitly requested `direction` must not contradict a value that is intrinsically the other direction.
28+
// For example, `{value: writableStream, input: true}` is invalid since a writable value can only be an output.
29+
const getRequestedDirection = ({type, value, direction, optionName}) => {
30+
const guessedDirection = guessStreamDirection[type](value);
31+
if (direction === 'input' && guessedDirection === 'output') {
32+
throw new TypeError(`The \`${optionName}\` option is invalid: \`input: true\` cannot be used with a writable value, which is always an output.`);
33+
}
34+
35+
return direction ?? guessedDirection;
36+
};
2637

2738
// `stdin`/`stdout`/`stderr` have a known direction
2839
const KNOWN_DIRECTIONS = ['input', 'output', 'output'];

‎lib/stdio/handle.js‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
getStdioItemType,
66
isRegularUrl,
77
isUnknownStdioString,
8-
isPipeInputObject,
8+
isStdioValueObject,
99
checkBooleanOption,
1010
FILE_TYPES,
1111
} from './type.js';
@@ -85,8 +85,8 @@ const isInheritedStdinOnly = stdioItems => stdioItems.length === 1
8585
&& stdioItems[0].value === 'inherit';
8686

8787
const initializeStdioItem = (value, optionName) => {
88-
if (isPipeInputObject(value)) {
89-
return initializePipeInputItem(value, optionName);
88+
if (isStdioValueObject(value)) {
89+
return initializeStdioValueObject(value, optionName);
9090
}
9191

9292
return {
@@ -96,12 +96,12 @@ const initializeStdioItem = (value, optionName) => {
9696
};
9797
};
9898

99-
// The `{value: 'pipe', input: true}` object form is a native pipe, plus an explicit input direction.
100-
const initializePipeInputItem = ({value, input}, optionName) => {
99+
// The `{value, input}` object form wraps any stdio value with an explicit direction.
100+
const initializeStdioValueObject = ({value, input}, optionName) => {
101101
checkBooleanOption(input, `${optionName}.input`);
102102

103103
return {
104-
type: 'native',
104+
type: getStdioItemType(value, optionName),
105105
value,
106106
direction: input ? 'input' : undefined,
107107
optionName,

‎lib/stdio/stdio-option.js‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {STANDARD_STREAMS_ALIASES} from '../utils/standard-stream.js';
22
import {normalizeIpcStdioArray} from '../ipc/array.js';
33
import {isFullVerbose} from '../verbose/values.js';
4-
import {isPipeInputObject} from './type.js';
4+
import {isStdioValueObject} from './type.js';
55

66
// Add support for `stdin`/`stdout`/`stderr` as an alias for `stdio`.
77
// Also normalize the `stdio` option.
@@ -15,11 +15,27 @@ export const normalizeStdioOption = ({stdio, ipc, buffer, ...options}, verboseIn
1515

1616
// The `stdio: 'ipc'` value (raw `child_process` syntax) was replaced by the `ipc: true` option
1717
const validateIpcStdioOption = stdioArray => {
18-
if (stdioArray.some(stdioOption => Array.isArray(stdioOption) ? stdioOption.includes('ipc') : stdioOption === 'ipc')) {
18+
if (stdioArray.some(stdioOption => hasIpcStdioOption(stdioOption))) {
1919
throw new Error('The `ipc: true` option must be used instead of `stdio: \'ipc\'`.');
2020
}
2121
};
2222

23+
const hasIpcStdioOption = stdioOption => {
24+
if (Array.isArray(stdioOption)) {
25+
return stdioOption.some(item => hasIpcStdioItem(item));
26+
}
27+
28+
return hasIpcStdioItem(stdioOption);
29+
};
30+
31+
const hasIpcStdioItem = stdioOption => {
32+
if (isStdioValueObject(stdioOption)) {
33+
return stdioOption.value === 'ipc';
34+
}
35+
36+
return stdioOption === 'ipc';
37+
};
38+
2339
const getStdioArray = (stdio, options) => {
2440
if (stdio === undefined) {
2541
return STANDARD_STREAMS_ALIASES.map(alias => options[alias]);
@@ -71,7 +87,7 @@ const isOutputPipeOnly = (stdioOption, fdNumber) => isOutputPipe(stdioOption, fd
7187
const isOutputPipe = (stdioOption, fdNumber) => stdioOption === 'pipe'
7288
|| isOutputPipeObject(stdioOption, fdNumber);
7389

74-
const isOutputPipeObject = (stdioOption, fdNumber) => isPipeInputObject(stdioOption)
90+
const isOutputPipeObject = (stdioOption, fdNumber) => isStdioValueObject(stdioOption)
7591
&& stdioOption.value === 'pipe'
7692
&& (stdioOption.input === undefined || stdioOption.input === false || isFixedOutputPipe(fdNumber, stdioOption.input));
7793

‎lib/stdio/type.js‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,13 @@ const isFilePathObject = value => isPlainObj(value)
130130
const FILE_PATH_KEYS = new Set(['file', 'append']);
131131
export const isFilePathString = file => typeof file === 'string';
132132

133-
// The `{value: 'pipe', input: true}` object form marks an additional file descriptor as input instead of output.
134-
// This is only needed for `'pipe'`/`'overlapped'` on fd3+, whose direction is otherwise ambiguous and defaults to output.
135-
export const isPipeInputObject = value => isPlainObj(value)
133+
// The `{value, input}` object form marks an additional file descriptor's direction explicitly.
134+
// This is needed for direction-ambiguous values (like `'pipe'`, `'inherit'`, a file or a transform) on fd3+, which otherwise default to output.
135+
export const isStdioValueObject = value => isPlainObj(value)
136136
&& Object.keys(value).length > 0
137-
&& Object.keys(value).every(key => PIPE_INPUT_KEYS.has(key))
138-
&& NATIVE_PIPE_VALUES.has(value.value);
139-
const PIPE_INPUT_KEYS = new Set(['value', 'input']);
140-
const NATIVE_PIPE_VALUES = new Set(['pipe', 'overlapped']);
137+
&& Object.keys(value).every(key => STDIO_VALUE_KEYS.has(key))
138+
&& 'value' in value;
139+
const STDIO_VALUE_KEYS = new Set(['value', 'input']);
141140

142141
export const isUnknownStdioString = (type, value) => type === 'native'
143142
&& typeof value === 'string'

‎test-d/return/result-stdio.test-d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ expectType<undefined>(inputFd3Result.stdio[3]);
114114
const inputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input: true}]});
115115
expectType<undefined>(inputPipeFd3Result.stdio[3]);
116116

117+
const inputInheritFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'inherit', input: true}]});
118+
expectType<undefined>(inputInheritFd3Result.stdio[3]);
119+
120+
const inputFdFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 3, input: true}]});
121+
expectType<undefined>(inputFdFd3Result.stdio[3]);
122+
117123
const outputPipeFd3Result = await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe'}]});
118124
expectType<string>(outputPipeFd3Result.stdio[3]);
119125

‎test-d/stdio/option/input-pipe.test-d.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {Writable} from 'node:stream';
12
import {expectError, expectAssignable} from 'tsd';
23
import {
34
execa,
@@ -11,6 +12,9 @@ import {
1112
const inputPipe = {value: 'pipe', input: true} as const;
1213
const input = true as boolean;
1314
const booleanInputPipe = {value: 'pipe', input} as const;
15+
const transform = function * (line: string) {
16+
yield line;
17+
};
1418

1519
await execa('unicorns', {stdin: inputPipe});
1620
execaSync('unicorns', {stdin: inputPipe});
@@ -31,6 +35,17 @@ expectError(execaSync('unicorns', {stdin: {value: 'overlapped', input: true}}));
3135
expectError(await execa('unicorns', {stdin: {value: 'other', input: true}}));
3236
expectError(await execa('unicorns', {stdin: {value: 'pipe', input: 'true'}}));
3337

38+
// The `{value, input}` form works with any direction-ambiguous value, not just `'pipe'`/`'overlapped'`.
39+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'inherit', input: true}]});
40+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: new URL('file:///test'), input: true}]});
41+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: {file: './example'}, input: true}]});
42+
await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: transform, input: true}]});
43+
expectAssignable<StdinOption>({value: 'inherit', input: true});
44+
expectAssignable<StdinOption>({value: {file: './example'}, input: true});
45+
46+
// A value with a fixed direction cannot be marked as input.
47+
expectError(await execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: new Writable(), input: true}]}));
48+
3449
expectAssignable<StdinOption>(inputPipe);
3550
expectAssignable<StdinOption>(booleanInputPipe);
3651
expectAssignable<StdinSyncOption>(inputPipe);

‎test-d/subprocess/stdio.test-d.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ expectType<Writable>(inputPipeSubprocess.stdio[3]);
4646
const outputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe'}]});
4747
expectType<Readable>(outputPipeSubprocess.stdio[3]);
4848

49+
const inputInheritSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'inherit', input: true}]});
50+
expectType<null>(inputInheritSubprocess.stdio[3]);
51+
52+
const inputFdSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 3, input: true}]});
53+
expectType<null>(inputFdSubprocess.stdio[3]);
54+
55+
const inputFileSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: {file: './example'}, input: true}]});
56+
expectType<Writable>(inputFileSubprocess.stdio[3]);
57+
4958
const input = true as boolean;
5059
const booleanInputPipeSubprocess = execa('unicorns', {stdio: ['pipe', 'pipe', 'pipe', {value: 'pipe', input}]});
5160
expectType<Readable | Writable>(booleanInputPipeSubprocess.stdio[3]);

‎test/stdio/direction.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,36 @@ const testDirectionInputPipe = async (t, stdioOption) => {
6060
test('stdio[*] { value: "pipe", input: true } sets the direction to input', testDirectionInputPipe, {value: 'pipe', input: true});
6161
test('stdio[*] { value: "overlapped", input: true } sets the direction to input', testDirectionInputPipe, {value: 'overlapped', input: true});
6262

63+
const uppercaseGenerator = function * (line) {
64+
yield line.toUpperCase();
65+
};
66+
67+
test('stdio[*] { value: generator, input: true } sets the direction to input', async t => {
68+
const subprocess = execa('stdin-fd.js', ['3'], getStdio(3, {value: uppercaseGenerator, input: true}));
69+
subprocess.stdio[3].end(foobarString);
70+
const {stdout} = await subprocess;
71+
t.is(stdout, foobarString.toUpperCase());
72+
});
73+
74+
test('stdio[*] { value: {file}, input: true } sets the direction to input', async t => {
75+
const filePath = tempfile();
76+
await writeFile(filePath, foobarString);
77+
const {stdout} = await execa('stdin-fd.js', ['3'], getStdio(3, {value: {file: filePath}, input: true}));
78+
t.is(stdout, foobarString);
79+
await rm(filePath);
80+
});
81+
82+
const testInputFixedDirection = (t, stdioOption, execaMethod) => {
83+
t.throws(() => {
84+
execaMethod('empty.js', getStdio(3, {value: stdioOption, input: true}));
85+
}, {message: /cannot be used with a writable value/});
86+
};
87+
88+
test('stdio[*] { value: WritableStream, input: true } is invalid', testInputFixedDirection, new WritableStream(), execa);
89+
test('stdio[*] { value: WritableStream, input: true } is invalid - sync', testInputFixedDirection, new WritableStream(), execaSync);
90+
test('stdio[*] { value: process.stdout, input: true } is invalid', testInputFixedDirection, process.stdout, execa);
91+
test('stdio[*] { value: process.stdout, input: true } is invalid - sync', testInputFixedDirection, process.stdout, execaSync);
92+
6393
test('stdio[*] { value: "pipe", input: true } cannot be used in sync mode', t => {
6494
t.throws(() => {
6595
execaSync('empty.js', getStdio(3, {value: 'pipe', input: true}));

0 commit comments

Comments
 (0)