Integrate a New Tool into an Nx Repository with a Tooling Plugin

Nx Plugins can be used to easily integrate a tool or framework into an Nx repository. If there is no plugin available for your favorite tool or framework, you can write your own.

In this tutorial, we'll create a plugin that helps to integrate the Astro framework. Astro is a JavaScript web framework optimized for building fast, content-driven websites. We'll call our plugin nx-astro.

To create a plugin in a brand new repository, use the create-nx-plugin command:

npx create-nx-plugin nx-astro

Skip the create-* package prompt, since we won't be creating a preset.

Understand Tooling Configuration Files

When integrating your tool into an Nx repository, you first need to have a clear understanding of how your tool works. Pay special attention to all the possible formats for configuration files, so that your plugin can process any valid configuration options.

For our nx-astro plugin, we'll read information from the astro.config.mjs or astro.config.ts file. We'll mainly be interested in the srcDir, publicDir and outDir properties specified in the defineConfig object. srcDir and publicDir define input files that are used in the build process and outDir defines what the build output will be created.

astro.config.mjs
1import { defineConfig } from 'astro/config'; 2 3// https://astro.build/config 4export default defineConfig({ 5 srcDir: './src', 6 publicDir: './public', 7 outDir: './dist', 8}); 9

Create an Inferred Task

The easiest way for people integrate your tool into their repository is for them to use inferred tasks. When leveraging inferred tasks, all your users need to do is install your plugin and the tool configuration file to their projects. Your plugin will take care of registering tasks with Nx and setting up the correct caching settings.

Once the inferred task logic is written, we want to be able to automatically create a task for any project that has a astro.config.* file defined in the root of the project. We'll name the task based on our plugin configuration in the nx.json file:

nx.json
1{ 2 "plugins": [ 3 { 4 "plugin": "nx-astro", 5 "options": { 6 "buildTargetName": "build", 7 "devTargetName": "dev" 8 } 9 } 10 ] 11} 12

If the astro.config.mjs for a project looks like our example in the previous section, then the inferred configuration for the build task should look like this:

1{ 2 "command": "astro build", 3 "cache": true, 4 "inputs": [ 5 "{projectRoot}/astro.config.mjs", 6 "{projectRoot}/src/**/*", 7 "{projectRoot}/public/**/*", 8 { 9 "externalDependencies": ["astro"] 10 } 11 ], 12 "outputs": ["{projectRoot}/dist"] 13} 14

To create an inferred task, we need to export a createNodesV2 function from the plugin's index.ts file. The entire file is shown below with inline comments to explain what is happening in each section.

src/index.ts
1import { 2 CreateNodesContextV2, 3 CreateNodesV2, 4 TargetConfiguration, 5 createNodesFromFiles, 6 joinPathFragments, 7} from '@nx/devkit'; 8import { readdirSync, readFileSync } from 'fs'; 9import { dirname, join, resolve } from 'path'; 10 11// Expected format of the plugin options defined in nx.json 12export interface AstroPluginOptions { 13 buildTargetName?: string; 14 devTargetName?: string; 15} 16 17// File glob to find all the configuration files for this plugin 18const astroConfigGlob = '**/astro.config.{mjs,ts}'; 19 20// Entry function that Nx calls to modify the graph 21export const createNodesV2: CreateNodesV2<AstroPluginOptions> = [ 22 astroConfigGlob, 23 async (configFiles, options, context) => { 24 return await createNodesFromFiles( 25 (configFile, options, context) => 26 createNodesInternal(configFile, options, context), 27 configFiles, 28 options, 29 context 30 ); 31 }, 32]; 33 34async function createNodesInternal( 35 configFilePath: string, 36 options: AstroPluginOptions, 37 context: CreateNodesContextV2 38) { 39 const projectRoot = dirname(configFilePath); 40 41 // Do not create a project if package.json or project.json isn't there. 42 const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); 43 if ( 44 !siblingFiles.includes('package.json') && 45 !siblingFiles.includes('project.json') 46 ) { 47 return {}; 48 } 49 50 // Contents of the astro config file 51 const astroConfigContent = readFileSync( 52 resolve(context.workspaceRoot, configFilePath) 53 ).toString(); 54 55 // Read config values using Regex. 56 // There are better ways to read config values, but this works for the tutorial 57 function getConfigValue(propertyName: string, defaultValue: string) { 58 const result = new RegExp(`${propertyName}: '(.*)'`).exec( 59 astroConfigContent 60 ); 61 if (result && result[1]) { 62 return result[1]; 63 } 64 return defaultValue; 65 } 66 67 const srcDir = getConfigValue('srcDir', './src'); 68 const publicDir = getConfigValue('publicDir', './public'); 69 const outDir = getConfigValue('outDir', './dist'); 70 71 // Inferred task final output 72 const buildTarget: TargetConfiguration = { 73 command: `astro build`, 74 options: { cwd: projectRoot }, 75 cache: true, 76 inputs: [ 77 '{projectRoot}/astro.config.mjs', 78 joinPathFragments('{projectRoot}', srcDir, '**', '*'), 79 joinPathFragments('{projectRoot}', publicDir, '**', '*'), 80 { 81 externalDependencies: ['astro'], 82 }, 83 ], 84 outputs: [`{projectRoot}/${outDir}`], 85 }; 86 const devTarget: TargetConfiguration = { 87 command: `astro dev`, 88 options: { cwd: projectRoot }, 89 }; 90 91 // Project configuration to be merged into the rest of the Nx configuration 92 return { 93 projects: { 94 [projectRoot]: { 95 targets: { 96 [options.buildTargetName]: buildTarget, 97 [options.devTargetName]: devTarget, 98 }, 99 }, 100 }, 101 }; 102} 103

We'll test out this inferred task a little later in the tutorial.

Inferred tasks work well for getting users started using your tool quickly, but you can also provide users with executors, which are another way of encapsulating a task script for easy use in an Nx workspace. Without inferred tasks, executors must be explicitly configured for each task.

Create an Init Generator

You'll want to create generators to automate the common coding tasks for developers that use your tool. The most obvious coding task is the initial setup of the plugin. We'll create an init generator to automatically register the nx-astro plugin and start inferring tasks.

If you create a generator named init, Nx will automatically run that generator when someone installs your plugin with the nx add nx-astro command. This generator should provide a good default set up for using your plugin. In our case, we need to register the plugin in the nx.json file.

To create the generator run the following command:

npx nx g generator init --directory=src/generators/init

Then we can edit the generator.ts file to define the generator functionality:

src/generators/init/generator.ts
1import { formatFiles, readNxJson, Tree, updateNxJson } from '@nx/devkit'; 2import { InitGeneratorSchema } from './schema'; 3 4export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { 5 const nxJson = readNxJson(tree) || {}; 6 const hasPlugin = nxJson.plugins?.some((p) => 7 typeof p === 'string' ? p === 'nx-astro' : p.plugin === 'nx-astro' 8 ); 9 if (!hasPlugin) { 10 if (!nxJson.plugins) { 11 nxJson.plugins = []; 12 } 13 nxJson.plugins = [ 14 ...nxJson.plugins, 15 { 16 plugin: 'nx-astro', 17 options: { 18 buildTargetName: 'build', 19 devTargetName: 'dev', 20 }, 21 }, 22 ]; 23 } 24 updateNxJson(tree, nxJson); 25 await formatFiles(tree); 26} 27 28export default initGenerator; 29

This will automatically add the plugin configuration to the nx.json file if the plugin is not already registered.

We need to remove the generated name option from the generator schema files so that the init generator can be executed without passing any arguments.

src/generators/init/schema.d.ts
1export interface InitGeneratorSchema {} 2

Create an Application Generator

Let's make one more generator to automatically create a simple Astro application. First we'll create the generator:

npx nx g generator application --directory=src/generators/application

Then we'll update the generator.ts file to define the generator functionality:

src/generators/application/generator.ts
1import { 2 addProjectConfiguration, 3 formatFiles, 4 generateFiles, 5 Tree, 6} from '@nx/devkit'; 7import * as path from 'path'; 8import { ApplicationGeneratorSchema } from './schema'; 9 10export async function applicationGenerator( 11 tree: Tree, 12 options: ApplicationGeneratorSchema 13) { 14 const projectRoot = `${options.name}`; 15 addProjectConfiguration(tree, options.name, { 16 root: projectRoot, 17 projectType: 'application', 18 sourceRoot: `${projectRoot}/src`, 19 targets: {}, 20 }); 21 generateFiles(tree, path.join(__dirname, 'files'), projectRoot, options); 22 await formatFiles(tree); 23} 24 25export default applicationGenerator; 26

The generateFiles function will use the template files in the files folder to add files to the generated project.

src/generators/application/files/package.json__templ__
1{ 2 "name": "<%= name %>", 3 "dependencies": {} 4} 5

The generator options in the schema files can be left unchanged.

Test Your Plugin

The plugin is generated with a default e2e test (e2e/src/nx-astro.spec.ts) that:

  1. Launches a local npm registry with Verdaccio
  2. Publishes the current version of the nx-astro plugin to the local registry
  3. Creates an empty Nx workspace
  4. Installs nx-astro in the Nx workspace

Let's update the e2e tests to make sure that the inferred tasks are working correctly. We'll update the beforeAll function to use nx add to add the nx-astro plugin and call our application generator.

e2e/src/nx-astro.spec.ts
1beforeAll(() => { 2 projectDirectory = createTestProject(); 3 4 // The plugin has been built and published to a local registry in the jest globalSetup 5 // Install the plugin built with the latest source code into the test repo 6 execSync('npx nx add nx-astro@e2e', { 7 cwd: projectDirectory, 8 stdio: 'inherit', 9 env: process.env, 10 }); 11 execSync('npx nx g nx-astro:application my-lib', { 12 cwd: projectDirectory, 13 stdio: 'inherit', 14 env: process.env, 15 }); 16}); 17

Now we can add a new test that verifies the inferred task configuration:

e2e/src/nx-astro.spec.ts
1it('should infer tasks', () => { 2 const projectDetails = JSON.parse( 3 execSync('nx show project my-lib --json', { 4 cwd: projectDirectory, 5 }).toString() 6 ); 7 8 expect(projectDetails).toMatchObject({ 9 name: 'my-lib', 10 root: 'my-lib', 11 sourceRoot: 'my-lib/src', 12 targets: { 13 build: { 14 cache: true, 15 executor: 'nx:run-commands', 16 inputs: [ 17 '{projectRoot}/astro.config.mjs', 18 '{projectRoot}/src/**/*', 19 '{projectRoot}/public/**/*', 20 { 21 externalDependencies: ['astro'], 22 }, 23 ], 24 options: { 25 command: 'astro build', 26 cwd: 'my-lib', 27 }, 28 outputs: ['{projectRoot}/./dist'], 29 }, 30 dev: { 31 executor: 'nx:run-commands', 32 options: { 33 command: 'astro dev', 34 cwd: 'my-lib', 35 }, 36 }, 37 }, 38 }); 39}); 40

Next Steps

Now that you have a working plugin, here are a few other topics you may want to investigate: