Creating Custom Executors
Creating Executors for your workspace standardizes scripts that are run during your development/building/deploying tasks in order to enable Nx's affected
command and caching capabilities.
This guide shows you how to create, run, and customize executors within your Nx workspace. The examples use the trivial use-case of an echo
command.
Creating an executor
Your executor should be created within the tools
directory of your Nx workspace like so:
happynrwl/
├── apps/
├── libs/
├── tools/
│ └── executors/
│ └── echo/
│ ├── executor.json
│ ├── impl.ts
│ ├── package.json
│ └── schema.json
├── nx.json
├── package.json
└── tsconfig.base.json
schema.json
This file describes the options being sent to the executor (very similar to the schema.json
file of generators). Setting the cli
property to nx
indicates that you're using the Nx Devkit to make this executor.
1{
2 "$schema": "http://json-schema.org/schema",
3 "type": "object",
4 "cli": "nx",
5 "properties": {
6 "textToEcho": {
7 "type": "string",
8 "description": "Text To Echo"
9 }
10 }
11}
12
This example describes a single option for the executor that is a string
called textToEcho
. When using this executor, specify a textToEcho
property inside the options.
In our impl.ts
file, we're creating an Options
interface that matches the json object being described here.
impl.ts
The impl.ts
contains the actual code for your executor. Your executor's implementation must export a function that takes an options object and returns a Promise<{ success: boolean }>
.
1import type { ExecutorContext } from '@nrwl/devkit';
2import { exec } from 'child_process';
3import { promisify } from 'util';
4
5export interface EchoExecutorOptions {
6 textToEcho: string;
7}
8
9export default async function echoExecutor(
10 options: EchoExecutorOptions,
11 context: ExecutorContext
12): Promise<{ success: boolean }> {
13 console.info(`Executing "echo"...`);
14 console.info(`Options: ${JSON.stringify(options, null, 2)}`);
15
16 const { stdout, stderr } = await promisify(exec)(
17 `echo ${options.textToEcho}`
18 );
19 console.log(stdout);
20 console.error(stderr);
21
22 const success = !stderr;
23 return { success };
24}
25
executor.json
The executor.json
file provides the description of your executor to the CLI.
1{
2 "executors": {
3 "echo": {
4 "implementation": "./impl",
5 "schema": "./schema.json",
6 "description": "Runs `echo` (to test executors out)."
7 }
8 }
9}
10
Note that this executor.json
file is naming our executor 'echo' for the CLI's purposes, and mapping that name to the given implementation file and schema.
package.json
This is all that’s required from the package.json
file:
1{
2 "executors": "./executor.json"
3}
4
Compiling and Running your Executor
After your files are created, compile your executor with tsc
(which is available locally in any Nx workspace):
npx tsc tools/executors/echo/impl
This will create the impl.js
file in your file directory, which will serve as the artifact used by the CLI.
Our last step is to add this executor to a given project’s targets
object in your project's project.json
file:
1{
2 //...
3 "targets": {
4 "build": {
5 // ...
6 },
7 "serve": {
8 // ...
9 },
10 "lint": {
11 // ,,,
12 },
13 "echo": {
14 "executor": "./tools/executors/echo:echo",
15 "options": {
16 "textToEcho": "Hello World"
17 }
18 }
19 }
20}
21
Note that the format of the executor
string here is: ${Path to directory containing the executor's package.json}:${executor name}
.
Finally, you run the executor via the CLI as follows:
nx run platform:echo
To which we'll see the console output:
> nx run platform:echo
Executing "echo"...
Options: {
"textToEcho": "Hello World"
}
Hello World
Debugging Executors
As part of Nx's computation cache process, Nx forks the node process, which can make it difficult to debug an executor command. Follow these steps to debug an executor:
- Use VS Code's command pallette to open a
Javascript Debug Terminal
- Find the compiled (
*.js
) executor code and set a breakpoint. - Run the executor in the debug terminal
nx run platform:echo
Using Node Child Process
Node’s childProcess
is often useful in executors.
Part of the power of the executor API is the ability to compose executors via existing targets. This way you can combine other executors from your workspace into one which could be helpful when the process you’re scripting is a combination of other existing executors provided by the CLI or other custom executors in your workspace.
Here's an example of this (from a hypothetical project), that serves an api (project name: "api") in watch mode, then serves a frontend app (project name: "web-client") in watch mode:
1import { ExecutorContext, runExecutor } from '@nrwl/devkit';
2
3export interface MultipleExecutorOptions {}
4
5export default async function multipleExecutor(
6 options: MultipleExecutorOptions,
7 context: ExecutorContext
8): Promise<{ success: boolean }> {
9 const result = await Promise.race([
10 await runExecutor(
11 { project: 'api', target: 'serve' },
12 { watch: true },
13 context
14 ),
15 await runExecutor(
16 { project: 'web-client', target: 'serve' },
17 { watch: true },
18 context
19 ),
20 ]);
21 for await (const res of result) {
22 if (!res.success) return res;
23 }
24
25 return { success: true };
26}
27
For other ideas on how to create your own executors, you can always check out Nx's own open-source executors as well!
(For example, our cypress executor)
Using Custom Hashers
For most executors, the default hashing in Nx makes sense. The output of the executor is dependent on the files in the project that it is being run for, or that project's dependencies, and nothing else. Changing a miscellaneous file at the workspace root will not affect that executor, and changing any file inside of the project may affect the executor. When dealing with targets which only depend on a small subset of the files in a project, or may depend on arbitrary data that is not stored within the project, the default hasher may not make sense anymore. In these cases, the target will either experience more frequent cache misses than necessary or not be able to be cached.
Executors can provide a custom hasher that Nx uses when determining if a target run should be a cache hit, or if it must be run. When generating an executor for a plugin, you can use nx g @nrwl/nx-plugin:executor my-executor --project my-plugin --includeHasher
to automatically add a custom hasher.
If you want to add a custom hasher manually, create a new file beside your executor's implementation. We will use hasher.ts
as an example here. You'll also need to update executors.json
, so that it resembles something like this:
1{
2 "executors": {
3 "echo": {
4 "implementation": "./src/executors/my-executor/executor",
5 "hasher": "./src/executors/my-executor/hasher",
6 "schema": "./src/executors/my-executor/schema.json"
7 }
8 }
9}
10
This would allow you to write a custom function in hasher.ts
, which Nx would use to calculate the target's hash. As an example, consider the below hasher which mimics the behavior of Nx's default hashing algorithm.
1import { CustomHasher, Task, HasherContext } from '@nrwl/devkit';
2
3export const mimicNxHasher: CustomHasher = async (
4 task: Task,
5 context: HasherContext
6) => {
7 return context.hasher.hashTask(task);
8};
9
10export default mimicNxHasher;
11
The hash function can do anything it wants, but it is important to remember that the hasher replaces the hashing done normally by Nx. If you change the hasher, Nx may return cache hits when you do not anticipate it. Imagine the below custom hasher:
1import { CustomHasher, Task, HasherContext } from '@nrwl/devkit';
2
3export const badHasher: CustomHasher = async (
4 task: Task,
5 context: HasherContext
6) => {
7 return {
8 value: 'my-static-hash',
9 };
10};
11
12export default badHasher;
13
This hasher would never return a different hash, so every run of a task that consumes the executor would be a cache hit. It is important that anything that would change the result of your executor's implementation is accounted for in the hasher.