Seamlessly using Webpack Module Federation with the Angular CLI.
Big thanks to the following people who helped to make this possible:
- Tobias Koppers, Founder of Webpack
- Dmitriy Shekhovtsov, Angular GDE
- Michael Egger-Zikes, Angular Architects
- Angular CLI 12 or higher (13, 14, 15)
Module Federation allows loading separately compiled and deployed code (like micro frontends or plugins) into an application. This plugin makes Module Federation work together with Angular and the CLI.
✅ Generates the skeleton for a Module Federation config.
✅ Installs a custom builder to enable Module Federation.
✅ Assigning a new port to serve (ng serve
) several projects at once.
The module federation config is a partial webpack configuration. It only contains stuff to control module federation. The rest is generated by the CLI as usual.
Since Version 1.2, we also provide some advanced features like:
✅ Dynamic Module Federation support
✅ Sharing Libs of a Monorepo
- Angular 12: @angular-architects/module-federation: ^12.0.0
- Angular 13: @angular-architects/module-federation: ~14.2.0
- Angular 14: @angular-architects/module-federation: ^14.3.0
- Angular 15: @angular-architects/module-federation: ^15.0.0
Beginning with Angular 13, we had to add some changes to adjust to the Angular CLI. Please see the next section for this.
This library supports ng update
:
ng update @angular-architects/module-federation
If you update by hand (e. g. via npm install
), make sure you also install a respective version of ngx-build-plus (version 15 for Angular 15, version 14 for Angular 14, version 13 for Angular 13, etc.)
Beginning with Angular 13, the CLI generates EcmaScript modules instead of script files. This affects how we work with Module Federation a bit.
Please find information on migrating here:
Migration Guide for Angular 13+
If you start from the scratch, ng add
will take care of these settings.
ng add @angular-architects/module-federation
- Adjust the generated
webpack.config.js
file - Repeat this for further projects in your workspace (if needed)
npm install --save-dev @angular-architects/module-federation
nx g @angular-architects/module-federation:init
- Adjust the generated
webpack.config.js
file - Repeat this for further projects in your workspace (if needed)
With version 14, we've introduced a --type switch for ng add
and the init
schematic. Set it to one of the following values to get a more streamlined configuration file:
host
dynamic-host
remote
A dynamic host reads the micro frontend's URLs from a configuration file at runtime.
Please find here a tutorial that shows how to use this plugin.
Please have a look at this article series about Module Federation.
This example loads a microfrontend into a shell:
Please have a look into the example's readme. It points you to the important aspects of using Module Federation.
While the above-mentioned tutorial and blog articles guide you through using Module Federation, this section draws your attention to some advanced aspects of this plugin and Module Federation in general.
Since version 1.2, we provide helper functions making dynamic module federation really easy. Just use our loadRemoteModule
function instead of a dynamic include
, e. g. together with lazy routes:
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const routes: Routes = [
[...]
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:3000/remoteEntry.js',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
[...]
]
If somehow possible, load the remoteEntry
upfront. This allows Module Federation to take the remote's metadata in consideration when negotiating the versions of the shared libraries.
For this, you could call loadRemoteEntry
BEFORE bootstrapping Angular:
// main.ts
import { loadRemoteEntry } from '@angular-architects/module-federation';
Promise.all([
loadRemoteEntry({
type: 'module',
remoteEntry: 'http://localhost:3000/remoteEntry.js',
}),
])
.catch((err) => console.error('Error loading remote entries', err))
.then(() => import('./bootstrap'))
.catch((err) => console.error(err));
The bootstrap.ts
file contains the source code normally found in main.ts
and hence, it calls platform.bootstrapModule(AppModule)
. You really need this combination of an upfront file calling loadRemoteEntry and a dynamic import loading another file bootstrapping Angular because Angular itself is already a shared library respected during the version negotiation.
Then, when loading the remote Module, you set to mention the remoteEntry
property anyway, as it also acts as an internal identifier for the remote:
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const routes: Routes = [
[...]
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
type: 'module',
remoteEntry: 'http://localhost:3000/remoteEntry.js',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
[...]
]
Let's assume, you have an Angular CLI Monorepo or an Nx Monorepo using path mappings in tsconfig.json
for providing libraries:
"shared-lib": [
"projects/shared-lib/src/public-api.ts",
],
You can now share such a library across all your micro frontends (apps) in your mono repo. This means, this library will be only loaded once.
Beginning with version 14, we use a more steamlined configuration, when using the above mentioned --type switch with one of the following options: remote
, host
, dynamic-host
.
This new configuration automatically shares all local libararies. Hence, you don't need to do a thing.
However, if you want to control, which local libraries to share, you can use the the sharedMappings
array:
module.exports = withModuleFederationPlugin({
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: 'auto',
}),
},
sharedMappings: ['shared-lib'],
});
Please don't forget that sharing in Module Federation is always an opt-in: You need to add this setting to each micro frontend that should share it.
In previous versions, you registered the lib name with the SharedMappings
instance in your webpack config:
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
[...]
const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
path.join(__dirname, '../../tsconfig.json'),
['auth-lib']
);
Beginning with version 1.2, the boilerplate for using SharedMappings
is generated for you. You only need to add your lib's name here.
This generated code includes providing metadata for these libraries for the ModuleFederationPlugin
and adding a plugin making sure that even source code generated by the Angular Compiler uses the shared version of the library.
plugins: [
new ModuleFederationPlugin({
[...]
shared: {
[...]
...sharedMappings.getDescriptors()
}
}),
sharedMappings.getPlugin(),
],
The helper function share adds some additional options for the shared dependencies:
shared: share({
"@angular/common": {
singleton: true,
strictVersion: true,
requiredVersion: 'auto',
includeSecondaries: true
},
[...]
})
The added options are requireVersion: 'auto'
and includeSecondaries
.
If you set requireVersion
to 'auto'
, the helper takes the version defined in your package.json
.
This helps to solve issues with not (fully) met peer dependencies and secondary entry points (see Pitfalls section below).
By default, it takes the package.json
that is closest to the caller (normally the webpack.config.js
). However, you can pass the path to an other package.json
using the second optional parameter. Also, you need to define the shared libray within the node dependencies in your package.json
.
Instead of setting requireVersion to auto time and again, you can also skip this option and call setInferVersion(true)
before:
setInferVersion(true);
If set to true
, all secondary entry points are added too. In the case of @angular/common
this is also @angular/common/http
, @angular/common/http/testing
, @angular/common/testing
, @angular/common/http/upgrade
, and @angular/common/locales
. This exhaustive list shows that using this option for @angular/common
is not the best idea because normally, you don't need most of them.
Since version 14.3,
includeSecondaries
is true by default.
However, this option can come in handy for quick experiments or if you want to quickly share a package like @angular/material
that comes with a myriad of secondary entry points.
Even if you share too much, Module Federation will only load the needed ones at runtime. However, please keep in mind that shared packages can not be tree-shaken.
To skip some secondary entry points, you can assign a configuration option instead of true
:
shared: share({
"@angular/common": {
singleton: true,
strictVersion: true,
requiredVersion: 'auto',
includeSecondaries: {
skip: ['@angular/common/http/testing']
}
},
[...]
})
The shareAll
helper shares all your dependencies defined in your package.json
. The package.json
is look up as described above:
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: 'auto'
}),
...sharedMappings.getDescriptors()
}
The options passed to shareAll are applied to all dependencies found in your package.json
.
This might come in handy in an mono repo scenario and when doing some experiments/ trouble shooting.
Big thanks to Michael Egger-Zikes, who came up with these solutions.
Module Federation allows to directly bundle shared dependencies into your app's bundles. Hence, you don't need to load an additional bundle per shared dependency. This can be interesting to improve an application's startup performance, when there are lots of shared dependencies.
One possible usage for improving the startup times is to set eager
to true
just for the host. The remotes loaded later can reuse these eager dependencies alothough they've been shipped via the host's bundle (e. g. its main.js
). This works best, if the host always has the highest compatible versions of the shared dependencies. Also, in this case, you don't need to load the remote entry points upfront.
While the eager
flag is an out of the box feature provided by module federation since its very first days, we need to adjust the webpack configuration used by the Angular CLI a bit to avoid code duplication in the generated bundles. The new withModuleFederationPlugin
helper that has been introduced with this plugin's version 14 does this by default. The config just needs to set eager to true
.
module.exports = withModuleFederationPlugin({
shared: {
...shareAll({
singleton: true,
eager: true,
pinned: true,
strictVersion: true,
requiredVersion: 'auto',
}),
},
});
As shown in the last example, we also added another property: pinned. This makes sure, the shared dependency is put into the application's (e. g. the host's) bundle, even though it's not used there. This allows to preload dependencies that are needed later but subsequently loaded micro frontends via one bundle.
If the plugin detects that you are using Nx (it basically looks for a nx.json
), it uses the builders provided by Nx.
Since Version 12.4.0 of this plugin, we support the new jsdom-based Angular Universal API for Server Side Rendering (SSR). Please note that SSR only makes sense in specific scenarios, e. g. for customer-facing apps that need SEO.
To make use of SSR, you should enable SSR for all of your federation projects (e. g. the shell and the micro frontends).
If you start with a new project, you should add Angular Universal BEFORE adding Module Federation:
ng add @nguniversal/common --project yourProject
ng add @angular-architects/module-federation --project yourProject
Then, adjust the port in the generated server.ts
:
const PORT = 5000;
After this, you can compile and run your application:
ng build yourProject && ng run yourProject:server
node dist/yourProject/server/main.js
If you already use @angular-architects/module-federation
, you can add Angular Universal this way:
-
Update
@angular-architects/module-federation
to the latest version (>= 12.4).npm i @angular-architects/module-federation@latest
-
Now, we need to disable asynchronous bootstrapping temporarily. While it's needed for Module Federation, the schematics provided by Angular Universal assume that Angular is bootstrapped in an traditional (synchronous) way. After using these Schematics, we have to enable asynchronous bootstrapping again:
ng g @angular-architects/module-federation:boot-async false --project yourProject ng add @nguniversal/common --project yourProject ng g @angular-architects/module-federation:boot-async true --project yourProject
-
As now we have both, Module Federation and Angular Universal, in place, we can integrate them with each other:
ng g @angular-architects/module-federation:nguniversal --project yourProject
-
Adjust the used port in the generated
server.ts
file:const PORT = 5000;
-
Now, you can compile and run your application:
ng build yourProject && ng run yourProject:server node dist/yourProject/server/main.js
Please find an example here in the branch ssr
.
To try it out, you can checkout the main
branch of our example. After installing the dependencies (npm i
), you can repeat the steps for adding Angular Universal to an existing Module Federation project described above twice: Once for the project shell and the port 5000 and one more time for the project mfe1 and port 3000.
Please find a brain dump for this here.
In order to make module federation work, we need to bootstrap the app asynchronously. Hence, we need to move the bootstrap logic into a new bootstrap.ts
and import it via a dynamic import in the main.ts
. This is a typical pattern when using Module Federation. The dynamic import makes Module Federation to load the shared libs.
However, some schematics (e. g. ng add @angular/material
or @angular/pwa
) assume that bootstrapping directly happens in main.ts
. For this reason, there is a schematic, that helps you turning async bootstrapping on and off:
ng g @angular-architects/module-federation:boot-async false --project yourProject
ng add your-libraries-of-chioce --project yourProject
ng g @angular-architects/module-federation:boot-async true --project yourProject
If you get the warning No required version specified and unable to automatically determine one, Module Federation needs some help with finding out the version of a shared library to use. Reasons are not fitting peer dependencies or using secondary entry points like @angular/common/http
.
To avoid this warning you can specify to used version by hand:
shared: {
"@angular/common": {
singleton: true,
strictVersion: true,
requireVersion: '12.0.0'
},
[...]
},
You can also use our share
helper that infers the version number from your package.json
when setting requireVersion
to 'auto'
:
shared: share({
"@angular/common": {
singleton: true,
strictVersion: true,
requireVersion: 'auto'
},
[...]
})
If you use a shared component without exporting it via your library's barrel (index.ts
or public-api.ts
), you get the following error at runtime:
core.js:4610 ERROR Error: Uncaught (in promise): TypeError: Cannot read property 'ɵcmp' of undefined
TypeError: Cannot read property 'ɵcmp' of undefined
at getComponentDef (core.js:1821)