PLUGIN ARCHITECTURE IN ANGULAR APPLICATIONS

The concept of plugins has always been popular and productive in software development. Scalability and capability of collective development is essential for enterprise-level applications when development teams may represent multiple lines of business and provide their own plugin to the corporate web portal. Angular, being one of the most suitable frameworks for building large browser-based information systems, offers a great out-of-the-box opportunity to split the application into multiple chunks by employing the lazy loading technology. This should result in acceptable loading time of even huge solutions in case of appropriate approach to the application design. However, the standard lazy loading implementation implies hard coding lazy loading routes in the app core which might lead to difficulties with multi-team development and impossibility of adding new modules without changes in the main application code. In this article, I am going to explain and demonstrate how to build an Angular application supporting plugins described only in a configuration file when the core app and pluggable modules are completely independent from each other and can be even placed in different locations.

To provide you with a good illustration of the used approach, I have made a minimal example that is located at https://github.com/VasilyIvanov/ng-plugin-demo. It was created and tested with Angular 11.

PREREQUISITES

Let us start with creating a new Angular application.

ng new ng-plugin-demo

The following commands will create 2 libraries we can use as plugins. The first plugin will contain 2 components whereas the second one will have only one simple component.

ng g library plugin1
ng g component main-screen — project=plugin1
ng g component child-screen — project=plugin1
ng g library plugin2
ng g component main-screen — project=plugin2

We should update the standard main-screen.component.html file to be able to display the second component and an image using the module base path to load the asset; this will be explained more detailed later.

<p>Plugin1 works!</p>
<p>Module base path is {{ moduleBasePath }}</p>
<a [routerLink]="['child']">Go to the child</a>
<br />
<a [routerLink]="'..':>Go back</a>
<br />
<img [src]="moduleBasePath + '/assets/smile.png'" />

The second component could be as simple as possible.

<p>child-screen works!</p>
<br />
<a [routerLink]="'..'">Go back</a>

We also need to add routing modules to the plugins; these modules will display specified components when the user goes to the plugin route defined in the main app. In our demo app we will have a default route, displaying the main library component and the child route to show the child component.

const routes: Routes = [
{
path: '',
component: MainScreenComponent
},
{
path: 'child',
component: ChildScreenComponent
}
];

These routes are added in the standard way into the routing module imports.

imports: [RouterModule.forChild(routes)],

And the routing module is in turn added into the plugin library main module imports.

imports: [
Plugin1RoutingModule
],

Since the plugin contains an image, we need to create a special folder for assets and modify the related ng-package.json file to tell the compiler how to treat that folder.

"assets": ["./assets"],

The second plugin should be prepared in a similar way. Once it has been done, we can concentrate on the main application.

COMMON LIBRARY

It is clear enough that the core application and plugins will have some common code. First, it should be a service to load and keep the plugin config file. In addition, we need to define an injection token to keep the plugin base path value and a couple of interfaces. The steps below illustrate the creation of the common library.

ng g library common

The interface describing a plugin should be declared as shown below.

export interface DemoPlugin {
path: string;
baseUrl: string;
pluginFile: string;
moduleName: string;
}

The second interface is used to define an object that will be environment specific. This will allow you to create multiple environments having their own config which is useful because you may need to have multiple plugin locations.

Now we are ready to create the plugin config service.

ng g service plugin-config — project=common

The purpose of the service is keeping the plugin config data which should be loaded once on the application start-up.

export class PluginConfigService {
public value: DemoPlugin[] = [];
public loaded = false;
public constructor() { } public load(uri: string): Promise<DemoPlugin[]> {
return fetch(uri).then(result => result.json()).then(json => {
this.value = json;
this.loaded = true;
return json;
});
}
}

Finally, we need to define the module base path injection token to facilitate loading assets in plugins.

export const MODULE_BASE_PATH = new InjectionToken<string>('MODULE_BASE_PATH');

CORE APP

A bit of theory. When we create an Angular library, it always has dependencies. Those dependencies should be defined in the application package.json file in the section of the same name. If you actively use 3rd party components, the list of dependencies might be significant enough. By default, Angular does not include dependency code in the compiled library. It may look not clear enough but this is done to reduce the overall application size and calculate the optimal lazy loading strategy. It is an option that allows to work around the rule called ‘budledDependencies’, however it is not always a good idea to use it because you may need to refer to the same libraries from multiple plugins. In this case, the app will have to load the same code every time it loads a new plugin.

Since the core application should provide plugins with dependencies, it must import them beforehand. Unfortunately, this may result in considerable increase of the initial loading bundle size. That, in turn, will slow down the application loading. Taking into account the above considerations, it would be reasonable to lazy load those dependencies if it is possible. Angular grants us such an opportunity. We only need to create a new module in the main app that will import all dependencies and lazy load it when we need to access a plugin.

ng g module plugin-loader — routing

Now we can get down to the most interesting part and code the plugin loading module. The module should include the dependency config file. An example of such a file is shown below.

import * as AngularAnimations from '@angular/animations';
import * as AngularCommon from '@angular/common';
import * as AngularCommonHttp from '@angular/common/http';
import * as AngularCompiler from '@angular/compiler';
import * as AngularCore from '@angular/core';
import * as AngularForms from '@angular/forms';
import * as AngularPlatformBrowser from '@angular/platform-browser';
import * as AngularPlatformBrowserDynamic from '@angular/platform-browser-dynamic';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import * as AngularRouter from '@angular/router';
import * as rxjs from 'rxjs';
import * as RxjsOperators from 'rxjs/operators';
import * as Common from 'common';
export const dependencyMap: { [propName: string]: any } = {
'@angular/animations': AngularAnimations,
'@angular/common': AngularCommon,
'@angular/common/http': AngularCommonHttp,
'@angular/compiler': AngularCompiler,
'@angular/core': AngularCore,
'@angular/forms': AngularForms,
'@angular/platform-browser': AngularPlatformBrowser,
'@angular/platform-browser-dynamic': AngularPlatformBrowserDynamic,
'@angular/platform-browser/animations': BrowserAnimationsModule,
'@angular/router': AngularRouter,
rxjs,
'rxjs/operators': RxjsOperators,
'common': Common
};

Normally, you should include in the file all dependencies your plugins may have. If you have some ones exceeding the app module, they will be at least lazy loaded which could have positive impact on the app initial loading time.

The most important part of the plugin loading module is its router configuration placed in the plugin-loader-routing.module.ts file. First, the plugin loading function.

const loadModule = (url: string): Promise<any> => {
try {
return fetch(url)
.then((response) => response.text())
.then((source) => {
const exports = {}; // This will hold module exports
// Shim 'require'
const require = (module) => {
if (!dependencyMap[module]) {
throw new Error(`No '${module}' module defined in the provided dependency map for the '${url}' module`);
}
return dependencyMap[module];
};
eval(source); // Interpret the plugin source
return exports;
});
} catch (error) {
const message = `Cannot load a module at '${url}'. ` + (error instanceof Error ? `${error.name} ${error.message}` : JSON.stringify(error));
window.alert(message);
return Promise.reject(message);
}
};

The function basically loads the plugin file, creates the export object that will contain the loaded module exports, overrides the ‘require’ keyword and calls the JavaScript eval function. If the plugin needs a dependency not defined in the config file, an error will be displayed in the browser console allowing you to understand how to fix the issue.

The routing module itself contains the router config that determines which plugin is to be loaded and performs the loading by calling the ‘loadModule’ function.

@NgModule({
imports: [RouterModule.forChild([])],
exports: [RouterModule],
providers: [
{
provide: ROUTES,
useFactory: (pluginConfigService: PluginConfigService) => pluginConfigService.value.map(plugin => ({
matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null => group.segments[0].path === plugin.path ? { consumed: [] } : null,
loadChildren: () => loadModule(`${plugin.baseUrl}/${plugin.pluginFile}?v=${new Date().getTime()}`).then(m => m[plugin.moduleName])
})),
deps: [PluginConfigService],
multi: true,
},
],
})
export class PluginLoaderRoutingModule { }

As you can see, we must resort to providing the ROUTES injection token because we need to access the plugin config service which contains the plugin configuration information loaded on the application start-up. This will be explained a bit later. Also, we cannot define paths for routes because they should be defined in the root router configuration in the app module if we want to have simple routes like ‘http://localhost:4260/plugin1’. A good way to determine which plugin is to load is using the URL matcher function parameters.

For sure, the plugin loader routing module should be imported in the plugin loader module.

imports: [CommonModule,PluginLoaderRoutingModule]

In the app module we need to load the plugin configuration JSON file and provide the root router configuration. Since we have to load the config prior to anything else, the APP_INITIALIZER injection token is a good candidate to do the job.

providers: [
PluginConfigService,
{
provide: APP_INITIALIZER,
useFactory: (pluginConfigService: PluginConfigService) =>
() => pluginConfigService.load(`${environment.pluginConfigUri}?v=${new Date().getTime()}`),
deps: [PluginConfigService],
multi: true
}
],

Your environment and plugin config files should look like the examples below. The environment config files is standard and created by the Angular CLI.

export const environment: Environment = {
production: false,
pluginConfigUri: './assets/plugin-config.json'
};

The plugin config file might be placed in the app assets folder.

[
{
"path": "plugin1",
"baseUrl": "http://localhost:4261/plugin1",
"pluginFile": "bundles/plugin1.umd.js",
"moduleName": "Plugin1Module"
},
{
"path": "plugin2",
"baseUrl": "http://localhost:4261/plugin2",
"pluginFile": "bundles/plugin2.umd.js",
"moduleName": "Plugin2Module"
}
]

Unfortunately, APP_INITIALIZER does not prevent the router from being configured before the initialiser finishes, thus we have to employ the ROUTES injection token in the app routing module again.

const staticRoutes: Route[] = [];@NgModule({
imports: [RouterModule.forRoot([])],
exports: [RouterModule],
providers: [
{
provide: ROUTES,
useFactory: (pluginConfigService: PluginConfigService) => {
const pluginRoute = {
// This function is called when APP_INITIALIZER is not yet completed, so matcher is the only option
matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null =>
group.segments.length && pluginConfigService.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null,
// Lazy load the plugin loader module because it may contain many 'heavy' dependencies
loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
};
return […staticRoutes, pluginRoute];
},
deps: [PluginConfigService],
multi: true,
},
]
})
export class AppRoutingModule { }

In this example, we do not have root routes besides the plugins, so the staticRoutes array is empty. Normally it will include your static (i.e., hard coded) routes.

The app component should apparently be changed. We need to inject the plugin config service.

export class AppComponent {
public constructor(public readonly pluginConfigService: PluginConfigService) {}
}

The component HTML template needs some changes to display links to the plugins defined in the plugin config file.

<ng-container *ngFor="let plugin of pluginConfigService.value">
<a [routerLink]="'/' + plugin.path">{{ plugin.moduleName }}</a>
<br />
</ng-container>
<router-outlet></router-outlet>

MODULE BASE PATH (URL)

When a plugin is loaded, it often needs to know what its URL is to be able to load assets such as pictures, videos, translation files, etc. By default, the base URL will be as same as the URL of the module that is loading the plugin. This is not acceptable, and we need provide the plugin module with correct information about its base URL.

As far as you remember, we have already created an injection token called MODULE_BASE_PATH. Let us add it to the plugin1 module to illustrate how to resolve the problem. First, we need to create a factory function.

const moduleBasePathFactory = (pluginConfigService: PluginConfigService): string =>
pluginConfigService.value.find(plugin => plugin.path === 'plugin1').baseUrl;

Then add a new provider to the module decorator.

providers: [
{
provide: MODULE_BASE_PATH,
useFactory: moduleBasePathFactory,
deps: [PluginConfigService],
}
]

Now the injection token has been added. It could be injected into plugin components by adding them to the class constructor parameter list as shown below.

public constructor(@Inject(MODULE_BASE_PATH) public readonly moduleBasePath: string) {}

In the component HTML template, you can just refer the injected value to load an image.

<img [src]="moduleBasePath + '/assets/smile.png'" />

LAST PREPARATIONS

In order to run the application, we need to set up the project config files. In the angular.json file you might wish to change the default port used when you serve the app. The setting path is projects.ng-plugin-demo.architect.serve.options.port.

A bit more changes should be done against the solution package.json file. To allow the locally running app to download the plugins, we need to set up an HTTP server. To get it, run the following command.

npm install — save-dev http-server

Installed, it will be added to the devDependencies section automatically. It is convenient to add a new entry to the scripts section.

"serve:plugins": "http-server ./dist/ — port=4261 — cors",

Finally, it would be perfect to define a command to build the whole application including the libraries in the same section.

"build:all": "ng build common && ng build plugin1 && ng build plugin2 && ng build"

Now, to run the demo, you need to create 2 terminal window and run the ‘npm run serve:plugins’ and ‘ng serve — hmr’ commands, respectively.

WHAT IF WE CHANGE COMPILER SETTINGS?

By default, Angular CLI creates a new solution with some predefined compiler settings. Let us try to change two of them, in particular ‘enableIvy’ and ‘aot’ (AOT stands for Ahead Of Time compilation). These settings could be found in the angular.json and tsconfig.json files.

1. AOT on, Ivy on works fine.

2. AOT off, Ivy off.

You will encounter some difficulties with this configuration. To avoid error messages and successfully compile the solution, you should make the following changes.

In the plugin1.module.ts file, convert the arrowed function into normal exported functions and also remove arrowed syntax from them as shown below.

export function find(plugin: DemoPlugin): boolean {
return plugin.path === 'plugin1';
}
export function moduleBasePathFactory(pluginConfigService: PluginConfigService): string {
return pluginConfigService.value.find(find).baseUrl;
}

In the plugin1-routing.module.ts and plugin1-routing.module.ts files remove the function call from the imports section of the decorator. The result should look like code below.

export const routerModule = RouterModule.forChild(routes);imports: [routerModule],

In the app-routing.module.ts and plugin-loader-routing.module.ts files you should add the ‘useValue’ member to the ROUTES provider.

providers: [
{
provide: ROUTES,
useFactory: (pluginConfigService: PluginConfigService) => {
const pluginRoute = {
// This function is called when APP_INITIALIZER is not yet completed, so matcher is the only option
matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null =>
group.segments.length && pluginConfigService.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null,
// Lazy load the plugin loader module because it may contain many ‘heavy’ dependencies
loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
};
return […staticRoutes, pluginRoute];
},
// The member below must exist if Ivy is off
useValue: [],
deps: [PluginConfigService],
multi: true,
},
]

I have applied these changes to the public repository demo. After successful compilation, the solution runs as expected.

3. AOT off, Ivy on.

If you run the solution in this configuration without any changes, you will get the error ‘ERROR Error: Uncaught (in promise): Error: Cannot match any routes. URL Segment: ‘plugin1’’. To get rid of it you should remove useValue: [] from the ROUTES provider. After that, the app works as expected.

4. AOT on, Ivy off.

Please, add useValue: [] in the plugin-loader-routing.module.ts file again.

In this configuration you are supposed to encounter a runtime error. This occurs because you have to manually compile loaded plugins. Fortunately, this is also achievable.

Go to the plugin-loader.module.ts file and a new function as recommended below.

export function createCompiler(compilerFactory: CompilerFactory) {
return compilerFactory.createCompiler();
}

Also add 3 providers to provide COMPILER_OPTIONS, CompilerFactory, and Compiler itself respectively to the same module.

providers: [
{
provide: COMPILER_OPTIONS,
useValue: {},
multi: true
},
{
provide: CompilerFactory,
useClass: JitCompilerFactory,
deps: [COMPILER_OPTIONS],
},
{
provide: Compiler,
useFactory: createCompiler,
deps: [CompilerFactory],
},
]

When it is done, go to the plugin-loader-routing.module.ts file and perform the following modifications against the ROUTES provider in order to compile loaded plugins manually.

providers: [
{
provide: ROUTES,
useFactory: (pluginConfigService: PluginConfigService, compiler: Compiler) => pluginConfigService.value.map(plugin => ({
matcher: (_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null => group.segments[0].path === plugin.path ? { consumed: [] } : null,
loadChildren: () =>
loadModule(`${plugin.baseUrl}/${plugin.pluginFile}?v=${new Date().getTime()}`)
.then(m => m[plugin.moduleName])
.then(result => result instanceof NgModuleFactory ? Promise.resolve(result) : compiler.compileModuleAsync(result))
})),
// The member below must exist if Ivy is off
useValue: [],
deps: [PluginConfigService, Compiler],
multi: true,
},
]

It seems, we can no longer use the ROUTES injection token in the app routing module because in this case Angular does not understand that the plugin loader module should be lazy loaded and this will result in the error saying ‘Runtime compiler is not loaded’. It appears, we can only provide the routes directly to avoid this error. So, to work around the problem, comment out the provider array in the module decorator and add a variable to refer to the plugin config service.

let service: PluginConfigService;

We will also need a function to match plugin routes.

export function pluginMatcher(_segments: UrlSegment[], group: UrlSegmentGroup, _route: Route): UrlMatchResult | null {
return group.segments.length && service.value.some(plugin => plugin.path === group.segments[0].path) ? { consumed: [group.segments[0]] } : null;
}

Modify the imports array in the module decorator.

imports: [RouterModule.forRoot([
…staticRoutes,
{
matcher: pluginMatcher,
loadChildren: () => import('./plugin-loader/plugin-loader.module').then((m) => m.PluginLoaderModule)
}
])],

And finally add the class constructor to the module because we need to assign a value to the service variable.

public constructor(private readonly pluginConfigService: PluginConfigService) {
service = this.pluginConfigService;
}

After all these manipulations, the app should be able to load plugins as expected. I would not recommend this option due to untidiness in code to a certain extent.

CONCLUSION

Loading plugins is not natively supported by Angular, despite the fact this programming technique could be demanded by enterprise-level development teams. Nevertheless, it is possible to do the job in different configurations by using almost the same approach. Probably in the future it may become a part of the standard Angular framework. I hope this article will be useful for you and help you build a better solution.

Developer at Geodis Australia