An Orderly Nx Workspace Will Help You Accomplish More

How to create a workspace generator as a library in Nx workspace?

How to create an Nx generator? How to use it in your Nx workspace? How we converted a workspace generator into a publishable library? And how can boring be good for you?

Nx is a powerful monorepo management tool. It helps you utilize one of the most powerful monorepo advantages (IMO) – standards. By standards I mean – no matter what you develop, or what team you are coming from, you are going to feel at home as a developer.

The Standards Advantage or: Why Boring is Good?

In order to run a build process for a library, a developer can type this in the cli:

npx nx run myLibrary:build

If said developer wants to run the tests?

npx nx run myLibrary:test

But what if this developer wants to run the super-duper server?

npx nx run mySuperDuperServer:serve

And if I want to build it for production with docker goodies?

npx nx run mySuperDuperServer:build

And how to test said server?

npx nx run mySuperDuperServer:test

This is boring, right? You get to build, test and serve the same way. Even running e2e tests look like:

npx nx run components:e2e

BORING!

But that’s the power of standards. Let’s say a developer comes from the components team to the super duper server team? Easy peasy! No need to explain about infra, installation etc. Just remember npx, nx run, the name of the component you are working on and what target you want to run (e2e/serve/build/test etc.).

Do you understand now why boring is good? This is the power of standards. Developers’ productivity is so way better when they need to remember less troublesome things. Now we can focus on what matters most 🙂

What is a Workspace Generator?

As with the build/test/e2e etc. executors, we also have generators. Generators are there to allow us to generate (well… dah!!!) new pieces of code from a template.

Let’s say you are creating ui components or an injectable service or angular modules… you don’t want to always write the same boilerplate all over again. Or even worse… copy from a different folder and manually change the names of files, classes, tests, watnot…

And, to make it boring, we want to have the same syntax for everything:

npx nx generate @nrwl/angular:library

This will generate an angular library using a pre-built nrwl plugin for angular. What if we have our own needs and want our own generator? Enters Workspace Generators!

With workspace generators you can build your own generators and run them like this:

npx nx workspace-generator vivid-component my-component

This looks kind of the same but not exactly. Why? Because workspace generator is a separate command. So you are running a different command that runs a generator that’s not part of a plugin. This breaks our boredom (e.g. we have another pattern to remember). Why can’t we have:

npx nx g @vonage/nx-vivid:component my-component

Well… we can, only now it is not a workspace generator – it is an Nx plugin used inside our Nx Workspace!

How to build an Nx Plugin inside an Nx workspace?

That’s the easy part. Let’s do this.

  1. Add the Nx plugin library to the workspace: npm i -D @nrwl/nx-plugin
  2. Generate a plugin library in the workspace: npx nx g @nrwl/nx-plugin:plugin nx-vivid --import-path @vonage/nx-vivid

These two steps result in a new library called nx-vivid which hold a stub generator and a stub executor. It also added an e2e test for the generator. The e2e test helps you test file operations usually done by generators.

Now all that’s left is to actually write the plugin.

How to write the an Nx plugin?

Just like writing any other code. Let’s first see what we want to do. In our repository, we have a components library that holds our UI components library.

All of our components live there inside the src/lib folder.

They all look kind of the same – or at least start the same:

  1. An index entry file
  2. A base class file
  3. A template fille
  4. scss file
  5. Readme file
  6. UI test file

Their content is also pretty repetitive. Because I’m a test first kinda guy, let’s start with the e2e tests.

Writing Nx Plugin E2E

Nx has lots of utilities to write and test plugins. Hence, the tests are as simple as:

import {
  checkFilesExist,
  ensureNxProject,
  readJson,
  runNxCommandAsync,
  uniq,
} from '@nrwl/nx-plugin/testing';

describe('nx-vivid e2e', () => {
  beforeAll(() => {
    ensureNxProject('@vonage/nx-vivid', 'dist/libs/nx-vivid');
  });

  afterAll(() => {
    runNxCommandAsync('reset');
  });

  describe('--directory', () => {
    it('should create src in the specified directory', async () => {
      const project = uniq('nx-vivid');
      await runNxCommandAsync(
        `generate @vonage/nx-vivid:component ${project}`
      );
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/index.ts`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/ui.test.ts`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/README.md`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/${project}.ts`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/${project}.template.ts`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/${project}.spec.ts`)
      ).not.toThrow();
      expect(() =>
        checkFilesExist(`libs/components/src/lib/${project}/${project}.scss`)
      ).not.toThrow();
    }, 120000);
  });
});

Here’s what’s happening:

  1. The code above starts a new Nx workspace (ensureNxProject('@vonage/nx-vivid', 'dist/libs/nx-vivid');).
  2. It then runs the command we want:
    await runNxCommandAsync( generate @vonage/nx-vivid:component ${project} ); . It is equivalent to running npx nx generate @vonage/nx-vivid:component my-project, and that is what we want!
  3. After that it expects to have all the files generated in the library’s folder.

Pretty simple!

Here’s the commit for that one

Can you guess how to run the e2e tests? Prepare your bored yawn: npx nx run nx-vivid-e2e:e2e. The tests fail because we didn’t setup the component’s generator yet!

Adding a generator to Nx Plugin

A generator is composed of 3 main things:

  1. Schema – the schema for the generator’s input
  2. Template files – files with placeholders that will be copied and manipulated into a fully working library/app/component/whatever
  3. The actual logic and its test file (in our case index.ts and index.spec.ts) file – where we tell what should go where.

The schema is a json file along with an optional d.ts file if you want type checking in your logic file.

{
  "$schema": "http://json-schema.org/schema",
  "cli": "nx",
  "$id": "vivid-component",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Component name",
      "$default": {
        "$source": "argv",
        "index": 0
      }
    }
  },
  "required": ["name"]
}

This is how it looks like. There are many types of properties and the Nx mechanism also allows you to ask the consumer questions (like use express or nestjs). You can read more about it in the Nx documentation.

The template files are what you’d expect to see in the output – with placeholders. For instance, you set __fileName__ in the file and folder names as a placeholder for component related file names. Inside the files you use tags like {<%= className %>} or <%= name %> as placeholders for dynamic properties.

Finally, the logic file binds them all. It exports an async function that is used when the generator is called:

import {
  Tree,
  formatFiles,
  names,
  joinPathFragments,
  getWorkspaceLayout,
  generateFiles, offsetFromRoot
} from '@nrwl/devkit';
import {VividComponentGeneratorOptions} from "./schema";
import {join} from "path";

export interface NormalizedSchema extends VividComponentGeneratorOptions {
  fileName: string;
  className: string;
  projectRoot: string;
}

function normalizeOptions(tree: Tree, options: VividComponentGeneratorOptions): NormalizedSchema {
  const projectDirectory = names(options.name).fileName;
  const className = names(options.name).className;

  const name = projectDirectory.replace(new RegExp('/', 'g'), '-');
  const fileName = names(projectDirectory).fileName;

  const { libsDir, npmScope } = getWorkspaceLayout(tree);

  const projectRoot = joinPathFragments(libsDir, 'components/src/lib', projectDirectory);

  return {
    ...options,
    fileName,
    name,
    className,
    projectRoot
  };
}

function createFiles(tree: Tree, options: NormalizedSchema) {
  const {className, name, propertyName} = names(options.name);

  generateFiles(tree, join(__dirname, './files'), options.projectRoot, {
    ...options,
    dot: '.',
    className,
    name,
    propertyName,
    cliCommand: 'nx',
    strict: undefined,
    tmpl: '',
    offsetFromRoot: offsetFromRoot(options.projectRoot)
  });
}

export default async function vividComponentGenerator(tree: Tree, schema: VividComponentGeneratorOptions) {
  const options = normalizeOptions(tree, schema);
  createFiles(tree, options);
  await formatFiles(tree);
}

In the above example, the function vividComponentGenerator is exported. It handles the options received from the user, creates the files and then runs formatFiles which, well… formats the files (linting mostly).

Finally, we need to tell the plugin that the generator exists and how to reach it. This is done in the main plugin’s generators.json file:

{
  "$schema": "http://json-schema.org/schema",
  "name": "nx-vivid",
  "version": "0.0.1",
  "generators": {
    "component": {
      "factory": "./src/generators/component/index",
      "schema": "./src/generators/component/schema.json",
      "description": "nx-vivid component generator"
    }
  }
}

After adding all of these, the e2e tests pass.

View the code in the commit of this part.

Testing your Nx plugin

We saw the e2e tests for the plugin. Note that I’ve also written unit tests (in the commit). I actually write them before I write the actual code. While TDD is beyond the scope of this article, let’s talk about the tests of a plugin.

The unit tests in this case do the same thing the e2e tests do – they make sure the right files are generated when we give a certain input (e.g. the component’s name):

import {names, Tree} from '@nrwl/devkit';
import {createTreeWithEmptyWorkspace} from '@nrwl/devkit/testing';
import {VividComponentGeneratorOptions} from './schema';
import vividComponentGenerator from './index';
describe(`vivid component generator`, function () {
let tree: Tree;
const options: VividComponentGeneratorOptions = {
name: 'test-component'
};
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it(`should generate files`, async function () {
const {fileName} = names(options.name);
await vividComponentGenerator(tree, options);
expect(tree.exists(`libs/components/src/lib/${options.name}`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/index.ts`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/README.md`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/ui.test.ts`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/${fileName}.ts`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/${fileName}.spec.ts`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/${fileName}.template.ts`))
.toBeTruthy();
expect(tree.exists(`libs/components/src/lib/${options.name}/${fileName}.scss`))
.toBeTruthy();
});
});

In the code snippet above, we use Nx devkit to generate a virtual filetree of our workspace. We then run the generator with the needed options. We expect the resulting tree to have the files from the template with the placeholders replaced.

The rule of thumb in tests is this: if you can cover the same thing with unit tests and E2E – prefer unit tests. They are much faster. As simple as that.

Why does Nx set us up with an E2E infrastructure for plugins then? While talking with Craigory from Nrwl, he gave quite a definite answer:

Advice about NX workspace Testing by a Nrwlist:
"Ideally, you should have some e2e test that generates an app with your custom executor and then runs the target that consumes it.
But if you aren't doing executors, they are much less useful"

This answer is great for people who love rules of thumb:

  1. It supports our rule of thumb for “more unit – less e2e”
  2. I gives us another rule of thumb specific to Nx plugins – “e2e is for executors – unit is for generators”

Summary

Standards are important. They contribute to Developer Experience, scalability, agility and I probably forgot some benefits of standards.

Nx allows you to utilize standards and not only that – its mechanism compels you to standardize your workspace.

We migrated from Lerna to Nx and bless the day. Everything is standardized. Even custom generators, linters and others look the same for all developers. A developer that works on the documentation uses the same command syntax as the developer working on the components or any other part. Even the generator plugin itself has the same developer experience.

I didn’t mention the other benefits you can get like dependency graphs, dry run for almost everything, parallel execution, caching, cloud caching and much more.

Where do we go from here? Well – this generator is for generating an internal code snippet in our own library. This was our first designs due to some build limitations. We believe we overcome these limitations and intend to extract the components from the lib – so each component will live “alone”.

This means, our generator will generate full libraries for components instead of generating code snippets inside an existing library. Or better yet, we can compose a component library generator that will use our component generator under the hood.

Thanks a lot to Craigory from Nrwl for a great review and discussion!

Featured Photo by Carl Heyerdahl on Unsplash

Sign up to my newsletter to enjoy more content:

5 1 vote
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments