💎 Discovering Nx Project Crystal’s Magic

💎 Discovering Nx Project Crystal’s Magic

Navigating the Evolution of Nx Configuration Towards Effortless Development


Overview

After delving into this feature in my previous articles:

I’m excited to discuss again one of my favorite Nx features: “The Inferred Project Configurations.” The Nx team now introduces a unified approach known as Nx Project Crystal 💎.

In this article, I’ll cover:

· The Origin
· Project Crystal in a Nutshell
· How it works
· Create Your Cyrstal Plugin
· A Multitude of Benefits
· Cautions

The Origin

To grasp the concept of Nx Project Crystal 💎, let’s revisit the Nx team’s journey in project configuration.

1. angular.json

“In the beginning, there was nothing”. The Nx team began defining a monorepo structure for apps and libs using angular.json, tied to Angular CLI:

However, this initial approach was specific to Angular, lacking generality and extensibility.

2. workspace.json

A v2 schema introduced theworkspace.json files**.** Some concepts were generalized such as the executors (instead of a builder) or the generators (instead of schematics).

The new format proved useful due to its extensibility and generality. However, maintaining a large codebase within a single file presented challenges. Consequently, the demand for greater flexibility and project-specific specifications increased.

3. project.json

Thus, the concept of distributed configuration emerged with the project.json (or package.json) file. It maintained the same format but was divided and specified at the root of each project.

Defining a project has become straightforward — simply add that file, and you’re done. However, the proliferation of files implies challenging maintenance due to significant duplication. This situation necessitated the development of migration scripts to manage these configurations effectively.

Additionally, there emerged a requirement to support technologies not adhering to Nx’s standards, enabling the integration of Nx benefits into existing repositories and accommodating various technologies, including .Net, among others.

4. Inferred configuration

Then, project inference appeared, introducing the possibility of assigning project configurations simply through glob pattern matching. An Nx plugin exposes specific functions that are automatically loaded by the Nx core, and voilà, you have dynamic configuration :)

It began with Plugin v1, which only assigned a list of targets for existing projects (see 🚡 Nx Targets Elevated).

Then came Plugin v2, offering the ability to dynamically add project nodes with full configuration, even if they do not exist in your codebase (see ✌️ Nx Plugin v2: Dynamic Project Configurations).

And finally, a consolidated approach to that concept was integrated into Nx:

Project Crystal in a Nutshell

The Nx Project Crystal offers the ability to utilize Nx plugins that automatically add tasks to your projects based on the configuration files of various tools.

I recommend watching the great videoNx — Project Crystaland reading the related articleWhat if Nx Plugins Were More Like VSCode ExtensionsbyJuri Strumpflohner.

The Nx team likes to compare it to a simple plugin that you’ll add to your favorite IDE. You don’t need to do anything else; the plugin activates functionalities automatically because it recognizes specific configurations or patterns in your workspace.

For example, if you want to use Vite to build your application, you simply need to add the @nx/vite plugin to your Nx workspace by using:

nx add @nx/vite

Automatically, all projects that contain a vite.config.* file will have new tasks assigned. You will be able to use build, preview, test, serve and serve-static directly:

This is true for Vite, but it is also applies to many other plugins, such as:

Why don’t I see the main plugins like@nx/angularor@nx/react?

Your Angular/React project configurations have already been simplified through the removal of common targets like lint or test.

However, it’s challenging to generalize targets like build or serve because they involve many specifications unique to your app.

IMO, it’s only a matter of time before theNx team proposes a solution for this :).

How it works

Let’s take a look behind the scenes to understand how we can benefit from this feature.

As you can see, Nx computes the project graph configuration by loading configurations from multiple places:

  • First, it iterates over the plugin list declared in your nx.json and calls the createNodes function for each.

  • Then, it reads targetDefaults in the nx.json file and it applies the default configuration to the corresponding targets.

  • Finally, it uses the configuration from the project.json (or package.json), if specified at the root of the project.

How do I know which task is available at the end?

Nx provided a solution for that too! You can explore your Nx workspace and see the consolidated configuration simply by running the command:

nx show project myreactapp --web

And you will be able to visualize all of the configurations for your project

This is also visible in theNx console pluginin your IDE!

Create Your Cyrstal Plugin

Let’s explore how quickly you can implement your plugin, injecting your configurations and behaviors into your workspace.

Nx Plugin

The first step is to create an Nx plugin and configure it in your nx.json.

It can be a simple *.ts file located anywhere in your workspace that you register in your nx.json:

{
  "plugins": [..., "./tools/plugins/my-plugin.ts"],
}

Or you can use the plugin generator by executing the command:

nx g @nx/plugin:plugin my-plugin

And again, you’ll need to register your plugin in your nx.json configuration file:

{
  "plugins": [..., "@my-org/my-plugin"]
}

You can also provide options to your plugin by using the following syntax in your nx.josn:

{
  "plugins": [
    ...,
    {
      "plugin": "@my-org/my-plugin",
      "options": {
        "targetName": "build"
      }
    }
  ],
}

Export the createNodes from your plugin

Then, you can begin implementing your plugin. I highly recommend examining some existing Nx plugin implementations:

I chose the @nx/vite plugin to illustrate the mechanism:

Many functions are omitted because they are specific to the plugin and not necessary to grasp the main structure of the plugin.

I divided the code into three main parts:

A. The plugin options contract: Typically used to define the names of the targets you wish to generate, but it can be used for anything.

B. The targets memoization: Since generating the configuration graph is resource-intensive, the memoizationpattern is essential to prevent unnecessary computation when nothing changes.

C. The maincreateNodesfunction: This function is invoked by Nx and returns new project configurations.

  1. The createNodes function is a pair consisting of a glob pattern to determine when the plugin should be activated and a function that receives the activation context as a parameter and can return a project configuration.

  2. The configFilePath contains the path matching the glob pattern, representing the project’s root path.

  3. Even if the glob pattern matches, it’s possible to filter out and skip execution if it doesn’t meet a specific criterion. Here, the criterion is to assign tasks only to existing projects.

  4. Since the options are optional, they simply provide a way to set default values for target names.

  5. This forms the core of the function, generating all tasks or retrieving them from the cache if they exist.

  6. It then creates and returns the project configuration to be incorporated into the global project configuration graph.

A Multitude of Benefits

You’ll gain from project inference and creating your plugin in several compelling use cases.

Remove duplication of configurations

Adhering to the “Don’t Repeat Yourself” principle becomes straightforward. If your workspace is cluttered with redundant project.json configurations, creating plugins to inject common elements significantly eases long-term maintenance.

Centralize your workspace conventions

As I discussed in my previous article ⚡ The Super Power of Conventions with Nx, for teams managing large codebases, enhancing the development experience through established conventions is invaluable. These can be rules or best practices, encapsulated within an Nx plugin to uniformly apply across your workspace.

Speed up the Nx adoption on the existing repository!

That’s one of my favorite use cases, just add Nx and plugins to your existing repo, and you’ll access many functionalities automatically.

But before having the possibility to execute tasks, you need to ensure that Nx can see your projects. The first reflex is to create a project.json file everywhere you want to declare a project.

However, it will be difficult if you have to define hundreds of project.json with the same configuration. It can also be projects that are not even related to JavaScript projects. In that case, the creation of custom plugins will facilitate that definition in a short time.

For example, if you have a huge list of themes containing only *.scss files like:

.
├── apps/
└── libs/
    └── themes/
        ├── core/
        ├── blue/
        │   ├── components/
        │   ├── colors.scss
        │   ├── variables.scss
        │   └── index.scss
        ├── dark/
        │   └── ...
        ├── light/
        │   └── ...
        ├── yellow/
        │   └── ...
        └── pink/
            └── ...

If you want to see the dependencies between the applications and the themes, you can simply create a plugin that will expose the themes as projects in Nx:

export const createNodes: CreateNodes = [
  'libs/themes/**/index.scss',
  (configFilePath, options, context) => {
    const name = toProjectName(configFilePath);
    return {
      projects: {
        [`theme-${name}`]: {
          name,
          root: dirname(configFilePath),
          targets: {
            build:{
              // ...
            }
          }
        },
      },
    };
  },
];

Automatically, without having to create any project.json file, you will see them in your dependency graph and gain the benefit of Nx features such as affected, caching, boundaries, etc.

Task Distributions on CI

Another interesting use case is the dynamic creation of tasks to parallelize executions that could be time-consuming.

By dividing long-running tasks into multiple tasks, you can benefit from the Nx Distribution Task Execution on CI and parallelize all tasks.

For example, Nx has implemented this approach for Cypress or Playwright by automatically splitting E2E tasks by file.

If you are interested in technical details, you can also read my article⚡ Distributed e2e Task Execution with Nx for Playwright and Cypress

Less complex generators and migrations

When I started using the project inference, the main concern was “It will break themigration process of Nx!”

Indeed! But before Nx Project Crystal, project inference was only used for specific use cases with custom executors not covered by Nx migrations.

🤯 Previously, supporting advanced project configurations required creating complex generators for complex project.json files.

Maintaining these was challenging. With every change, we needed to update the generator and create a migration script to apply modifications across all projects.

This was time-consuming and frustrating, especially when migrations were buggy.

😎 Now, with the project inference approach, you can centralize your specific configurations. If something changes, you simply update your plugins, and the changes are automatically applied to all projects.

No need for complex generators or migrations anymore!

Cautions

Plugins Order Matters!

As highlighted in the flow chart, configurations are not deeply merged, meaning if two plugins configure the same target name, only the last one will take precedence.

For instance, if your nx.json includes:

{
  "plugins": ["@nx/cypress/plugin", "@nx/playwright/plugin"]
}

And a project contains both Cypress and Playwright tests, Nx will first invoke @nx/cypress and then @nx/playwright.

To address this, you can rename one of the targets using the plugin options in nx.json:

{
  "plugins": [
    {
      "plugin": "@nx/cypress/plugin",
      "options": {
        "targetName": "e2e-legacy"
      }
    },
    "@nx/playwright/plugin"
  ]
}

This allows you to run both targets:

Configs Order Matters!

It’s also important to note that targetDefaults configurations in your nx.json take precedence over plugins.

For example, if a plugin returns a configuration like:

{
  "my-app": {
    "build": {
      "executor": "@angular-devkit/build-angular:application",
      "dependsOn": ["^build", "generate-api"]
    },
    "generate-api": {
      "executor": "..."
    }
  }
}

But in your nx.json, you’ve specified targetDefaults like:

{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

Then your generate-api target won’t be executed because the targetDefaults will override the dependsOn configuration.

I would recommend being specific in your targetDefaults to prevent conflicts with the plugins.

{
  "targetDefaults": {
    "@angular-devkit/build-angular:application": {
      "dependsOn": ["^build", "generate-api"]
    }
  }
}

Final Thoughts

The journey the Nx team embarked on to bring Project Crystal to life has been truly inspiring. From the early days of Angular-specific setups to the seamless approach offered by Project Crystal, every step has shown their dedication to making our lives as developers easier.

Looking ahead, the idea of Zero Configuration repositories sounds like an exciting leap forward. It promises a future where setting up and managing projects will be a breeze, giving us more time to focus on what we love: building awesome software.

So let’s embrace tools like Nx Project Crystal with open arms. They’re here to help us work smarter, not harder, and together, we can unlock endless possibilities for innovation and creativity in our development journeys.


Looking for some help?🤝
Connect with me on
TwitterLinkedInGithub