Creating Secondary Entry Points for your Angular Library

Christian Ing Sunardi
Tunaiku Tech
Published in
9 min readJun 4, 2020

--

Photo by Ian Noble on Unsplash

Developing an Angular Library is easier than ever nowadays since the release of Angular Library feature (from Angular 7). The Angular Library itself is equipped with a community-driven package named ng-packagr, which is pretty much the core. In this article, we will take a look at how we can utilise ng-packagr secondary entry points to split our Angular Library even further!

Why do we need secondary entry points?

One of the reasons we want to have secondary entry points is to enable us split our dependencies. Let’s look at an example where one module has peerDependencies, while another does not have any.

Suppose we have the following Angular Library example:

Example of Angular Library folder structures
  • There is only one library named my-awesome-lib.
  • Inside my-awesome-lib/src, there are 2 modules: awesome-plain and awesome-text.

Now, let’s look at the contents of awesome-plain component:

import { Component } from '@angular/core';@Component({
selector: 'awesome-plain',
template: `
<div>Hey I'm just a plain text with no dependencies!</div>
`
})
export class AwesomePlainComponent {}

and awesome-time component:

import { Component } from '@angular/core';
import * as moment_ from 'moment';
const moment = moment_;
@Component({
selector: 'awesome-time',
template: `
<div>Hey, Awesome Time:</div>
<div>{{ time }}</div>
`
})
export class AwesomeTimeComponent {
time: string;

constructor() {
this.time = moment().format();
}
}
  • The awesome-plain component does NOT have any dependencies.
  • The awesome-time component depends on moment.

And this is how moment is specified under my-awesome-lib/package.json:

{
"name": "my-awesome-lib",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^8.2.14",
"@angular/core": "^8.2.14",
"moment": "^2.26.0"
}
}

Notice that these peerDependencies are put under the scope of library my-awesome-lib, not on the individual module (files inside the library).

Finally, the following is how awesome-plain and awesome-time are exported under my-awesome-lib/src/public-api.ts:

/*
* Public API Surface of my-awesome-lib
*/
export * from './awesome-plain/awesome-plain.component';
export * from './awesome-plain/awesome-plain.module';
export * from './awesome-time/awesome-time.component';
export * from './awesome-time/awesome-time.module';

It is clear from the above explanations that inside my-awesome-lib there are 2 files: awesome-plain does not have any dependencies, while awesome-time has peerDependencies (which is moment). Now, where can it go wrong though?

The problem: Client needs to install ALL peer dependencies from the library

Suppose we have an Angular application that wants to consume my-awesome-lib. The first thing that the Client App (Angular application) needs to do is installing the library:

npm i my-awesome-lib

After it is installed, the Client App then proceeds to import and use, for example only awesome-plain component. Here is the code in the Client App may look alike:

// app.module.ts
import { AwesomePlainModule } from 'my-awesome-lib';
@NgModule({
...,
imports: [
...,
AwesomePlainModule,
],
bootstrap: [AppComponent]
})
export class AppModule {}
// app.component.html
<awesome-plain></awesome-plain>

When we run ng serve, suddenly we got this error in our terminal:

ERROR in ./node_modules/my-awesome-lib/fesm2015/my-awesome-lib.js Module not found: Error: Can't resolve 'moment' in '/app-showcase-v8/node_modules/my-awesome-lib/fesm2015'

It says that it cannot find moment installed in the Client App. Well, this is what happens. Although the Client App only imports and uses awesome-plain, the Angular compiler will still ask to install all peerDependencies defined in my-awesome-lib, which is moment in this case.

The current condition might be fine if the Client App uses both awesome-plain and awesome-time. However, imagine if the library grows large and there are more than just 2 modules, let’s say there are 10 modules instead. Let’s exaggerate a bit more; what if 5 out of 10 modules have distinct peerDependencies? If there’s a Client App that consumes this library and uses only 1 module that does not have any peerDependencies, the Client App still has to install all 5 peerDependencies! Surely, there should be a better approach than this, right?

Enter the secondary entry points!

Fortunately, it is quite possible to optimise the current approach. So far, the approach used in the library is only using something called primary entry points. This is denoted by package.json file that only exists under my-awesome-lib/package.json, which is where all peerDependencies for the entire library is defined.

With secondary entry points, we can further split the peerDependencies beyond the library level; it makes it feasible to define peerDependencies at folders or modules inside the library. For instance, by making awesome-time into secondary entry points, we can have another package.json file inside the sub directory, which contains peerDependencies only for awesome-time module. As a result, we are not defining the peerDependencies at the library-level anymore; we define them at the sub directory instead.

Also, secondary entry points enable us to import the library like the following:

// Primary entry points
import { AwesomePlainModule } from 'my-awesome-lib';
// Secondary entry points
import { AwesomeTimeModule } from 'my-awesome-lib/awesome-time';

This way, if the Client App only uses AwesomePlainModule, the compiler won’t ask to install moment anymore!

Implement secondary entry points

Hopefully the above explanations give you all a rough idea on why we want to use secondary entry points. The good news is, implementing secondary entry points is fairly simple and straightforward because ng-packagr will do most of the work behind the scenes!

We will use my-awesome-lib as a context for the following implementation guides. In this case, we are going to set awesome-time as secondary entry points, while awesome-plain will stay as is (still primary entry points).

1. Place the folders for secondary entry points directly under the library folder.

According to ng-packagr documentation, one of the folder layout examples for secondary entry points is to have it like the following:

my_package
├── src
| ├── public_api.ts
| └── *.ts
├── ng-package.json
├── package.json
└── testing (secondary entry points)
├── src
| ├── public_api.ts
| └── *.ts
└── package.json

As can be seen, the folder for secondary entry points is placed directly under /my_package, which differs from the primary entry point folders that are put under /my_package/src.

Interestingly, this is a similar folder layout used in @angular/common package, which has @angular/common as the primary entry points, while @angular/common/testing as the secondary entry points.

With that, the current library folder layout should be like the following:

Library folder layout after moving “/awesome-time” up one level
Library folder layout after moving “/awesome-time” up one level

2. Create additional package.json and public-api.ts files in secondary entry points folder.

To make secondary entry points, we need to tell ng-packagr which folder to look for. This can be achieved by creating another package.json and public-api.ts files under the /my-awesome-lib/awesome-time folder in addition to the one for primary entry points. By doing only this, secondary entry points will be dynamically discovered by ng-packagr.

The contents of /my-awesome-lib/awesome-time/package.json can be like:

{
"ngPackage": {
"lib": {
"entryFile": "public-api.ts",
"umdModuleIds": {
"moment": "moment"
}
}
},
"peerDependencies": {
"moment": "^2.26.0"
}
}

Notice that we put moment as peerDependencies here as of now. Also, the "umdModuleIds" is for removing the warning from ng-packagr when building the library.

and the contents of /my-awesome-lib/awesome-time/public-api.ts as follows:

/*
* Public API Surface of my-awesome-lib/awesome-time
*/
export * from './awesome-time.component';
export * from './awesome-time.module';

By now, the library folder layout should be like the following:

Library folder layout after creating additional package.json and public-api.ts for secondary entry points
Library folder layout after creating additional package.json and public-api.ts for secondary entry points

3. Remove secondary entry points peer dependencies from the main package.json and secondary entry point exported files from the main public-api.ts.

This step is also crucial as we would like to explicitly tell ng-packagr to completely exclude secondary entry points from the primary entry points. To do that, we need to remove peerDependencies that are only used for secondary entry points from the main package.json. In this case, we are going to remove moment.

The main my-awesome-lib/package.json should look like:

{
"name": "my-awesome-lib",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^8.2.14",
"@angular/core": "^8.2.14"
}
}

The package moment has been removed as it is already defined in /my-awesome-lib/awesome-time/package.json.

Also, we are going to remove awesome-time files that are exported in the main my-awesome-lib/src/public-api.ts. The file should only export awesome-plain files now, which look like the following:

/*
* Public API Surface of my-awesome-lib
*/
export * from './awesome-plain/awesome-plain.component';
export * from './awesome-plain/awesome-plain.module';

4. Build the library.

Now that everything has been setup, we can now try to build the library by executing the command ng build my-awesome-lib. If it’s done properly, you should see the following in the terminal:

Terminal logs when building the library with secondary entry points
Terminal logs when building the library with secondary entry points

Also, if you open the library build folder dist/my-awesome-library, there should be some additional files named my-awesome-lib-awesome-time.*.js inside the folders, such as dist/fesm2015 and dist/bundles. If you compare it with the one without secondary entry points, the build folders normally contain just my-awesome-lib.*.js, which is the build only for the library itself.

5. Install and import the library in the Client App.

The final step is to finally consume it within an Angular app. There would be slight changes in the import path as we have moved awesome-time from the primary entry points. To use the new library folder structures in the Client App, it should be like the following:

// Primary entry points
import { AwesomePlainModule } from 'my-awesome-lib';
// Secondary entry points
import { AwesomeTimeModule } from 'my-awesome-lib/awesome-time';

Now, if the Client App only uses AwesomePlainModule, we should be able to run the app without installing moment (that is used only in AwesomeTimeModule).

Bear in mind that implementing secondary entry points might cause Breaking Changes to your Angular Library. The reason is because the Client App that is consuming your library will have to update the import paths. Otherwise, their app will break as the secondary entry point files are now imported NOT from 'your-lib' anymore. As such, this change is not backward compatible.

Should there be any primary entry points at all? Is it okay to only have secondary entry points for the library?

You might be wondering, should we even use primary entry points? In my opinion, it is alright to only have secondary entry points mainly because @angular/material uses only secondary entry points. On the other hand, it is also generally recommended to use primary entry points for functions or features that are logically similar. The following is written in Angular Package Format documentation:

The general rule in the Angular Package Format is to produce a FESM file for the smallest set of logically connected code. For example, the Angular package has a single FESM for @angular/core. When a developer uses the Component symbol from @angular/core they are very likely to also use symbols such as Injectable, Directive, NgModule, etc as well — either directly or indirectly. Therefore, all these pieces should be bundled together into a single FESM. For most library cases a single logical group should be grouped together into a single NgModule, and all these files should be bundled together as a single FESM file within the package which represents a single entry point in the npm package.

Additionally, in my case, the rule of thumb is to make some modules as secondary entry points if they have distinct peerDependencies. This is to prevent the Client App to be forced into manually installing all dependencies although they are not using all of them.

Conclusions

To sum up, secondary entry points is a neat feature that allows us to split our Angular Library even further, especially when dealing with peerDependencies. It is also quite easy to implement as ng-packagr will dynamically discover the secondary entry points through package.json of the sub-directories.

One of the benefits is that it will reduce the chance for the Client App to be forced to install all dependencies even though the app is not importing/using any of the library functions that depend on the installed dependencies.

P.S. This article is very much inspired by an amazing article written by Kevin Kreuzer, which talks about how to implement secondary entry points even further by using something called “signpost”. I recommend to check out the article!

GitHub repo

In case the explanation written in this article is unclear, you may check the example library’s GitHub repo:

References

--

--