DEV Community

loading...

Angular dynamic modules at runtime with Module Federation

Sean Perkins
・8 min read

Angular 12 recently launched with the added enhancements of Webpack 5 and opening the door to using module federation. If you are looking for a great deep-dive into module federation and micro-frontends, I suggest reading: https://www.angulararchitects.io/aktuelles/the-microfrontend-revolution-module-federation-in-webpack-5/.

Micro frontends

Micro frontends and more importantly module federation, allows developers the flexibility of remotely requesting a module on the network and bootstrapping that module into their application. Similar to lazy-loading, remotely loading modules can greatly reduce the bundle size of your application and the network cost to loading modules that end up unused by your users.

There's other benefits to micro-frontends, including:

  • A/B serving features
  • Incremental updates
  • Independent versioning of features
  • Dynamic feature resolutions

Getting started

The Angular Architects package @angular-architects/module-federation creates a simple API to request modules and pull them into your application.

Assuming an NX mono-repo set-up:

To add module federation to your workspace, run:

nx add @angular-architects/module-federation@next
Enter fullscreen mode Exit fullscreen mode

This will install the necessary dependency, with the schematics needed to add remote apps to be consumed by module federation.

Let's assume you have the following mono-repo:

apps/
    shell/
    remote/
Enter fullscreen mode Exit fullscreen mode

Shell is your consuming application. It is the highest container, responsible for what pieces are pulled in and the composition of features.

Remote is the feature set, isolated and decoupled to be pulled in on-demand, by the shell.

To make these apps compatible with module federation, you will need to run the schematic on their projects:

nx add @angular-architects/module-federation --project shell --port 5000
nx add @angular-architects/module-federation --project remote --port 6000
Enter fullscreen mode Exit fullscreen mode

You can configure the port to be whatever you desire. This only matters for local development.

This schematic will:

  • Generate a webpack.config.js and webpack.config.prod.js with a boilerplate for module federation
  • Update angular.json for the project definition, to reference the extraWebpackConfig and update the project's port to the value specified
  • Split the bootstrap logic of your app from main.ts to bootstrap.ts and reference the function in main.ts.

Module Federation Plugin

Inside your webpack.config.js you will want to get accommodated with the config for module federation.

module.exports = {
    output: {
        uniqueName: 'remote',
        publicPath: 'auto',
    },
    optimization: {
        runtimeChunk: false,
    },
    resolve: {
        alias: {
            ...sharedMappings.getAliases(),
        },
    },
    plugins: [
        new ModuleFederationPlugin({
            name: 'remote',
            filename: 'remoteEntry.js',
            exposes: {
                './Module':
                    './apps/remote/src/app/app.module.ts',
            },
            shared: {
                '@angular/core': {
                    singleton: true,
                    strictVersion: true,
                    requiredVersion: '>= 12.0.0',
                },
                '@angular/common': {
                    singleton: true,
                    strictVersion: true,
                    requiredVersion: '>= 12.0.0',
                },
                '@angular/common/http': {
                    singleton: true,
                    strictVersion: true,
                    requiredVersion: '>= 12.0.0',
                },
                '@angular/router': {
                    singleton: true,
                    strictVersion: true,
                    requiredVersion: '>= 12.0.0',
                },
                ...sharedMappings.getDescriptors(),
            },
        }),
        sharedMappings.getPlugin(),
    ],
};
Enter fullscreen mode Exit fullscreen mode
  • name should align with your output.uniqueName and match your shell app's webpack config for the remotes section.
  • fileName is the name of the generated file's entry point to your remote module. This file name will not be renamed in the build process and is the asset you will be referencing in your shell to request the module.
  • exposes is the named paths to modules, components, etc. that you want to make accessible to the shell to pull in. I'll explain this further below.
  • shared the shared dependencies (and rules) between your remote and shell app. This allows tight control for your remote to not re-declare modules/services that you expect to be singleton, or prevent mismatched versions of Angular or other libraries existing in the eco-system. By assigning strictVersion to true, the build will quick fail if an issue occurs. Removing this option will potentially pass the build, but display warnings in the dev console.

You can now locally run your shell and remote with:

nx serve shell -o
nx serve remote -o
Enter fullscreen mode Exit fullscreen mode

-o will automatically launch the apps in your default browser

Exposes (continued)

While the example schematic will generate the exposes section with the AppModule and AppComponent I would strongly advise against this.

When serving the remote and shell to develop locally, the sites will be deployed to:

  • localhost:5000
  • localhost:6000

When you make changes to the remote app folder's contents, only localhost:6000 will live-reload.

This means for local development, consuming the remote into the shell app is not sustainable for development against remote-specific functionality.

So what do I propose?

The AppModule of your remote app should be your "demo" or self-deployed landscape. You will import modules and providers to establish a foundation to locally test your remote app in isolation. The AppModule should have a separate module of the cohesive functionality you are wanting to expose, i.e: LoginModule.

With this approach, exposing and pulling in AppModule has the potential to pulling in duplicate root providers; as well as pulling duplicate assets and styles.

Instead with:

exposes: {
    './Module':
        './apps/remote/src/app/login/login.module.ts',
},
Enter fullscreen mode Exit fullscreen mode

./Module is nomenclature you can define as you please. I would recommend being more specific in a diverse system.

The shell app still can access the shared functionality to pull in, but doesn't pull in more than it needs to.

I can locally develop on localhost:6000, having an accurate test bed for my application and live-dev against the changes with ease.

Now that the foundation of module federation have been set, let's jump into dynamically swapping modules at runtime.


Dynamic Runtime modules

All of the top resources available for module federation show statically referencing the modules in your shell app's route definition.

import { loadRemoteModule } from '@angular-architects/module-federation';

[...]

const routes: Routes = [
    [...]
    {
        path: 'flights',
        loadChildren: () =>
            loadRemoteModule({
                remoteEntry: 'http://localhost:3000/remoteEntry.js',
                remoteName: 'mfe1',
                exposedModule: './Module'
            })
            .then(m => m.FlightsModule)
    },
    [...]
];
Enter fullscreen mode Exit fullscreen mode

This serves a purpose when your application wants to independently build and manage known features. This doesn't however allow you conditionally serve features or create an application that does not have context of what features exist at build time.

Remote module diagram

Dynamic module federation

Dynamic module federation attempts to resolve this by allowing you independently request modules before bootstrapping Angular:

import { loadRemoteEntry } from '@angular-architects/module-federation';

Promise.all([
    loadRemoteEntry('http://localhost:3000/remoteEntry.js', 'mfe1')
])
.catch(err => console.error('Error loading remote entries', err))
.then(() => import('./bootstrap'))
.catch(err => console.error(err));

Enter fullscreen mode Exit fullscreen mode

Dynamic module federation diagram

Better... but still has a few drawbacks:

  • What if my remote module is routable? Will it recognize the route when I navigate directly to it?
  • How does this impact lazy loading?
  • Remote entries are still hard-coded

Dynamic runtime module federation

We need the ability to have a decoupled shell, that can dynamically request federated modules at runtime.

A real use case?

On our team, we want to dynamically serve separate authentication experiences for customers. Some customers use our platform's stock username/password authentication. Others have their own corporate SSO. All of them have strict branding standards that aren't compatible with each other.

We do however, want all customers to share the primary functionality of our platform - content management and learning delivery. Once they login to the application, they only need branding for their corporate logo and primary brand color; they can use all the existing interfaces.

Dynamic runtime modules

Less rigid example?

Feature toggles in an application. Some customers have "X" others have "Y". You want to serve one app that can respond to "X" and "Y".

Getting started

Authentication deals with routing and we need to allow our users to navigate to /authentication/login and get served the correct federated module for their company.

We will be using an injection token to store our route definitions as they relate to module federation.

export const PLATFORM_ROUTES = new InjectionToken<Routes>('Platform routes for module federation');
Enter fullscreen mode Exit fullscreen mode

If you used the the schematic discussed above, you should have a bootstrap.ts file. Prior to bootstrapping Angular, we need to request the registry of the modules that should exist for this user. This can be any network call, for this demo we will use a local JSON asset called platform-config.json

Platform config is going to describe all the modules, the location of the modules, the module name to bootstrap and the route to register in the shell app for the remote module.

{
    "authentication": {
        "path": "authentication",
        "remoteEntry": "http://localhost:5001/remoteEntry.js",
        "remoteName": "coreAuthentication",
        "exposedModule": "./LoginModule",
        "exposedModuleName": "LoginModule"
    }
}
Enter fullscreen mode Exit fullscreen mode
  • path is the Angular route namespace to load the remote module under.
  • remoteEntry is the served location of your remote module. This would be replaced with the served location (CDN, CloudFoundry, S3 asset, etc.) in a built environment. This currently references where we will be serving our Angular apps for local development.
  • exposedModule is the key in your remote app's webpack.config.js for the exposed module (your nomenclature)
  • exposedModuleName is the name of the Angular module that was exposed, this is leveraged for lazy loading.

In bootstrap.ts we will consume this asset and build the injection token value:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/module-federation';

import { AppModule } from './app/app.module';
import { PLATFORM_ROUTES } from './app/platform-routes';
import { environment } from './environments/environment';

if (environment.production) {
    enableProdMode();
}
fetch('/assets/platform-config.json').then(async (res) => {
    const config = await res.json();

    const platformRoutes: Routes = [];
    for (const [key, value] of Object.entries<any>(config)) {
        platformRoutes.push({
            path: value.path,
            loadChildren: () =>
                loadRemoteModule({
                    remoteEntry: value.remoteEntry,
                    remoteName: value.remoteName,
                    exposedModule: value.exposedModule,
                }).then((m) => m[value.exposedModuleName]),
        });
    }

    platformBrowserDynamic([
        {
            provide: PLATFORM_ROUTES,
            useValue: platformRoutes,
            multi: true,
        },
    ])
        .bootstrapModule(AppModule)
        .catch((err) => console.error(err));
});
Enter fullscreen mode Exit fullscreen mode

By passing the providers to platformBrowserDynamic, we are setting a static provider value prior to bootstrap, that can be used on bootstrap.

In the module responsible for your shell app's router module declaration (typically app-routing.module.ts), update as follows:

import { NgModule } from '@angular/core';
import { RouterModule, ROUTES, Routes } from '@angular/router';

import { PLATFORM_ROUTES } from './platform-routes';

@NgModule({
    imports: [
        RouterModule.forRoot(
            [
                /* Declare root routes in the factory below */
            ],
            { initialNavigation: 'enabled' }
        ),
        {
            ngModule: RouterModule,
            providers: [
                {
                    provide: ROUTES,
                    useFactory: (
                        staticRoutes: Routes = [],
                        dynamicRoutes: Routes = []
                    ) => {
                        let rootRoutes: Routes = [];

                        if (Array.isArray(staticRoutes)) {
                            rootRoutes = [...staticRoutes];
                        }
                        if (Array.isArray(dynamicRoutes)) {
                            rootRoutes = [...rootRoutes, ...dynamicRoutes];
                        }
                        rootRoutes.push({
                            path: '**',
                            redirectTo: '/authentication/login',
                        });
                        return rootRoutes;
                    },
                    deps: [ROUTES, PLATFORM_ROUTES],
                },
            ],
        },
    ],
    exports: [RouterModule],
})
export class AppRoutingModule {}

Enter fullscreen mode Exit fullscreen mode

Let's explain a bit...

RouterModule.forRoot([]) establishes a lot of necessary providers and functionality required for routing. Under the hood, all router modules roll-up the route definition to an injection token named ROUTES. We can bootstrap the module and immediately provide a new value on-top for the ROUTES value.

To allow our shell app to have it's own built-in routes as well as the dynamic runtime routes, we use a factory to concat rootRoutes and the dynamicRoutes (from our injection token PLATFORM_ROUTES).

Lastly, we have a fallback route, as routes will execute first-to-last, to handle global redirect behavior for unhandled routes.

Conclusion

At this point, we are rolling. We can now change our config while serving the different remotes and shell and see it swap out the served bundle. In a real environment, the config data would come from an endpoint.

Swapping different login screens

If you read this far I appreciate it. Module federation in Angular is a very new concept and I welcome feedback and questions on this topic!

Discussion (5)

Collapse
gr4vitonn profile image
gr4vitonn • Edited

Hello Sean,

Great article. Thank you for sharing.

I'm trying to do what you advise against: exposing AppModule and AppComponent.

So what I'm trying to achieve is the following:

  • have a host which lazily loads the remote on specific route
  • have a remote which has its own routes which lazily load modules

With your advice the code structure at remote app level will look like:
app.module.ts
|- feature.module.ts
_
_|- sub-feature1.module.ts
_
_|- sub-feature2.module.ts

By exposing tha app.module.ts and app.component.ts I would only have 1 router-outlet and flat structure at remote app level:
app.module.ts
_ |- feature1.module.ts
_ |- feature2.module.ts

Unfortunately I hit the wall with this approach (exposing the app.module.ts and app.component.ts). When the host loads the remote, the remote's sub-route gets loaded in the host's router-outlet, without running the remote's app.component.ts (nothing runs from the remote's app.component.ts and the content of app.component.html is not rendered).

So I figured I can enforce it by using named outlets. However it just breaks the app completely.

I also tried to rename the remote's app.component and app.module to avoid possible name collision, but nothing changed.

Do you know what causes the problem?

Collapse
seanperkins profile image
Sean Perkins Author

In your example, does the feature.module.ts have a component with a router outlet and a router module declaration for forChild([...])? By exposing feature.module.ts in your remote's webpack config, you should be able to something similar to this in your shell/root's app routing module:

RouterModule.forRoot([
   {
       path: 'feature-path',
       loadChildren: () => loadRemoteModule({
            remoteEntry: 'http://localhost:xxxx/remoteEntry.js',
            remoteName: 'remoteExampleName',
            exposedModule: './FeatureModule'
       }).then(m => m['FeatureModuleName'])
   }
])
Enter fullscreen mode Exit fullscreen mode

This should lazy-load your remote app's feature module contents when navigating to /feature-path and then defer to that module's structure for further nested lazy loading. I've done something similar where my shell loads different authentication experiences under the /authentication namespace and certain auth experiences have further lazy loading for forgot password & user registration screens.

If you can statically declare the module federation information, it's much easier. Otherwise, you'll have to have that information in a config and pass static tokens into the app module of the shell and override the ROUTES token as shown above.

If you eco-system is small enough or not having live reloading against the remote isn't a deal breaker, you can federate the AppModule from the remote and not run into any issues.

Collapse
craig_payne_26cd679fd3b28 profile image
weirdfishes

Is it possible running two versions of Angular on the same page? One for Host and a different version for Remotes?

Collapse
seanperkins profile image
Sean Perkins Author

Yes, it should be possible. I'd be cautious with doing this over major versions; as you're pulling in the remote app into the context of the host. One of the many benefits of micro frontends and module federation, is that you can pull in different frameworks, versions, etc.; so that you can independently manage slices of your application and update and deploy those changes.

If your two Angular applications are using Angular Elements, you may need to share the PlatformRef between these (via the window global). There's a great walkthrough of this in the link at the top of this article.

Collapse
craig_payne_26cd679fd3b28 profile image
weirdfishes

Hi Sean

Thank you for the response!

I'm currently testing with:

  • Shell (Angular 12.1.0-next.6)
  • Remote (Angular 12.0.5)

Shell shared configured with @angular/* singleton: true
Remote shared configured with @angular/* singleton: false

When Shell injects Remote with loadRemoteModule lazy routing; node_modules_angular_core___ivy_ngcc___fesm2015_core_js (Angular 12.0.5 chunk from Remote) JS file is added from Remote. This immediately fires a run-time error: Error: inject() must be called from an injection context. From what I have found, the error is caused by two Angular instances running concurrently.

If I updated shared configured with
Shell shared configured with @angular/* singleton: true
Remote shared configured with @angular/* singleton: true

The Remote; is lazy loaded successfully, loaded on <router-outlet> but Remote module is rendered from the single @angular/core instance from Shell. Not ideal.

I have multiple micro-frontends running on a single Shell instance. I won't be able to necessarily keep all micro-frontends up to date with latest Angular, and Shell instance might move ahead in versions. Thought module federation would have some solutions.