Building an extensible Dynamic Pluggable Enterprise Application with Angular

Post Editor

In this article, we will talk about how we can leverage Angular CLI build tools to create an AOT precompiled Angular plugin, which can share common code with other plugins and even work with Angular universal. This is an unofficial solution, but it works well for our case.

11 min read
post

Building an extensible Dynamic Pluggable Enterprise Application with Angular

In this article, we will talk about how we can leverage Angular CLI build tools to create an AOT precompiled Angular plugin, which can share common code with other plugins and even work with Angular universal. This is an unofficial solution, but it works well for our case.

post
post
11 min read

In this article, we will talk about how we can leverage Angular CLI build tools to create an AOT precompiled Angular plugin, which can share common code with other plugins and even work with Angular universal. This is an unofficial solution, but it works well for our case.

Content imageContent image
AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!

Here’s a simple representation of what we’re building:

Content imageContent image

Here we have a simple page where we get plugins configuration from a plugins-config.json file. Then we first lazy load the AOT compiled plugin (plugin1.js) which has a dependency to shared.js . The shared library contains a Tabs component and Tabs ng factories. Sometime later we load the second plugin(plugin2.js) that reuses the code from the previously loaded shared.js library.

Here’s the angular-plugin-architecture Github repository if you want to take a look at the source code.

We use Angular CLI 7.3.6 in the demo

Let’s to not wait for Ivy but instead deal with the current ViewEngine today
Link to this section

What are we going to discuss here?

  • Why Angular?
  • The goal
  • What’s a plugin?
  • Why is it so hard to create a plugin with Angular?
  • Requirements
  • Other solutions
  • Towards a solution
  • Single bundle
  • Externals
  • Dynamic exports
  • Build plugin
  • Externals and Shared Angular library
  • Load plugin (client, server)
  • How to render a plugin?
  • How to isolate the main app from errors in a plugin?

Why Angular?

The Angular team and community are continuing the rapid growth of its ecosystem.

Angular helps us to structure our code in a consistent manner so every new developer can easily be involved in a project.

We like the fact that we’re following best web practices with Angular, just think of typescript, observables, server-side rendering, web workers, differential loading, progressive web application(PWA), lazy loading, etc. All of this helps us to adopt those features in a fast manner.

There are also more features that Angular offers us, like built-in dependency injection system, reactive forms, schematics and so on.

That is why we usually choose Angular when building an enterprise application.

The goal

One day our client asked us to add a new feature to his existing Angular Universal application. He wanted to have a pluggable Content Management System(CMS). The goal was to add the possibility to extend the functionality of the current app so that a 3rd party developer could easily develop a new module on his own and upload it. Then, the Angular app should pick it up without having to recompile the whole application and redeploy.

Simply put, we need to develop a plugin system.

What is a plugin?

Plugin systems allow an application to be extended without modification of the core application code.

It sounds simple but writing a plugin with Angular is always a challenge.

Why is it so hard to create a plugin with Angular?

One of my colleagues, who worked with AngularJS long time ago, said that he saw an Angular 2 application written in plain es5 (hmm.. maybe he found my jsfiddle or my old repo). So he suggested creating an Angular module in es5, putting it in a folder and that the main app should make it work somehow.

Don’t get me wrong but I’m with Angular since 2 alpha and Angular 2 (or just Angular) is a completely new thing.

Of course, the main pitfall here is Ahead Of Time (AOT) compilation. The main advantage of using AOT is better performance for your application.

I saw lots of examples out there that use JitCompiler for building a pluggable architecture. This is not the way we want to go. We have to keep our app fast and not include compiler code in the main bundle. That’s why we shouldn’t use es5 because only TypeScript code can be AOT precompiled. Also, the current implementation of Angular AOT is based on the @NgModule transitive scope and requires that all should be compiled together. All of this makes things harder.

Another pitfall is that we need to share code between plugins to avoid code duplication. What kind of duplication can we consider? We distinguish two types of code that can be duplicated:

  • The code that we write or the code that we take from node_modules
  • The code produced by AOT, think of component and module factories (component.ngfactory.js and module.ngfactory.js). Actually, this is a huge amount of code.

In order to avoid these code duplications, we need to deal with the fact of how ViewEngine generates a factory.

If you don’t know, the ViewEngine is the current Angular rendering engine

The problem here is that code generated by the Angular compiler can point to ViewFactory from another piece of generated code. For instance, here’s how element definition is linked to ViewDefinitionFactory (Github source code)

elementDef(…, componentView?: null | ViewDefinitionFactory, componentRendererType?: RendererType2 | null)

So this results in getting duplicates of all the factories from the shared library.

Content imageContent image
Duplicates of ngFactories in non-optimized plugin

So when we were discussing the Angular plugin system, we should keep the following in mind:

Requirements

  • AOT
  • Avoid duplicated code (packages like @angular/core{common,forms,router},rxjs,tslib)
  • Use a shared library in all plugins. But, DO NOT SHIP generated factories from that shared library in each plugin. Rather, reuse library code and factories.
  • For importing the external modules we just need to know one thing: their bundle file path.
  • Our code should recognize the module and place the plugin into the page.
  • Support server-side rendering
  • Load the module only when needed
  • Support the same level of optimization that Angular CLI gives us

All of these considerations led us to our own solution.

Similar solutions

There are different approaches out there, but they lack the crucial parts: support for AOT, optimized code, and non-duplicated code.

And one of the solutions that is close to our needs is: https://github.com/iwnow/angular-plugin-example

It uses rollup to produce the plugin bundle in umd format.

However, here are the drawbacks I see with this approach:

  • ❌ it doesn’t use the optimization techniques that Angular CLI offers us: i.e. it doesn’t remove Angular decorators and doesn’t run buildOptimizer.
  • ❌ it duplicates factories if we use shared components in each plugin.

Towards a Solution

Fortunately, Angular is very extendable through custom scripts.

Since Angular 6, there’s the possibility to hook into the compilation process using builders. It allows us to add a custom webpack configuration, including all the benefits you can imagine from a vanilla webpack setup.

So I thought that it could be a good idea to write a custom builder for building plugins.

Angular CLI supports the generation of libraries. But ng-packagr, that is used to build that library, generates many artifacts that we don’t need. (Yeah, I know that it follows the Angular Package Format(APF)). And those artifacts can be only consumed by other Angular applications.

And here I thought that I could use an Angular application to build my plugins. By application, I mean an application which is generated by using a CLI command like ng generate application . This approach is beneficial because it gives us the same level of optimization that Angular CLI does. For example, we won’t get Angular specific decorators after AOT.

Content imageContent image
Decorators in non-optimized bundle

Now, if only I could find a way to produce a single bundle with all exports I need:

Single bundle
Link to this section

So, I started with:

<>Copy
ng generate application plugins —minimal

which gave me a simple new Angular application in projects folder

<>Copy
projects |_ plugins src angular.json


Next, I removed all unnecessary files so that it looked like:

<>Copy
projects |_ plugins |_ src |_ plugin1 |_ plugin2 main.ts tsconfig.app.json src angular.json

Then I removed all the redundant stuff from angular.json for that project:

Content imageContent image
plugins project configuration in angular.json

As you can see, I removed index, all assets, scripts , fileReplacements etc. and I also created a custom PluginBuilder which is located in ./builders:plugin.

The builder should produce a single umd bundle:

<>Copy
// Make sure we are producing a single bundle delete config.entry.polyfills; delete config.optimization.runtimeChunk; delete config.optimization.splitChunks; delete config.entry.styles; config.output.library = pluginName; config.output.libraryTarget = 'umd';

Also, it should provide externals and generate the appropriate exports depending on which plugin we’re building. This is a vital part of the builder.

Externals
Link to this section

Webpack allows us to provide externals. So they might look like:

<>Copy
config.externals = { rxjs: 'rxjs', '@angular/core': 'ng.core', '@angular/common': 'ng.common', '@angular/forms': 'ng.forms', '@angular/router': 'ng.router', tslib: 'tslib' // put here other common dependencies };

There’s another option which I’ll show a bit later in the Shared library section.

Dynamic exports
Link to this section

Here is the interesting part which gives us the ability to build different plugins with the same build command.

First, thetsconfig.app.json of theplugins application only looks at the main.ts file so that we only compile what we provide in main.ts.

tsconfig.app.json

<>Copy
{ "extends": "../../tsconfig.json", "compilerOptions": { ... }, "files": ["./src/main.ts"] }

Next, the builder takes the modulePath parameter:

<>Copy
modulePath=./plugin1/plugin1.module#Plugin1Module

You may notice that it looks like a path to a lazy module when we’re working on lazy loading. The builder analyzes that path and generates dedicated code in main.ts. For example, in the modulePath above we get:

<>Copy
export * from './plugin1/plugin1.module'; export * from './plugin1/plugin1.module.ngfactory'; import { Plugin1ModuleNgFactory } from './plugin1/plugin1.module.ngfactory'; export default Plugin1ModuleNgFactory;

The key point here is to be able to export not only the code, but also the factories generated by Angular compiler.

I keep main.ts empty so that I can update it dynamically before the build and so it will export what we want.

Build plugin
Link to this section

Here’s an example of the command I use to build the plugin:

<>Copy
"build:plugin1": "ng build --project plugins --prod --modulePath=./plugin1/plugin1.module#Plugin1Module --pluginName=plugin1 --outputPath=./src/assets/plugins",

What is going here?

  • ng build — project plugins — prod
    Build the above created plugins application in prod mode.
  • modulePath=./plugin1/plugin1.module#Plugin1Module
    Provide a path to the module we want to be a plugin.
  • pluginName=plugin1
    Define the name for the bundle.
  • — outputPath=./src/assets/plugins
    Specify the directory where the generated bundle should be placed.

Externals and Shared Angular libraries
Link to this section

We also can use a shared library for plugins. To be able to expose all factories from the shared library we need to have the library code in one file. Otherwise, it would be hard to figure out what’s the path for those ngfactories.

And that’s what ng-packagr generates for us.

We can build generated by ng-packagr file. So, first, I create a library:

ng generate library shared

and then build it for the plugin system:

"build:shared": "ng build shared && ng build --project plugins --prod --modulePath=shared#SharedModule --pluginName=shared  --outputPath=./src/assets/plugins",

Now, when I create a shared library I can reuse it in my plugins:

"build:plugin1": "ng build --project plugins --prod --modulePath=./plugin1/plugin1.module#Plugin1Module --pluginName=plugin1 --sharedLibs=shared --outputPath=./src/assets/plugins",

Note--sharedLibs=shared

I’m controlling the factory externals via the webpack externals options where I’m providing a special function to catch all ngfactory imports.

Content imageContent image
Webpack externals to catch factories imports

This way we cover all possible externals in our bundle:

Content imageContent image
Webpack externals cover all possible external imports

And finally, get what we wanted:

Content imageContent image

Consuming the plugin
Link to this section

First of all, we need to load our configuration from the server.

For the sake of simplicity, we load a simple JSON file, that looks like this:

Content imageContent image
plugins-config.json

Then, in order to consume the built plugin, we create two services for the client and server sides which extend PluginLoaderService abstract class:

Content imageContent image
Base interface for LoaderService

Client Side

We use Angular Dependency Injection to provide a client specific loader only for the client code:

app.module.ts

<>Copy
providers: [ { provide: PluginLoaderService, useClass: ClientPluginLoaderService } ],

In order to load the plugin on the client-side, we use a minimal systemjs@3.0.2 build that supports loading AMD modules.

<>Copy
"scripts": [ "node_modules/systemjs/dist/s.js", "node_modules/systemjs/dist/extras/named-register.js", "node_modules/systemjs/dist/extras/amd.js" ],

Externals are provided by the define global function:

<>Copy
Object.keys(PLUGIN_EXTERNALS_MAP).forEach(externalKey => window.define(externalKey, [], () => PLUGIN_EXTERNALS_MAP[externalKey]) );


And the load function looks like

<>Copy
load<T>(pluginName): Promise<NgModuleFactory<T>> { return SystemJs.import(config[pluginName].path).then( module => module.default.default ); }

Server Side
Link to this section

The Server code has its own implementation of the loader service:

app.server.module.ts

<>Copy
providers: [ { provide: PluginLoaderService, useClass: ServerPluginLoaderService } ],

The service uses the nodejs require method to get the module:

<>Copy
load<T>(pluginName): Promise<NgModuleFactory<T>> { const factory = global['require'](`./browser${config[pluginName].path}`) .default; return Promise.resolve(factory); }

We also override the native require method in order to provide externals:

Content imageContent image
Externals on the server-side

The case when the main application uses the same shared components as plugins
Link to this section

Let’s now imagine that our shared library should be also used in our main application:

Content imageContent image
Plugins and the main app are shared the same library

What can we do to not duplicate the code?

Since we have theshared library already built in the main app we can define it as externals to other plugins.

plugin-externals.prod.ts

<>Copy
import * as shared from 'shared'; export const PLUGIN_EXTERNALS_MAP = { 'ng.core': core, 'ng.common': common, 'ng.forms': forms, 'ng.router': router, rxjs, tslib, shared: { ...shared, ...require('shared/shared.ngfactory') } };

This way we share both the library code and the code produced by aot.

You may note that I have prod postfix in the file above. That’s because we define such external only for production build.

<>Copy
"configurations": { "production": { "fileReplacements": [ ... { "replace": "src/app/services/plugin-loader/plugin-externals.ts", "with": "src/app/services/plugin-loader/plugin-externals.prod.ts" } ],

In dev mode we still load shared library through Systemjs as other plugins.

An example of the code for this case can be found in share-lib-between-app-and-plugins branch.

How do we render the plugin?
Link to this section

Now that we have all we need to get NgModuleFactory, we can render our plugins with the help of one of the well-known Angular API methods for creating a dynamic component:

  • componentFactory.create
  • viewContainerRef.createComponent

In the example below, we used the second option:

Content imageContent image
Low-level API to render plugins

Isolating the main app from errors in a plugin
Link to this section

One of the problems with a pluggable architecture is that exceptions in plugins can sink the entire app.

Fortunately, there are a few different ways for dealing with such errors:

  • Global ErrorHandler
  • Zone.onError.subscribe
  • try catch

This lets us gracefully handle any errors when creating a plugin, and safely isolate, kill, and report bad components without impacting the rest of our app.

Summary
Link to this section

Angular evolves each quarter. But, it still doesn’t have a simple way to deal with dynamic templating. It’s the most required part when we are working with a big application and clients demand that all should be dynamic, lazy and changeable at runtime.

Luckily, Angular CLI provides us the tools to configure a build for our specific needs. We showed in this article one option for how to build an AOT precompiled plugin. It might not exactly work for your needs, but possibly you can borrow some of the ideas in this article. So, I hope you found them useful.

Thank you for reading!


The code can be found on Github: angular-plugin-architecture.

Share

About the author

author_image

Alexey is a GDE for Angular and Web Technologies and also active StackOverflow contributor.

author_image

About the author

Alexey Zuev

Alexey is a GDE for Angular and Web Technologies and also active StackOverflow contributor.

About the author

author_image

Alexey is a GDE for Angular and Web Technologies and also active StackOverflow contributor.

Looking for a JS job?
Job logo
NODE.JS / ANGULAR DEVELOPER(SENIOR)

Interon IT Solutions LLC

Worldwide
Remote
$115k - $125k
Job logo
Sr. Developer-.Net/Angular/Node

PRICE WATERHOUSE COOPERS

Worldwide
Remote
$81k - $103k
Job logo
Angular Developer

Software Technology, Inc.

Worldwide
Remote
$75k - $100k
Job logo
Angular Developer

GenSpark

United States
Remote
$60k - $70k
More jobs

Featured articles

blockchainpost
19 July 202212 min read
An Introduction to Blockchain

Learn the fundamentals of a blockchain starting from first principles. We'll cover hashing, mining, consensus and more. After reading this article, you'll have a solid foundation upon which to explore platforms like Ethereum and Solana.

blockchainpost
19 July 202212 min read
An Introduction to Blockchain

Learn the fundamentals of a blockchain starting from first principles. We'll cover hashing, mining, consensus and more. After reading this article, you'll have a solid foundation upon which to explore platforms like Ethereum and Solana.

Read more
blockchainpostAn Introduction to Blockchain

19 July 2022

12 min read

Learn the fundamentals of a blockchain starting from first principles. We'll cover hashing, mining, consensus and more. After reading this article, you'll have a solid foundation upon which to explore platforms like Ethereum and Solana.

Read more