Repository: sindresorhus/execa
Pull request #610 (2023-12-16 17:37:45)

Allow `stdin` to be a file URL
Part of #592.

This allows the `stdin` option to be a file URL. I will send another PR for `stdout`/`stderr`.

This is an alternative implementation: see #614 for using file path strings instead. Should we allow file URLs, file path strings, or both?
  - File path strings are easier for users.
  - File URLs are more "proper" as they are typed and work in browsers. 
  - Allowing both is what most Node.js core methods do.


Patch:
diff --git a/index.d.ts b/index.d.ts
index 6ce169d..f216dee 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -12,7 +12,7 @@ export type StdioOption =
 	| number
 	| undefined;
 
-export type StdinOption = StdioOption | Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>;
+export type StdinOption = StdioOption | Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array> | URL;
 
 type EncodingOption =
   | 'utf8'
@@ -88,7 +88,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 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.
+	It can also be 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.
 
 	@default `inherit` with `$`, `pipe` otherwise
 	*/
diff --git a/lib/stdio.js b/lib/stdio.js
index 20f8ca8..aac7dfa 100644
--- a/lib/stdio.js
+++ b/lib/stdio.js
@@ -15,15 +15,28 @@ const getIterableStdin = stdioArray => isIterableStdin(stdioArray[0])
 	? stdioArray[0]
 	: undefined;
 
+const isUrlInstance = stdioOption => Object.prototype.toString.call(stdioOption) === '[object URL]';
+const hasFileProtocol = url => url.protocol === 'file:';
+const isFileUrl = stdioOption => isUrlInstance(stdioOption) && hasFileProtocol(stdioOption);
+const isRegularUrl = stdioOption => isUrlInstance(stdioOption) && !hasFileProtocol(stdioOption);
+
 // 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_STDIN.has(stdioOption)
 	|| isStream(stdioOption)
 	|| typeof stdioOption === 'number'
-	|| isIterableStdin(stdioOption);
+	|| isIterableStdin(stdioOption)
+	|| isFileUrl(stdioOption);
 
 const NO_PIPE_STDIN = new Set(['ipc', 'ignore', 'inherit']);
 
+const validateFileUrl = stdioOption => {
+	if (isRegularUrl(stdioOption)) {
+		throw new TypeError(`The \`stdin: URL\` option must use the \`file:\` scheme.
+For example, you can use the \`pathToFileURL()\` method of the \`url\` core module.`);
+	}
+};
+
 const validateInputOptions = (stdioArray, input, inputFile) => {
 	if (input !== undefined && inputFile !== undefined) {
 		throw new TypeError('The `input` and `inputFile` options cannot be both set.');
@@ -37,6 +50,8 @@ const validateInputOptions = (stdioArray, input, inputFile) => {
 	if (noPipeStdin && inputFile !== undefined) {
 		throw new TypeError('The `inputFile` and `stdin` options cannot be both set.');
 	}
+
+	validateFileUrl(stdioArray[0]);
 };
 
 const getStdioStreams = (stdioArray, {input, inputFile}) => {
@@ -46,6 +61,10 @@ const getStdioStreams = (stdioArray, {input, inputFile}) => {
 		return {stdinStream: Readable.from(iterableStdin)};
 	}
 
+	if (isFileUrl(stdioArray[0])) {
+		return {stdinStream: createReadStream(stdioArray[0])};
+	}
+
 	if (inputFile !== undefined) {
 		return {stdinStream: createReadStream(inputFile)};
 	}
@@ -95,6 +114,12 @@ export const pipeStdioOption = (spawned, {stdinStream, stdinInput}) => {
 	}
 };
 
+const transformStdioItemSync = stdioItem => isFileUrl(stdioItem) ? 'pipe' : stdioItem;
+
+const transformStdioSync = stdio => Array.isArray(stdio)
+	? stdio.map(stdioItem => transformStdioItemSync(stdioItem))
+	: stdio;
+
 const validateInputOptionsSync = (stdioArray, input) => {
 	if (getIterableStdin(stdioArray) !== undefined) {
 		throw new TypeError('The `stdin` option cannot be an iterable in sync mode');
@@ -110,6 +135,10 @@ const getInputOption = (stdio, {input, inputFile}) => {
 	validateInputOptions(stdioArray, input, inputFile);
 	validateInputOptionsSync(stdioArray, input);
 
+	if (isFileUrl(stdioArray[0])) {
+		return readFileSync(stdioArray[0]);
+	}
+
 	if (inputFile !== undefined) {
 		return readFileSync(inputFile);
 	}
@@ -126,7 +155,7 @@ export const handleInputOption = options => {
 		options.input = input;
 	}
 
-	options.stdio = stdio;
+	options.stdio = transformStdioSync(stdio);
 };
 
 const hasAlias = options => aliases.some(alias => options[alias] !== undefined);
diff --git a/readme.md b/readme.md
index a5d8610..44864f3 100644
--- a/readme.md
+++ b/readme.md
@@ -559,12 +559,12 @@ If the input is not a file, use the [`input` option](#input) instead.
 
 #### stdin
 
-Type: `string | number | Stream | undefined | Iterable<string | Uint8Array> | AsyncIterable<string | Uint8Array>`\
+Type: `string | number | Stream | 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 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.
+It can also be 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.
 
 #### stdout
 


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 0f5a6de..0b74107 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -164,6 +164,7 @@ execa('unicorns', {stdin: stringGenerator()});
 execa('unicorns', {stdin: binaryGenerator()});
 expectError(execa('unicorns', {stdin: [0]}));
 expectError(execa('unicorns', {stdin: numberGenerator()}));
+execa('unicorns', {stdin: new URL('file:///test')});
 execa('unicorns', {stdin: 1});
 execa('unicorns', {stdin: undefined});
 execa('unicorns', {stdout: 'pipe'});
diff --git a/test/kill.js b/test/kill.js
index 66ff48b..a995e7c 100644
--- a/test/kill.js
+++ b/test/kill.js
@@ -1,4 +1,5 @@
 import process from 'node:process';
+import {setTimeout} from 'node:timers/promises';
 import test from 'ava';
 import {pEvent} from 'p-event';
 import isRunning from 'is-running';
@@ -150,10 +151,16 @@ const spawnAndKill = async (t, [signal, cleanup, detached, isKilled]) => {
 
 	await t.throwsAsync(subprocess);
 
+	// The `cleanup` option can introduce a race condition in this test.
+	// This is especially true when run concurrently, so we use `test.serial()` and a manual timeout.
+	if (signal === 'SIGTERM' && cleanup && !detached) {
+		await setTimeout(1e3);
+	}
+
 	t.false(isRunning(subprocess.pid));
-	t.is(isRunning(pid), !isKilled);
+	t.not(isRunning(pid), isKilled);
 
-	if (isRunning(pid)) {
+	if (!isKilled) {
 		process.kill(pid, 'SIGKILL');
 	}
 };
@@ -167,8 +174,6 @@ const spawnAndKill = async (t, [signal, cleanup, detached, isKilled]) => {
 const exitIfWindows = process.platform === 'win32';
 test('spawnAndKill SIGTERM', spawnAndKill, ['SIGTERM', false, false, exitIfWindows]);
 test('spawnAndKill SIGKILL', spawnAndKill, ['SIGKILL', false, false, exitIfWindows]);
-// The `cleanup` option can introduce a race condition in this test
-// This is especially true when run concurrently, so we use `test.serial()`
 test.serial('spawnAndKill cleanup SIGTERM', spawnAndKill, ['SIGTERM', true, false, true]);
 test('spawnAndKill cleanup SIGKILL', spawnAndKill, ['SIGKILL', true, false, exitIfWindows]);
 test('spawnAndKill detached SIGTERM', spawnAndKill, ['SIGTERM', false, true, false]);
diff --git a/test/stream.js b/test/stream.js
index 2bfd6b1..bfedaae 100644
--- a/test/stream.js
+++ b/test/stream.js
@@ -4,6 +4,7 @@ import process from 'node:process';
 import fs from 'node:fs';
 import Stream from 'node:stream';
 import {promisify} from 'node:util';
+import {pathToFileURL} from 'node:url';
 import test from 'ava';
 import getStream from 'get-stream';
 import {pEvent} from 'p-event';
@@ -158,6 +159,18 @@ test('stdin option cannot be an iterable when "inputFile" is used', t => {
 	}, {message: /`inputFile` and `stdin` options/});
 });
 
+test('stdin option cannot be a file URL when "input" is used', t => {
+	t.throws(() => {
+		execa('stdin.js', {stdin: pathToFileURL('unknown'), input: 'foobar'});
+	}, {message: /`input` and `stdin` options/});
+});
+
+test('stdin option cannot be a file URL when "inputFile" is used', t => {
+	t.throws(() => {
+		execa('stdin.js', {stdin: pathToFileURL('unknown'), inputFile: 'dummy.txt'});
+	}, {message: /`inputFile` and `stdin` options/});
+});
+
 test('stdin option cannot be a generic iterable string', async t => {
 	await t.throwsAsync(() => execa('stdin.js', {stdin: 'foobar'}), {code: 'ERR_INVALID_SYNC_FORK_INPUT'});
 });
@@ -208,6 +221,20 @@ test('input option can be used with $', async t => {
 	t.is(stdout, 'foobar');
 });
 
+test('stdin can be a file URL', async t => {
+	const inputFile = tempfile();
+	fs.writeFileSync(inputFile, 'howdy');
+	const stdin = pathToFileURL(inputFile);
+	const {stdout} = await execa('stdin.js', {stdin});
+	t.is(stdout, 'howdy');
+});
+
+test('stdin cannot be a non-file URL', async t => {
+	await t.throws(() => {
+		execa('stdin.js', {stdin: new URL('https://example.com')});
+	}, {message: /pathToFileURL/});
+});
+
 test('inputFile can be set', async t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');
@@ -234,6 +261,10 @@ test('inputFile option cannot be set when stdin is set', t => {
 	}, {message: /`inputFile` and `stdin` options/});
 });
 
+test('stdin file URL errors should be handled', async t => {
+	await t.throwsAsync(execa('stdin.js', {stdin: pathToFileURL('unknown')}), {code: 'ENOENT'});
+});
+
 test('inputFile errors should be handled', async t => {
 	await t.throwsAsync(execa('stdin.js', {inputFile: 'unknown'}), {code: 'ENOENT'});
 });
@@ -277,6 +308,20 @@ test('helpful error trying to provide an input stream in sync mode', t => {
 	);
 });
 
+test('stdin can be a file URL - sync', t => {
+	const inputFile = tempfile();
+	fs.writeFileSync(inputFile, 'howdy');
+	const stdin = pathToFileURL(inputFile);
+	const {stdout} = execaSync('stdin.js', {stdin});
+	t.is(stdout, 'howdy');
+});
+
+test('stdin cannot be a non-file URL - sync', async t => {
+	await t.throws(() => {
+		execaSync('stdin.js', {stdin: new URL('https://example.com')});
+	}, {message: /pathToFileURL/});
+});
+
 test('inputFile can be set - sync', t => {
 	const inputFile = tempfile();
 	fs.writeFileSync(inputFile, 'howdy');