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:

  1. Generator
  2. Executor build
  3. 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:

  1. Generate new files.
  2. 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.

  1. __fileName__ at the beginning of the file name will be replaced with the generated route's name
  2. __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:

  1. Find the latest import and add a new import statement that'll refer our newly create route below it.
  2. Find the app.listen call and add an app.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:

  1. jscodeshift
  2. nx modifying-files
  3. nx composing-generators

Follow more in depth articles, and keep in touch with me @adamgensh.

🚀