# Angular CLI under the hood - builders demystified

Published 2021-08-08 · https://www.justjeb.com/post/angular-cli-under-the-hood-builders-demystified

An in-depth overview of internal Angular CLI APIs and example of custom builder

*Hey folks. Hope the sun is shining for you today.*

Today we’ll **take a look under the hood and create our own custom builder from scratch**.

## Angular CLI builders

Angular CLI 6 came with a new architecture, basically a rewrite of the old CLI, which was broken down into small pieces. Though its API was still experimental and subject to change.

*Angular CLI 8* was an important milestone as this was the first time the experimental API has been well defined and stabilized.

Now we’re going to review the API and understand what it consists of and how to use it.

Here are 3 important parts of Angular CLI:

- [Angular CLI package](https://github.com/angular/angular-cli/tree/master/packages/angular/cli) contains pre-defined commands, help and CLI related stuff.

- [Architect package](https://github.com/angular/angular-cli/tree/master/packages/angular_devkit/architect) handles the configuration from *angular.json*. It is responsible for mapping the architect target into the relevant builder, create the builder and trigger it with the configuration specified in *angular.json* for this builder.

- Builders are the ones who do the actual job. Thus, [BrowserBuilder](https://github.com/angular/angular-cli/tree/master/packages/angular_devkit/build_angular/src/builders/browser) runs Webpack build for browser target, [KarmaBuilder](https://github.com/angular/angular-cli/tree/master/packages/angular_devkit/build_angular/src/builders/karma) starts the Karma server and runs Webpack build for unit tests and so on.

## Angular CLI commands and architect targets

When you run **ng build** or **ng test** or any of the [predefined Angular CLI commands](https://angular.dev/cli) a few things happen:

- Angular CLI command is transformed into a relevant architect target

- A relevant builder is created

- A relevant builder is triggered with the relevant configuration

When you run a custom architect target, the following happens:

- A relevant builder is created

- A relevant builder is triggered with the relevant configuration

As you can see the only difference between the predefined command and custom architect target is that in the latter there is no mapping from the Angular CLI command to an architect target.

In a nutshell there is one generic command **ng run**, that receives an architect target as an argument (in **project:target** format) and asks the architect to execute this command.

Thus, *each one* of the predefined Angular CLI commands that are mapped to an architect target can be executed with **ng run**. E.g:

- **ng build**: **ng run my-cool-project:build**

- **ng test**: **ng run my-cool-project:test**

And so on…

The beauty is that once you’ve created your own builder you can put it in any architect target you want:

You can create your own target, call it **my-target** and execute it with **ng run my-cool-project:my-target**

OR

You can replace the builder in one of the existing targets (say, build target) and execute it with the predefined Angular CLI command ( **ng build** ), because as we’ve seen, Angular CLI commands are just *mappings into relevant architect targets*.

## Architect targets configuration

Let’s take a closer look at the ***angular.json*** file:

```json
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "example": {
      "root": "",
      "sourceRoot": "src",
      "projectType": "application",
      "prefix": "app",
      "schematics": {},
      "architect": {
        "build": {
            ...
        },
        "serve": {
            ...
        },
      }
    }
  }
}
```

Inside each project there is an entry called **architect** and it contains *architect targets* configurations. Thus, in this particular example we have only one project called **example** which, in turn, has two architect targets: **build** and **serve**. If you wanted to add another architect target called, say, **format**, the file would have become:

```json
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "example": {
      "root": "",
      "sourceRoot": "src",
      "projectType": "application",
      "prefix": "app",
      "schematics": {},
      "architect": {
        "build": {
            ...
        },
        "serve": {
            ...
        },
        "format": {
            ...
        }
      }
    }
  }
}
```

Every architect target configuration has 3 properties:

- **builder** — path to the builder. The format of the path is **\[package-path]:\[builder-name]**, where **\[package-path]** is a path to a folder with ***package.json*** containing ***builders*** entry and **\[builder-name]** is one of the entries in ***builders.json*** (we’ll return to this later)

- **options** — the configuration of the builder. Must match the builder configuration schema, otherwise the command will fail.

- **configurations** — a map of alternative target options (prod, dev etc.). This is an optional property.

That’s pretty much it for the theoretical background.

Enough talks, let’s do something real!

## Creating your own builder

I’m not a fan of doing things in vain, so I had to come up with something more than just *Hello World Builder,* yet as simple as *Hello World Builder.*

So imagine you want to display the date and the time on which your application was built last time. The system is loading up, fetching some file that contains the timestamp for the latest build and the date is displayed in the page footer.

What we’re going to do is implement a builder that creates this timestamp file.

### Creating the package

A single package can contain multiple builders but in our case it will contain only one.

First thing after you’ve created a folder for your builders package is adding *package.json* into this folder (architect assumes that builders package is an npm package). This *package.json* is just a plain [*package.json*](https://docs.npmjs.com/files/package.json) file with one additional entry:

```json
"builders": "builders.json"
```

*Spoiler: the file doesn’t have to be builders.json, can be any name you choose.*

### builders.json

**builders.json** is a file that describes your builders. It’s a json file that follows Angular builders [schema](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/architect/src/builders-schema.json) and has the following structure:

```json
{
  "$schema": "@angular-devkit/architect/src/builders-schema.json",
  "builders": {
    "builder-name": {
      "implementation": "path-to-builder-class",
      "schema": "path-to-builder-schema",
      "description": "builder-description"
    },
    ... more builders definitions
  }
}
```

Single **builders.json** can contain definitions for multiple builders.

### Builder definition

Each builder is defined by three properties:

1. **implementation** — path to a Javascript file that exports the builder as default. Architect will parse the configuration and create an instance of the builder.

2. **schema** — path to json schema that defines builder configuration (**options** property in architect target definition). Architect verifies the configuration against this schema and if the configuration is wrong it will fail the target.

3. **description** — a short description of what the builder does.

Here what our *builders.json* will look like:

```json
{
  "$schema": "@angular-devkit/architect/src/builders-schema.json",
  "builders": {
    "file": {
      "implementation": "./dist/index.js",
      "schema": "./schema.json",
      "description": "Builder that creates timestamp"
    }
  }
}
```

### schema.json

Let’s say we want to allow the user to modify the format of the timestamp and the name of the file to which the timestamp will be saved.

Thus, our *schema.json* will look like this:

```json
{
  "id": "TimestampBuilderSchema",
  "title": "Timestamp builder",
  "description": "Timestamp builder options",
  "properties": {
    "format": {
      "type": "string",
      "description": "Timestamp format",
      "default": "dd/mm/yyyy"
    },
    "path": {
      "type": "string",
      "description": "Path to the timestamp file",
      "default": "./timestamp"
    }
  }
}
```

If the user hasn’t specified any options in the architect target configuration, architect will pick up the defaults from the schema.

### Installing dependencies

To format the Date we will use [dateformat](https://www.npmjs.com/package/dateformat) package, let’s install it:

```bash
npm i dateformat@4
```

We’re going to develop our builder with Typescript (though it’s not mandatory) so we have to install it too. We will also seize the functionality of **@angular-devkit/core** as well as some of the types from **@angular-devkit/architect**. To benefit from Typescript static typing we will probably want to install **@types** for **node** and **dateformat**.

This is it for *devDependencies* (**@angular-devkit** will be used at runtime but rather as a peer dependency). Let’s install them:

```bash
npm i -D @angular-devkit/core @angular-devkit/architect @types/node @types/dateformat@3 typescript
```

### The builder

Now we’re ready to implement the builder itself. First of all let’s define our builder configuration as an interface in *schema.d.ts*:

```ts
export interface TimestampBuilderSchema {
	format: string;
	path: string;
}
```

*Another option is to generate the interface from schema.json using* [*quicktype*](https://quicktype.io/)*.*

Once we have the interface we can implement the skeleton of a builder:

```ts
import { TimestampBuilderSchema } from './schema';
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { Observable } from 'rxjs';
import { json } from '@angular-devkit/core';

export function createTimestamp(
    { path, format }: TimestampBuilderSchema,
    { workspaceRoot, logger }: BuilderContext,
): Observable<BuilderOutput> {

}

export default createBuilder<json.JsonObject & TimestampBuilderSchema>(createTimestamp);
```

**createTimestamp** is our builder handler function, which receives an input from the architect and returns **Observable\<BuilderOutput> , Promise\<BuilderOutput>** or just **BuilderOutput**. Which one of them is up to you to decide. In our example it will return an Observable.

Here is an official type definition of builder handler function, taken from [here](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/architect/src/api.ts):

```ts
/**
 * A builder handler function. The function signature passed to `createBuilder()`.
 */
export interface BuilderHandlerFn<A> {
  /**
   * Builders are defined by users to perform any kind of task, like building, testing or linting,
   * and should use this interface.
   * @param input The options (a JsonObject), validated by the schema and received by the
   *     builder. This can include resolved options from the CLI or the workspace.
   * @param context A context that can be used to interact with the Architect framework.
   * @return One or many builder output.
   */
  (input: A, context: BuilderContext): BuilderOutputLike;
}
```

**BuilderOutput** is an object whose purpose is to notify the architect of the build result. Here is the typing (inferred from [this](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/architect/src/output-schema.json) schema):

```ts
export interface Schema {
    error?: string;
    info?: {
        [key: string]: any;
    };
    success: boolean;
    target?: Target;
}
```

**BuilderOutput** will notify the architect of successful or unsuccessful execution, and architect, in turn, will pass the execution result to CLI that will eventually finish the process with appropriate exit value.

Finally, we create the builder implementation by invoking **createBuilder** function. This function essentially connects our builder handler to the architect and creates an actual builder. We then export the result of this function as default export which is later consumed by architect.

Below is the **createBuilder** typing taken from [here](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/architect/src/create-builder.ts):

```ts
export function createBuilder<OptT = json.JsonObject, OutT extends BuilderOutput = BuilderOutput>(
  fn: BuilderHandlerFn<OptT>,
): Builder<OptT & json.JsonObject>
```

Now when we understand the API, we can implement the builder.

In our case we want to return success if the file with the timestamp was successfully created and failure otherwise:

```ts
import { TimestampBuilderSchema } from './schema';
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { Observable, bindNodeCallback, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { getSystemPath, normalize, json } from '@angular-devkit/core';
import { writeFile } from 'fs';
import * as dateFormat from 'dateformat';

export function createTimestamp(
    { path, format }: TimestampBuilderSchema,
    { workspaceRoot, logger }: BuilderContext,
): Observable<BuilderOutput> {
    const timestampFileName = `${getSystemPath(normalize(workspaceRoot))}/${path}`;
    const writeFileObservable = bindNodeCallback(writeFile);
    const timestampLogger = logger.createChild('Timestamp');
    return writeFileObservable(timestampFileName, dateFormat(new Date(), format)).pipe(
        map(() => ({ success: true })),
        tap(() => timestampLogger.info("Timestamp created")),
        catchError(e => {
            timestampLogger.error("Failed to create timestamp", e);
            return of({ success: false });
        })
    );
}

export default createBuilder<json.JsonObject & TimestampBuilderSchema>(createTimestamp);
```

Let’s break it down:

1. We get **workspaceRoot** and **logger** from **BuilderContext**.

2. We retrieve the **path** and the **format** from the options. These should be specified in architect target configuration in ***angular.json*** of host application. If none were specified, the default values will be taken from the builder’s schema.

3. **getSystemPath** is a utility function that returns system specific path. We concatenate it with the relative **path** from the options, while using **normalize** to create a **Path** object from a **string**.

4. We use [**writeFile**](https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback) function from **fs** module but since we have to return an **Observable** and **writeFile** works with callbacks, we use [**bindNodeCallback**](https://rxjs.dev/api/index/function/bindNodeCallback) function to transform it into a function that returns **Observable**.

5. We create a child logger to separate our builder output from the rest of the architect logs. All the **timestamp** builder logs will be prepended with *Timestamp* prefix.

6. We format the date with the **dateFormat** function while using the **format** we’ve got from the options and write the formatted date to the file.

7. Finally we return success if the file was created successfully and return failure otherwise.

*Side note: use the logger to provide build information to the user*

Compile the source code to JavaScript and you’re good to go.

## Using the builder

Now when the builder is ready you can use it by either specifying a relative path to the folder in *angular.json:*

```json
"architect": {
        "timestamp": {
          "builder": "[relative-path-to-timestamp-package]:file",
          "options": {}
        }
}
```

… or packing it into npm package and installing it locally:

```bash
npm pack
cp angular-builders-timestamp-1.0.0.tgz [host-application-root]
cd [host-application-root]
npm i -D angular-builders-timestamp-1.0.0.tgz
```

***angular.json****:*

```json
"architect": {
        "timestamp": {
          "builder": "@angular-builders/timestamp:file",
          "options": {}
        }
}
```

… or publishing it on npm and installing from there.

## Finishing words

I hope you enjoyed the article and understand the concept better now. I also hope the sun is still shining and you didn’t spend all the day on this booooooring stuff.

If you’re into open source and have some brilliant ideas for a builder that can be useful for everyone, you’re welcome to contribute to the [angular-builders](https://github.com/just-jeb/angular-builders) project.

All the source code of the timestamp builder *(as well as the example app that uses this builder)* is available on [GitHub](https://github.com/just-jeb/angular-builders/tree/master/packages/timestamp).

## Further reading

- Read [this](https://blog.angular.io/introducing-cli-builders-d012d4489f1b) great post to learn more about advanced logger usage, builder watch mode and testing

- Official [documentation](https://angular.dev/tools/cli/cli-builder) is on [angular.dev](https://angular.dev/overview)
