Execa 9: our biggest release

ehmicky
6 min readMay 8, 2024

--

Execa logo

Execa runs commands in your script, application or library. Unlike zx and Bun shell, Execa distances itself from shells and the old days of Bash scripts. Instead, it embraces a modern, purely JavaScript approach, optimized for programmatic usage. This makes executing commands simple, secure, cross-platform and easy to debug.

import {$} from 'execa';

const tokensUrl = 'https://example.com/api/tokens';
const token = await $`curl ${tokensUrl}`
.pipe`grep api_token`
.pipe`head -n 1`;

const branch = await $`git branch --show-current`;

const logFile = 'logs.txt';
await $({stderr: logFile})`dep deploy
--parallel
--token=${token}
--branch=${branch}`;

Despite being 8 years old, the project is very active. In fact, today’s release is our biggest so far, involving 6 contributors, 9 months of development, 317 PRs and 3915 automated tests. Here’s a highlight of some of the new features.

Read the output, one line at a time

If a command lasts for a long time, you might want to read its output while it is still running. Since most commands are text-based, this usually means iterating over each output line. Although this might seem simple at first sight, it is surprisingly hard to get right.

With Execa, commands are iterable, one line at a time. The lines option can also be used to split the full output into multiple lines.

import {execa} from 'execa';

// One line at a time
for await (const line of execa`npm run build`) {
if (line.includes('ERROR')) {
await reportError(line);
}
}

// All lines at once
const {stdout: lines} = await execa({
lines: true,
})`npm run build`;
const errorLines = lines
.filter(line => line.includes('ERROR'))
.join('\n');
console.error(errorLines);

Map/filter the input and output

Node.js Duplexes and Transforms are streams that map or filter data. There are many available modules based on them, from parsing CSV to compressing data or logging.

With Execa, they can be passed directly to a command’s stdin, stdout or stderr option to transform its input or output. Web-based TransformStreams are supported too.

Streaming consumes memory progressively and holds the CPU in small bursts. If a command is slow or its output is big, this is more efficient than modifying its final result all at once.

import {execa} from 'execa';

const {stdout} = await execa({
stdout: new CompressionStream('gzip'),
encoding: 'buffer',
})`npm run build`;

// `stdout` is compressed with gzip
console.log(stdout);

Generator-based transforms

That being said, writing your own streams can be tricky. To enjoy their benefits without diving into their intricate ins and outs, simple generator functions can be used instead.

import {execa} from 'execa';

let count = 0;
const {stdout} = await execa({
* stdout(line) {
yield `[${count++}] ${line}`;
},
})`npm run build`;

// Prefix line number:
// [0] ...
// [1] ...
// [2] ...
console.log(stdout);

Redirect the input and output

A command’s input or output is commonly redirected from/to a file, as shown by the < and > builtin operators in Unix shells. With Execa, this can be done by passing a {file: './path'} object to the stdin, stdout or stderr option.

Another usual task is to print a command’s output progressively. Passing 'inherit' to the stdout or stderr option achieves that, but it prevents storing the output in a variable. This can be fixed by passing ['inherit', 'pipe'] instead.

import {execa} from 'execa';

const {stderr} = await execa({
// Write stdout to a file
stdout: {file: './stdout.txt'},
// Return stderr, but also print it
stderr: ['inherit', 'pipe'],
})`npm run build`;

Pipe multiple commands

The pipe operator | is great in interactive terminals. However, in a script file, it cannot easily:

  • Retrieve the output of each command in the pipeline, as opposed to only the last one, making debugging difficult.
  • Handle errors: unless the pipefail option is set, the pipeline might succeed even if some of its commands failed.
  • Pipe one command to several, or several commands to one.
  • Change a command’s piping destination.
  • Benefit from strong types, since parsing the pipeline string in TypeScript is not feasible.

Execa’s subprocess.pipe() method can do all of the above, enabling a better experience in a programmatic context.

import {execa, execaNode} from 'execa';

// `npm run build | sort | head -n 2`
// Throws if any of the three commands fails
const finalResult = await execa`npm run build`
.pipe`sort`
.pipe`head -n 2`;
// `npm run build | sort`
const sortResult = finalResult.pipedFrom[0];
// `npm run build`
const buildResult = sortResult.pipedFrom[0];

// Pipe several commands to the same logging process
const logger = execaNode`log-remotely.js`;
await Promise.all([
execa`npm run build`.pipe(logger),
execa`npm run test`.pipe(logger),
]);

Verbose mode

Commands sometimes feel like black boxes. They run in processes isolated from each other, which can turn a small typo into an hours-long debugging headache.

To alleviate this problem, the verbose mode has been improved to automatically print the commands’ arguments, output, errors, completion and duration.

// build.js
import {execa} from 'execa';

await execa`npm run build`;
await execa`npm run test`;
$ NODE_DEBUG=execa node build.js
[00:57:44.581] [0] $ npm run build
[00:57:44.653] [0] Building application...
[00:57:44.653] [0] Done building.
[00:57:44.658] [0] ✔ (done in 78ms)
[00:57:44.658] [1] $ npm run test
[00:57:44.740] [1] Running tests...
[00:57:44.740] [1] Error: the entrypoint is invalid.
[00:57:44.747] [1] ✘ Command failed with exit code 1: npm run test
[00:57:44.747] [1] ✘ (done in 89ms)

Detailed errors

Errors now include richer information including the interleaved output, the duration, and additional insights into the failure’s root cause.

import {execa} from 'execa';

try {
await execa({timeout: 5000})`npm run build`;
} catch (error) {
console.error(error);
// ExecaError: Command timed out after 5000 milliseconds: npm run build
// at file:///home/me/Desktop/example.js:2:20
// at ... {
// command: 'npm run build',
// escapedCommand: 'npm run build',
// cwd: '/path/to/cwd',
// durationMs: 19.95693,
// failed: true,
// timedOut: true,
// isCanceled: false,
// isTerminated: true,
// isMaxBuffer: false,
// signal: 'SIGTERM',
// signalDescription: 'Termination',
// stdout: 'Building the application...',
// stderr: 'Warning: deprecated API.',
// stdio: [
// undefined,
// 'Building the application...',
// 'Warning: deprecated API.',
// ],
// pipedFrom: []
// }
}

Debug termination signals

Have you ever wondered why a specific command ended abruptly? Termination signals like SIGTERM do not carry any information: no message, no stack trace.

This can be solved by passing an error instance to subprocess.kill(), which could be a time-saver when debugging complex bugs.

import {execa} from 'execa';

const subprocess = execa`npm run build`;
onCancel(reason => {
const error = new Error(`Canceled by ${reason}`);
subprocess.kill(error);
});
await subprocess;

Template strings

Since Execa 7, commands can be specified using a template string, like zx. However, this was previously limited to the $ method.

Both the template string syntax and the traditional array syntax can now be used with all Execa methods. They are equivalent and mostly a matter of preference.

Also, template strings can span multiple lines, which is useful when passing many CLI flags.

When executing a series of commands in a script, $ is recommended. When calling individual commands in an application or library, execa and execaNode are preferred instead. The only difference is that $ uses script-friendly default options. For example, it automatically reads its stdin from the terminal.

import {execa} from 'execa';

await execa`npm run build
--concurrency 2
--fail-fast`;

Share options

All Execa methods can bind options. This enables setting global options or re-using them between multiple commands.

import {execa as execa_} from 'execa';

// Set global options
const execa = execa_({timeout: 5000});

await execa`npm run build`;
await execa`npm run test`;

Embrace web APIs

Server-side JavaScript is increasingly adopting web APIs in lieu of Node.js core modules. So is Execa: instead of Node.js streams, file path strings and Buffer, you can use web streams, file URLs and Uint8Array.

import {execaNode} from 'execa';

const response = await fetch('https://example.com/api/orders');
await execaNode({
stdin: response.body,
})`send_orders.js`;

Convert to streams

Some modules take streams as arguments or return them. In order to let you use commands to those modules directly, Execa subprocesses can be converted to streams using subprocess.readable(), subprocess.writable() or subprocess.duplex().

import {execaNode} from 'execa';
import {pipeline} from 'node:stream/promises';
import {
createReadStream,
createWriteStream,
} from 'node:fs';

await pipeline(
createReadStream('./input.txt'),
execaNode`transform.js`.duplex(),
createWriteStream('./output.txt'),
);

For a full list of the breaking changes, new features and bug fixes, please check out the release notes.

Also, we’ve completely revamped the documentation: aside from the reference section, it now includes many user guides and examples. We’d love to encourage new users to better understand processes, which can be a daunting topic at first. Hopefully, the new documentation will also help long-time users discover specific features they previously missed.

--

--