Repository: sindresorhus/execa (js)

Issue:
Support `AbortController`
Node.js is adopting it for everything, so I think we should support it too.

https://developer.mozilla.org/en-US/docs/Web/API/AbortController

Node.js just added support for it in `.spawn()`: https://nodejs.org/en/blog/release/v15.5.0/

This would not replace the current `.cancel()` function, in the near-term at least.

Pull request description:
Support `AbortController`
Fixes #449.

I'd appreciate it if you could let me know if I need to write more tests or documents.

Thanks for all your efforts for this awesome library :)



Patch:
diff --git a/index.d.ts b/index.d.ts
index fdf9de1..f9cf418 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -33,9 +33,11 @@ export interface CommonOptions<EncodingType> {
 	/**
 	Preferred path to find locally installed binaries in (use with `preferLocal`).
 
+	Using a `URL` is only supported in Node.js `14.18.0`, `16.14.0` or above.
+
 	@default process.cwd()
 	*/
-	readonly localDir?: string;
+	readonly localDir?: string | URL;
 
 	/**
 	Path to the Node.js executable to use in child processes.
@@ -111,9 +113,11 @@ export interface CommonOptions<EncodingType> {
 	/**
 	Current working directory of the child process.
 
+	Using a `URL` is only supported in Node.js `14.18.0`, `16.14.0` or above.
+
 	@default process.cwd()
 	*/
-	readonly cwd?: string;
+	readonly cwd?: string | URL;
 
 	/**
 	Environment key-value pairs. Extends automatically from `process.env`. Set `extendEnv` to `false` if you don't want this.
@@ -204,6 +208,34 @@ export interface CommonOptions<EncodingType> {
 	*/
 	readonly killSignal?: string | number;
 
+	/**
+	You can abort the spawned process using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
+
+	When `AbortController.abort()` is called, [`.isCanceled`](https://github.com/sindresorhus/execa#iscanceled) becomes `false`.
+
+	*Requires Node.js 16 or later.*
+
+	@example
+	```js
+	import {execa} from 'execa';
+
+	const abortController = new AbortController();
+	const subprocess = execa('node', [], {signal: abortController.signal});
+
+	setTimeout(() => {
+		abortController.abort();
+	}, 1000);
+
+	try {
+		await subprocess;
+	} catch (error) {
+		console.log(subprocess.killed); // true
+		console.log(error.isCanceled); // true
+	}
+	```
+	*/
+	readonly signal?: AbortSignal;
+
 	/**
 	If `true`, no quoting or escaping of arguments is done on Windows. Ignored on other platforms. This is set to `true` automatically when the `shell` option is `true`.
 
@@ -337,6 +369,8 @@ export interface ExecaReturnValue<StdoutErrorType = string>
 
 	/**
 	Whether the process was canceled.
+
+	You can cancel the spawned process using the [`signal`](https://github.com/sindresorhus/execa#signal-1) option.
 	*/
 	isCanceled: boolean;
 }
diff --git a/index.js b/index.js
index 9df491d..f060590 100644
--- a/index.js
+++ b/index.js
@@ -127,7 +127,7 @@ export function execa(file, args, options) {
 				escapedCommand,
 				parsed,
 				timedOut,
-				isCanceled: context.isCanceled,
+				isCanceled: context.isCanceled || (parsed.options.signal ? parsed.options.signal.aborted : false),
 				killed: spawned.killed,
 			});
 
diff --git a/package.json b/package.json
index 3604397..1871388 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
 		"get-node": "^12.0.0",
 		"is-running": "^2.1.0",
 		"p-event": "^5.0.1",
+		"semver": "^7.3.5",
 		"tempfile": "^4.0.0",
 		"tsd": "^0.18.0",
 		"xo": "^0.46.4"
diff --git a/readme.md b/readme.md
index a9db621..a529f39 100644
--- a/readme.md
+++ b/readme.md
@@ -83,10 +83,11 @@ try {
 ```js
 import {execa} from 'execa';
 
-const subprocess = execa('node');
+const abortController = new AbortController();
+const subprocess = execa('node', [], {signal: abortController.signal});
 
 setTimeout(() => {
-	subprocess.cancel();
+	abortController.abort();
 }, 1000);
 
 try {
@@ -171,10 +172,6 @@ Milliseconds to wait for the child process to terminate before sending `SIGKILL`
 
 Can be disabled with `false`.
 
-#### cancel()
-
-Similar to [`childProcess.kill()`](https://nodejs.org/api/child_process.html#child_process_subprocess_kill_signal). This is preferred when cancelling the child process execution as the error is more descriptive and [`childProcessResult.isCanceled`](#iscanceled) is set to `true`.
-
 #### all
 
 Type: `ReadableStream | undefined`
@@ -290,6 +287,8 @@ Type: `boolean`
 
 Whether the process was canceled.
 
+You can cancel the spawned process using the [`signal`](#signal-1) option.
+
 #### killed
 
 Type: `boolean`
@@ -357,11 +356,13 @@ If you `$ npm install foo`, you can then `execa('foo')`.
 
 #### localDir
 
-Type: `string`\
+Type: `string | URL`\
 Default: `process.cwd()`
 
 Preferred path to find locally installed binaries in (use with `preferLocal`).
 
+Using a `URL` is only supported in Node.js `14.18.0`, `16.14.0` or above.
+
 #### execPath
 
 Type: `string`\
@@ -446,11 +447,13 @@ Execa also accepts the below options which are the same as the options for [`chi
 
 #### cwd
 
-Type: `string`\
+Type: `string | URL`\
 Default: `process.cwd()`
 
 Current working directory of the child process.
 
+Using a `URL` is only supported in Node.js `14.18.0`, `16.14.0` or above.
+
 #### env
 
 Type: `object`\
@@ -542,6 +545,16 @@ Default: `SIGTERM`
 
 Signal value to be used when the spawned process will be killed.
 
+#### signal
+
+Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
+
+You can abort the spawned process using [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
+
+When `AbortController.abort()` is called, [`.isCanceled`](#iscanceled) becomes `false`.
+
+*Requires Node.js 16 or later.*
+
 #### windowsVerbatimArguments
 
 Type: `boolean`\


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 5e06366..b73cfa0 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -90,6 +90,7 @@ try {
 execa('unicorns', {cleanup: false});
 execa('unicorns', {preferLocal: false});
 execa('unicorns', {localDir: '.'});
+execa('unicorns', {localDir: new URL('file:///test')});
 execa('unicorns', {execPath: '/path'});
 execa('unicorns', {buffer: false});
 execa('unicorns', {input: ''});
@@ -121,6 +122,7 @@ execa('unicorns', {reject: false});
 execa('unicorns', {stripFinalNewline: false});
 execa('unicorns', {extendEnv: false});
 execa('unicorns', {cwd: '.'});
+execa('unicorns', {cwd: new URL('file:///test')});
 // eslint-disable-next-line @typescript-eslint/naming-convention
 execa('unicorns', {env: {PATH: ''}});
 execa('unicorns', {argv0: ''});
@@ -140,6 +142,7 @@ execa('unicorns', {timeout: 1000});
 execa('unicorns', {maxBuffer: 1000});
 execa('unicorns', {killSignal: 'SIGTERM'});
 execa('unicorns', {killSignal: 9});
+execa('unicorns', {signal: new AbortController().signal});
 execa('unicorns', {windowsVerbatimArguments: true});
 execa('unicorns', {windowsHide: false});
 /* eslint-enable @typescript-eslint/no-floating-promises */
diff --git a/test/kill.js b/test/kill.js
index 08ae393..c26c58d 100644
--- a/test/kill.js
+++ b/test/kill.js
@@ -262,3 +262,50 @@ test('calling cancel method on a process which has been killed does not make err
 	const {isCanceled} = await t.throwsAsync(subprocess);
 	t.false(isCanceled);
 });
+
+if (globalThis.AbortController !== undefined) {
+	test('calling abort throws an error with message "Command was canceled"', async t => {
+		const abortController = new AbortController();
+		const subprocess = execa('noop.js', [], {signal: abortController.signal});
+		abortController.abort();
+		await t.throwsAsync(subprocess, {message: /Command was canceled/});
+	});
+
+	test('calling abort twice should show the same behaviour as calling it once', async t => {
+		const abortController = new AbortController();
+		const subprocess = execa('noop.js', [], {signal: abortController.signal});
+		abortController.abort();
+		abortController.abort();
+		const {isCanceled} = await t.throwsAsync(subprocess);
+		t.true(isCanceled);
+		t.true(subprocess.killed);
+	});
+
+	test('calling abort on a successfully completed process does not make result.isCanceled true', async t => {
+		const abortController = new AbortController();
+		const subprocess = execa('noop.js', [], {signal: abortController.signal});
+		const {isCanceled} = await subprocess;
+		abortController.abort();
+		t.false(isCanceled);
+	});
+
+	test('calling cancel after abort should show the same behaviour as only calling cancel', async t => {
+		const abortController = new AbortController();
+		const subprocess = execa('noop.js', [], {signal: abortController.signal});
+		abortController.abort();
+		subprocess.cancel();
+		const {isCanceled} = await t.throwsAsync(subprocess);
+		t.true(isCanceled);
+		t.true(subprocess.killed);
+	});
+
+	test('calling abort after cancel should show the same behaviour as only calling cancel', async t => {
+		const abortController = new AbortController();
+		const subprocess = execa('noop.js', [], {signal: abortController.signal});
+		subprocess.cancel();
+		abortController.abort();
+		const {isCanceled} = await t.throwsAsync(subprocess);
+		t.true(isCanceled);
+		t.true(subprocess.killed);
+	});
+}
diff --git a/test/test.js b/test/test.js
index 23449f5..d246076 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1,9 +1,10 @@
 import path from 'node:path';
 import process from 'node:process';
-import {fileURLToPath} from 'node:url';
+import {fileURLToPath, pathToFileURL} from 'node:url';
 import test from 'ava';
 import isRunning from 'is-running';
 import getNode from 'get-node';
+import semver from 'semver';
 import {execa, execaSync} from '../index.js';
 
 process.env.PATH = fileURLToPath(new URL('./fixtures', import.meta.url)) + path.delimiter + process.env.PATH;
@@ -198,6 +199,28 @@ test('do not extend environment with `extendEnv: false`', async t => {
 	t.deepEqual(stdout.split('\n'), ['undefined', 'bar']);
 });
 
+test('can use `options.cwd` as a string', async t => {
+	const cwd = '/';
+	const {stdout} = await execa('node', ['-p', 'process.cwd()'], {cwd});
+	t.is(path.toNamespacedPath(stdout), path.toNamespacedPath(cwd));
+});
+
+if (semver.satisfies(process.version, '^14.18.0 || >=16.4.0')) {
+	test('localDir option can be a URL', async t => {
+		const command = process.platform === 'win32' ? 'echo %PATH%' : 'echo $PATH';
+		const {stdout} = await execa(command, {shell: true, preferLocal: true, localDir: pathToFileURL('/test')});
+		const envPaths = stdout.split(path.delimiter);
+		t.true(envPaths.some(envPath => envPath.endsWith('.bin')));
+	});
+
+	test('can use `options.cwd` as a URL', async t => {
+		const cwd = '/';
+		const cwdUrl = pathToFileURL(cwd);
+		const {stdout} = await execa('node', ['-p', 'process.cwd()'], {cwd: cwdUrl});
+		t.is(path.toNamespacedPath(stdout), path.toNamespacedPath(cwd));
+	});
+}
+
 test('can use `options.shell: true`', async t => {
 	const {stdout} = await execa('node test/fixtures/noop.js foo', {shell: true});
 	t.is(stdout, 'foo');