Writing custom nx plugins
If you're starting a new project with nx in mind there's a decent chance this article will only be relevant for you after several months to a couple of years into development. That is because of Nrwl's great options for plugins for any use case.
If however you have an existing project, with pre-existing opinions that aren't aligned with Nrwl's default plugins, and you have some issues the plugin development is a must for you. And it's way simpler than it is currently documented.
The three main types os NX plugins; generate, build and serve.
When writing nx plugins there are a few basic concepts to take in mind before putting the hands in the dirt.
There are three type of plugins we need to take into account:
- Generator
- Executor build
- Executor serve
All three are required to build and run an app.
Generator creates new features as we'll define them.
Build executors will compile/transpile and output with a js bundle of some kind.
Serve executors will run the node process that will serve our apps.
With these three we can confidently have common standards inside our team for building and running our app.
The approach where the build and serve scripts are 2 independent steps has some advantages over scripts that magically compile and run your app.
I've tweeted about it in detail here:
Now we have an understanding of what types of plugins are there, let's write them one by one, in this order: generator, builder and executor.
Scaffolding the plugin with nx generate command
The generator has 2 main roles:
- Generate new files.
- Update files that depend on it, like importing a route and assign to an express.
First, create a new nx workspaces
npx create-nx-workspace \
--preset=node-standalone \
--name=nx-plugin-demo \
--framework=express \
--no-docker \
--no-nxCloud
This will create the most basic express workspace I could generate for this demo.
Inside (cd nx-plugin-demo
) we will use nx to generate a plugin. So first add
the @nrwl/nx-plugin
plugin.
# cd nx-plugin-demo if you didn't yet
yarn add @nrwl/nx-plugin
Generate the plugin using nx cli
nx g @nrwl/nx-plugin:plugin --name=my-targets
A word of advice, after running generator commands, see that everything works well and commit before making any changes.
Which will change a few files, and generate this directory in the project root. Under my-targets, we will work on the contents of the executors and generators directories.
my-targets
|-- README.md
|-- executors.json
|-- generators.json
|-- jest.config.ts
|-- package.json
|-- project.json
|-- src
| |-- executors 👈 We will work here laster
| | `-- build
| | |-- executor.spec.ts
| | |-- executor.ts
| | |-- schema.d.ts
| | `-- schema.json
| |-- generators 👈 First we will work here
| | `-- my-targets
| | |-- files
| | | `-- src
| | | `-- index.ts__template__
| | |-- generator.spec.ts
| | |-- generator.ts
| | |-- schema.d.ts
| | `-- schema.json
| `-- index.ts
|-- tsconfig.json
|-- tsconfig.lib.json
`-- tsconfig.spec.json
When running nx list
you should see this output somewhere at the top:
> NX Local workspace plugins:
@nx-plugin-demo/my-targets (executors,generators)
To see what executors and generators is has run
nx list @nx-plugin-demo/my-targets
.
To make sure that the generator can run without running it, run
nx g @nx-plugin-demo/my-targets:my-targets --name=test-name --dry-run
If everything looks fine, let's move on with the generator plugin.
[//] # "TODO Add link to tweet"
If not, go ahead and ask me here.
Writing a basic nx generator plugin example
I find their basic example a bit overwhelming. So, this is how it looks like after we inline all the helper functions and moving out a few variables.
I think it's a useful view since it shows what functionality is written in the generator, knowing that all function calls refer to nx utils and not local abstractions. Also, it's ~20 lines shorter than the default generator.
import {
addProjectConfiguration,
formatFiles,
generateFiles,
getWorkspaceLayout,
names,
Tree,
} from "@nrwl/devkit";
import * as path from "path";
import { MyTargetsGeneratorSchema } from "./schema";
export default async function (tree: Tree, options: MyTargetsGeneratorSchema) {
const name = names(options.name).fileName;
const projectDirectory = options.directory
? `${names(options.directory).fileName}/${name}`
: name;
const projectName = projectDirectory.replace(new RegExp("/", "g"), "-");
const projectRoot = `${getWorkspaceLayout(tree).libsDir}/${projectDirectory}`;
const parsedTags = options.tags
? options.tags.split(",").map((s) => s.trim())
: [];
// This is only relevant for generators that create new projects. In our
// example we will add and modify files for an existing project, so we'll remove it.
addProjectConfiguration(tree, projectName, {
root: projectRoot,
projectType: "library",
sourceRoot: `${projectRoot}/src`,
targets: {
build: {
executor: "@nx-plugin-demo/my-targets:build",
},
},
tags: parsedTags,
});
// The third parameter takes a key value pari that replaces in templates all keys with values.
// file names each key is seround by double underscore. Meaning that the "template" key will
// replace all places where __template__ occurs in file names with ''.
generateFiles(tree, path.join(__dirname, "files"), projectRoot, {
template: "",
...names(options.name),
});
await formatFiles(tree);
}
Running the generator without the --dry-run
flag will result in the following
addition to our repo. I'd run and remove them just to make sure everything works
ok before going into the next step, adjusting the generator to add an express
route.
test-name
|-- project.json
`-- src
`-- index.ts
2 directories, 2 files
Create file templates for nx plugins
Now, let's remove and add code so that the generator adds a route, starting with the template file. To keep things simple for the demo, I'll use a single route files. Keep in mind that in production project I usually have more files added for each feature. E.g. controller, schema, test and so on.
my-targets/src/generators/my-targets
|-- files
+ `-- __fileName__.router.ts__template__ 👍 Added
- `-- src 👎 Removed
- `-- index.ts__template__ 👎 Removed
|-- generator.spec.ts
|-- generator.ts
|-- schema.d.ts
`-- schema.json
Note that the file's full name is __fileName__.router.ts__template__
and it
has 2 template variables.
- __fileName__ at the beginning of the file name will be replaced with the generated route's name
- __template__ at the end of the file name will be replaced with an empty
string. It's there so that the IDE and other tools won't consider it as a
.ts
file.
The templates content isn't idea to write or understand since it's an .ejs
template that constitutes a .ts
file. I personally edit a ts file and replace
all feature names with ejs template placeholders.
import { Response, Router } from 'express';
export const <%= propertyName %>Router = Router({ mergeParams: true });
interface <%= className %> {
id: string;
}
interface <%= className %>Response<IsList = false> {
data?: IsList extends true ? <%= className %>[] : <%= className %>;
message: string;
}
// GET
<%= propertyName %>Router.get('/', (req, res: Response<<%= className %>Response<true>>) => {
res.status(501).json({ message: 'Endpoint in development' });
});
<%= propertyName %>Router.get('/:id', (req, res: Response<<%= className %>Response>) => {
res.status(501).json({ message: 'Endpoint in development' });
});
// POST
<%= propertyName %>Router.post('/', (req, res: Response<<%= className %>Response>) => {
res.status(501).json({ message: 'Endpoint in development' });
});
// PUT
<%= propertyName %>Router.put('/:id', (req, res: Response<<%= className %>Response>) => {
res.status(501).json({ message: 'Endpoint in development' });
});
// DELETE
<%= propertyName %>Router.delete('/:id', (req, res: Response<<%= className %>Response>, next) => {
res.status(501).json({ message: 'Endpoint in development' });
});
For this demo, that will be our entire file structure. However, like said before, in medium-sized project I use a more complex structure per feature.
Updating the generator function and configuration to create an express route
Our generator function needs some updates. We need it not to create a project but to create a feature file inside a given directory.
To do that we'll provide the generator with a --directory
parameter, and
remove the project.json generation.
We declare the generator params as a schema in schema.json
and in
schema.d.ts
files inside the generator's directory.
my-targets/src/generators
`-- my-targets
|-- files
| `-- __fileName__.router.ts__template__
|-- generator.spec.ts
|-- generator.ts
|-- schema.d.ts 👈
`-- schema.json 👈
Inside schema.json
under properties
remove the tag
properties, and add a
value directory
to the required array since now it's mandatory.
The entire schema.json after changes
{
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "MyTargets",
"title": "",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "",
"$default": {
"$source": "argv",
"index": 0
},
"x-prompt": "What name would you like to use?"
},
"directory": {
"type": "string",
"description": "A directory where the project is placed"
}
},
"required": ["name"]
}
For schema.d.ts
this will be the modified content
export interface MyTargetsGeneratorSchema {
name: string;
directory: string;
}
The generator function is a lot simpler without a project config. It actually concludes to generating a file and formatting it done by 2 function calls.
import {
formatFiles,
generateFiles,
getWorkspaceLayout,
names,
Tree,
} from "@nrwl/devkit";
import * as path from "path";
import { MyTargetsGeneratorSchema } from "./schema";
export default async function (tree: Tree, options: MyTargetsGeneratorSchema) {
generateFiles(
tree,
path.join(__dirname, "files"),
`${getWorkspaceLayout(tree).libsDir}/${options.directory}`,
{
template: "",
...names(options.name),
}
);
await formatFiles(tree);
}
Now run the generator and see the magic in work.
nx g @nx-plugin-demo/my-targets:my-targets --name=users --directory src/routes
The only thing left to do is to add a codemod that will add the router as an express handler.
Updating an express route with jscodeshift and nx generators
Add jscodeshift
yarn add -D jscodeshift @types/jscodeshift
Update the plugin's schema by adding appFilePath: string;
to the d.ts
and to
the schema.json
we'll add:
{
"appFilePath": {
"type": "string",
"description": "The file with express routers reference"
}
}
Import applyTransform
from jscodeshift
import { applyTransform } from "jscodeshift/src/testUtils";
Inside the generator function, add the transform logic that will make 2 transofrms:
- Find the latest
import
and add a new import statement that'll refer our newly create route below it. - Find the
app.listen
call and add anapp.use(route)
above it.
Simple changes, but using codemod requires a bit more code. With the extra complexity comes the additional benefit that if done right, the transform will work close to 100% of the time.
The final generator.ts
file looks is below. The code is well commented, I
consider the comments to be a part of the article.
import {
formatFiles,
generateFiles,
getWorkspaceLayout,
names,
Tree,
} from "@nrwl/devkit";
import * as path from "path";
import { MyTargetsGeneratorSchema } from "./schema";
// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries
import { applyTransform } from "jscodeshift/src/testUtils";
export default async function (tree: Tree, options: MyTargetsGeneratorSchema) {
generateFiles(
tree,
path.join(__dirname, "files"),
`${getWorkspaceLayout(tree).libsDir}/${options.directory}`,
{
template: "",
...names(options.name),
}
);
// New code begins here 👇 👇 👇
const filePath = options.appFilePath;
const input = tree.read(filePath).toString();
const { propertyName, fileName } = names(options.name);
const output = applyTransform(
{
// j is the api for jscodeshift
default: (file, { j }) => {
let source = file.source;
source = j(source)
// ExpressionStatement is usually a function call.
// To better understand this section you better play with https://astexplorer.net/#/gist/dbe0fe81c4e62f03483eccc5aa94ca51/189783d7f6624c7dd011f6d2eb660cd6f07851e9
.find(j.ExpressionStatement, {
expression: {
callee: {
object: {
name: "app",
},
property: {
name: "listen",
},
},
},
})
.at(-1)
.forEach((path) => {
const newAppUseCode = `app.use('/api/${fileName}', ${propertyName}Router);`;
// Make sure the inserted code wasn't inserted before, for cases with duplicate calls.
if (source.indexOf(newAppUseCode) !== -1) {
return;
}
// A bit confusing, but it inserts newAppUseCode before path.
j(path).insertBefore(newAppUseCode);
})
.toSource();
source = j(source)
.find(j.ImportDeclaration)
.at(-1)
.forEach((path) => {
const newImportCode = `import { ${propertyName}Router } from './routes/${fileName}.router';`;
if (source.match(newImportCode)) {
return;
}
j(path).insertAfter(newImportCode);
})
.toSource();
return source;
},
parser: "ts",
},
{},
{ source: input, path: filePath }
);
tree.write(filePath, output);
await formatFiles(tree);
}
And that's basically it. Now running. Now running with --dry-run
you should
see an addition of the updated file.
nx g @nx-plugin-demo/my-targets:my-targets --name=users --directory src/routes --appFilePath=src/main.ts --dry-run main
> NX Unable to load tsconfig-paths, workspace libraries may be inaccessible.
- To fix this, install tsconfig-paths with npm/yarn/pnpm
> NX Generating @nx-plugin-demo/my-targets:my-targets
CREATE src/routes/users.router.ts
UPDATE src/main.ts 👈 That's the new addition
Run it, and see the magic at your fingertips.
Further reading:
Follow more in depth articles, and keep in touch with me @adamgensh.