Hey folks. Hope the sun is shining for you today.
In the previous article we talked about customizing Angular 6 build configuration without ejecting the underlying webpack configuration.
The proposed solution was to use an existing custom builder.
Today we’ll take a look under the hood and create our own custom builder from scratch.
Angular CLI 6 came with a new architecture, basically a rewrite of the old CLI, which was broken down into small pieces.
In fact, Angular CLI itself has nothing to do with the configuration you provide in angular.json, not anymore. Instead, it wraps Angular Dev Kit and triggers architect targets.
Briefly speaking:
When you run ng build
or ng test
or any of the predefined Angular CLI commands a few things happen:
When you run a custom architect target, the following happens:
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 format project:target
) 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.
Let’s take a closer look on angular.json file:
{
"$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 become:
{
"$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. [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 talk, let’s do something real!
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 last 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.
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 file with one additional entry:
"builders": "builders.json"
Spoiler: the file doesn’t have to be builders.json, can be any name you choose.
builders.json
is a file that describes your builders. It’s a json file that follows Angular builders schema and has the following structure:
{
"$schema": "@angular-devkit/architect/src/builders-schema.json",
"builders": {
"builder-name": {
"class": "path-to-builder-class",
"schema": "path-to-builder-schema",
"description": "builder-description"
},
... more builders definitions
}
}
Single builders.json
can contain definitions for multiple builders.
Each builder is defined by two properties:
class
— path to Javascript class that implements Builder
interface.schema
— path to json schema that defines builder configuration options
property in architect target definition).Here what our builders.json will look like:
{
"$schema": "@angular-devkit/architect/src/builders-schema.json",
"builders": {
"file": {
"class": "./timestamp.builder.js",
"schema": "./schema.json",
"description": "Builder that creates timestamp"
}
}
}
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:
{
"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.
To format the Date we will use dateformat package, let’s install it:
npm i dateformat
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 interfaces 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:
npm i -D @angular_devkit/core @angular_devkit/architect @types/node @types/dateformat typescript
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:
schema.d.ts
export interface TimestampBuilderSchema {
format: string;
path: string;
}
Once we have the interface we can implement the generic Builder
interface:
import {Builder, BuilderConfiguration, BuilderContext, BuildEvent} from '@angular-devkit/architect';
import {Observable} from 'rxjs';
import {TimestampBuilderSchema} from './schema';
export default class TimestampBuilder implements Builder<TimestampBuilderSchema> {
constructor(private context: BuilderContext) {
}
run(builderConfig: BuilderConfiguration<Partial<TimestampBuilderSchema>>): Observable<BuildEvent> {
}
}
**timestamp.builder.ts **
run
method should return an Observable of BuildEvent
that looks like this:
export interface BuildEvent {
success: boolean;
}
BuildEvent
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.
In our case we want to return success if the file with the timestamp was successfully created and failure otherwise:
import {Builder, BuilderConfiguration, BuilderContext, BuildEvent} from '@angular-devkit/architect';
import {bindNodeCallback, Observable, of} from 'rxjs';
import {catchError, map, tap} from 'rxjs/operators';
import {TimestampBuilderSchema} from './schema';
import {getSystemPath} from '@angular-devkit/core';
import {writeFile} from 'fs';
import * as dateFormat from 'dateformat';
export default class TimestampBuilder implements Builder<TimestampBuilderSchema> {
constructor(private context: BuilderContext) {
}
run(builderConfig: BuilderConfiguration<Partial<TimestampBuilderSchema>>): Observable<BuildEvent> {
const root = this.context.workspace.root;
const {path, format} = builderConfig.options;
const timestampFileName = `${getSystemPath(root)}/${path}`;
const writeFileObservable = bindNodeCallback(writeFile);
return writeFileObservable(timestampFileName, dateFormat(new Date(), format)).pipe(
map(() => ({success: true})),
tap(() => this.context.logger.info("Timestamp created")),
catchError(e => {
this.context.logger.error("Failed to create timestamp", e);
return of({success: false});
})
);
}
}
timestamp.builder.ts
Let’s break it down:
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.getSystemPath
is a utility function that returns system specific path and we concatenate it with the relative path
from the options.[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](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#static-method-bindNodeCallback)
function to transform it into a function that returns Observable.formatDate
function while using the format
we’ve got from the options and write the formatted date to the file.Side node: use the logger to provide build information to the user
Compile the source code to JavaScript and you’re good to go.
Now when the builder is ready you can use it by either specifying a relative path to the folder in angular.json:
"architect": {
"timestamp": {
"builder": "[relative-path-to-package]/timestamp:file",
"options": {}
}
}
… or packing it into npm package and installing it locally:
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:
"architect": {
"timestamp": {
"builder": "@angular-builders/timestamp:file",
"options": {}
}
}
… or publishing it on npm and installing from there.
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.
Recommended Courses:
☞ The Complete Web Developer Course 2.0
☞ Become a Web Developer from Scratch
☞ Automate the Boring Stuff with Python Programming
☞ Learn How To Code: Google’s Go (golang) Programming Language
☞ Angular Tutorial - Learn Angular from Scratch
☞ JavaScript Programming Tutorial Full Course for Beginners
☞ Angular and Nodejs Integration Tutorial
☞ Learn JavaScript - Become a Zero to Hero
☞ E-Commerce JavaScript Tutorial - Shopping Cart from Scratch