This tutorial shows how to use Webpack Module Federation together with the Angular CLI and the @angular-architects/module-federation
plugin. The goal is to make a shell capable of loading a separately compiled and deployed microfrontend:
In this part you will clone the starter kit and inspect its projects.
-
If not already done during the preparation, Clone the starter kit for this tutorial:
git clone https://github.com/manfredsteyer/module-federation-plugin-example.git --branch starter
-
If not already done during the preparation, switch into the project directory and install the dependencies with npm:
cd module-federation-plugin-example npm i
-
Start the shell (
ng serve shell -o
) and inspect it a bit:-
Click on the
flights
link. It leads to a dummy route. This route will later be used for loading the separately compiled Micro Frontend. -
Have a look to the shell's source code.
-
Stop the CLI (
CTRL+C
).
-
-
Do the same for the Micro Frontend. In this project, it's called
mfe1
(Micro Frontend 1) You can start it withng serve mfe1 -o
.
Now, let's activate and configure module federation:
-
Install
@angular-architects/module-federation
into the shell:ng add @angular-architects/module-federation --project shell --type host --port 4200
Also, install it into the micro frontend:
ng add @angular-architects/module-federation --project mfe1 --type remote --port 4201
This activates module federation, assigns a port for ng serve, and generates the skeleton of a module federation configuration.
-
Switch into the project
mfe1
and open the generated configuration fileprojects\mfe1\webpack.config.js
. It contains the module federation configuration formfe1
. Adjust it as follows:const { shareAll, withModuleFederationPlugin, } = require('@angular-architects/module-federation/webpack'); module.exports = withModuleFederationPlugin({ name: 'mfe1', exposes: { // Update this whole line (both, left and right part): './Module': './projects/mfe1/src/app/flights/flights.module.ts', }, shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto', }), }, });
This exposes the
FlightsModule
under the Name./Module
. Hence, the shell can use this path to load it. -
Switch into the
shell
project and open the fileprojects\shell\webpack.config.js
. Make sure, the mapping in the remotes section uses port4201
(and hence, points to the Micro Frontend):const { shareAll, withModuleFederationPlugin, } = require('@angular-architects/module-federation/webpack'); module.exports = withModuleFederationPlugin({ remotes: { // Check this line. Is port 4201 configured? 'mfe1': 'http://localhost:4201/remoteEntry.js', }, shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto', }), }, });
This references the separately compiled and deployed
mfe1
project. -
Open the
shell
's router config (projects\shell\src\app\app.routes.ts
) and add a route loading the Micro Frontend:{ path: 'flights', loadChildren: () => import('mfe1/Module') .then(m => m.FlightsModule) }, { path: '**', component: NotFoundComponent } // DO NOT insert routes after this one. // { path:'**', ...} needs to be the LAST one.
Please note that the imported URL consists of the names defined in the configuration files above.
-
As the URL
mfe1/Module
does not exist at compile time, ease the TypeScript compiler by adding the following line to the fileprojects\shell\src\decl.d.ts
:declare module 'mfe1/Module';
Solution for this lab: Please find the solution for this lab in the branch
ngconf-2022-safepoint-step2
.
Now, let's try it out!
-
Start the
shell
andmfe1
side by side in two different terminals:ng serve shell -o ng serve mfe1 -o
Hint: You might use two terminals for this.
-
After a browser window with the shell opened (
http://localhost:4200
), click onFlights
. This should load the Micro Frontend into the shell: -
Also, ensure yourself that the Micro Frontend also runs in standalone mode at http://localhost:4201:
Hint: You can also call the following script to start all projects at once: npm run run:all
. This script is added by the Module Federation plugin.
Congratulations! You've implemented your first Module Federation project with Angular!
Now, let's remove the need for registering the Micro Frontends upfront with with shell.
-
Switch to your
shell
application and open the fileprojects\shell\webpack.config.js
. Here, remove the registered remotes:remotes: { // Remove this line: // "mfe1": "http://localhost:4201/remoteEntry.js", },
-
Open the file
app.routes.ts
and use the functionloadRemoteModule
instead of the dynamicimport
statement:import { loadRemoteModule } from '@angular-architects/module-federation'; [...] const routes: Routes = [ [...] { path: 'flights', loadChildren: () => loadRemoteModule({ type: 'module', remoteEntry: 'http://localhost:4201/remoteEntry.js', exposedModule: './Module' }) .then(m => m.FlightsModule) }, [...] ]
Remarks:
type: 'module'
is needed for Angular 13 or higher as beginning with version 13, the CLI emits EcmaScript modules instead of "plain old" JavaScript files. -
Restart both, the
shell
and the micro frontend (mfe1
). -
The shell should still be able to load the micro frontend. However, now it's loaded dynamically.
Solution for this lab: Please find the solution for this lab in the branch
ngconf-2022-safepoint-step4a
.
This was quite easy, wasn't it? However, we can improve this solution a bit. Ideally, we load the Micro Frontend's remoteEntry.js before Angular bootstraps. This file contains meta data about the Micro Frontend, esp. about its shared dependencies. Knowing about them upfront helps Module Federation to avoid version conflicts.
-
Switch to the
shell
project and open the filemain.ts
. Adjust it as follows:import { loadRemoteEntry } from '@angular-architects/module-federation'; Promise.all([ loadRemoteEntry({ type: 'module', remoteEntry: 'http://localhost:4201/remoteEntry.js', }), ]) .catch((err) => console.error('Error loading remote entries', err)) .then(() => import('./bootstrap')) .catch((err) => console.error(err));
-
Restart both, the
shell
and the micro frontend (mfe1
). -
The shell should still be able to load the micro frontend.
Solution for this lab: Please find the solution for this lab in the branch
ngconf-2022-safepoint-step4b
.
-
Add a library to your monorepo:
ng g lib auth-lib
-
In your
tsconfig.json
in the workspace's root, adjust the path mapping forauth-lib
so that it points to the libs entry point:"auth-lib": [ "projects/auth-lib/src/public-api.ts" ]
-
As most IDEs only read global configuration files like the
tsconfig.json
once, restart your IDE (Alternatively, your IDE might also provide an option for reloading these settings). -
Switch to your
auth-lib
project and open the fileauth-lib.service.ts
(projects\auth-lib\src\lib\auth-lib.service.ts
). Adjust it as follows:import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root', }) export class AuthLibService { private userName: string; public get user(): string { return this.userName; } constructor() {} public login(userName: string, password: string): void { // Authentication for **honest** users TM. (c) Manfred Steyer this.userName = userName; } }
-
Switch to your
shell
project and open itsapp.component.ts
(projects\shell\src\app\app.component.ts
). Use theAuthLibService
to login a user:[...] // IMPORTANT: Make sure you import the service // from 'auth-lib'! import { AuthLibService } from 'auth-lib'; [...] @Component({ selector: 'app-root', templateUrl: './app.component.html', }) export class AppComponent { title = 'shell'; constructor(private service: AuthLibService) { this.service.login('Max', null); } }
-
Switch to your
mfe1
project and open itsflights-search.component.ts
(projects\mfe1\src\app\flights\flights-search\flights-search.component.ts
). Use the shared service to retrieve the current user's name:[...] // IMPORTANT: Make sure you import the service // from 'auth-lib'! import { AuthLibService } from 'auth-lib'; [...] export class FlightsSearchComponent { // Add this: user = this.service.user; // And add that: constructor(private service: AuthLibService) { } [...] }
-
Open this component's template(
flights-search.component.html
) and data bind the propertyuser
:<div id="container"> <!-- Add this line: --> <div>User: {{user}}</div> [...] </div>
-
Restart both, the
shell
and the micro frontend (mfe1
). -
In the shell, navigate to the micro frontend. If it shows the used user name
Max
, the library is shared.
Remarks: All the libraries of your Monorepo are shared by default. The next section shows how to select libraries to share.
Solution for this lab: Please find the solution for this lab in the branch
ngconf-2022-safepoint-step5
.
-
Install
@angular-architects/module-federation-tools
:npm i @angular-architects/module-federation-tools
-
Restart your VS Code (or your TS Server within VS Code at least)
-
Open your shell's
app.routes.ts
and add the following routes:[...] // Add this import: import { WebComponentWrapper, WebComponentWrapperOptions, startsWith } from '@angular-architects/module-federation-tools'; [...] export const APP_ROUTES: Routes = [ [...] // Add these routes: { path: 'react', component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://witty-wave-0a695f710.azurestaticapps.net/remoteEntry.js', remoteName: 'react', exposedModule: './web-components', elementName: 'react-element' } as WebComponentWrapperOptions }, { path: 'angular1', component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://nice-grass-018f7d910.azurestaticapps.net/remoteEntry.js', remoteName: 'angular1', exposedModule: './web-components', elementName: 'angular1-element' } as WebComponentWrapperOptions }, { path: 'angular2', component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://gray-pond-030798810.azurestaticapps.net//remoteEntry.js', remoteName: 'angular2', exposedModule: './web-components', elementName: 'angular2-element' } as WebComponentWrapperOptions }, { matcher: startsWith('angular3'), component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://gray-river-0b8c23a10.azurestaticapps.net/remoteEntry.js', remoteName: 'angular3', exposedModule: './web-components', elementName: 'angular3-element' } as WebComponentWrapperOptions }, { path: 'vue', component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://mango-field-0d0778c10.azurestaticapps.net/remoteEntry.js', remoteName: 'vue', exposedModule: './web-components', elementName: 'vue-element' } as WebComponentWrapperOptions }, { path: 'angularjs', component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://calm-mud-0a3ee4a10.azurestaticapps.net/remoteEntry.js', remoteName: 'angularjs', exposedModule: './web-components', elementName: 'angularjs-element' } as WebComponentWrapperOptions }, { matcher: startsWith('angular3'), component: WebComponentWrapper, data: { type: 'script', remoteEntry: 'https://gray-river-0b8c23a10.azurestaticapps.net/remoteEntry.js', remoteName: 'angular3', exposedModule: './web-components', elementName: 'angular3-element' } as WebComponentWrapperOptions }, // THIS needs to be the last route!!! { path: '**', component: NotFoundComponent } ];
Remarks: The URL matcher
startsWith
makes the shell to ignore the remaining part of the URL. This is necessary when the loaded micro frontend uses a router too.Remarks: Please note that we are using
type: 'script'
here. This is needed for classic webpack setups as normally used in the Vue and React world as well as for Angular before version 13. Beginning with version 13, the CLI emits EcmaScript module instead of "plain old" JavaScript files. Hence, when loading a remote compiled with Angular 13 or higher, you need to settype
tomodule
. In our case, however, the remotes we find at the shown URLs in the cloud are Angular 12-based, hence we needtype: 'script'
. -
Open your shell's
app.component.html
and add the following links:<!-- Add these links --> <li><a routerLink="/react">React</a></li> <li><a routerLink="/angular1">Angular 1</a></li> <li><a routerLink="/angular2">Angular 2</a></li> <li><a routerLink="/angular3/a">Angular 3</a></li> <li><a routerLink="/vue">Vue</a></li> <li><a routerLink="/angularjs">AngularJS</a></li>
-
Open your shell's
bootstrap.ts
and use thebootstrap
helper function found in@angular-architects/module-federation-tools
for bootstrapping:import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; import { bootstrap } from '@angular-architects/module-federation-tools'; bootstrap(AppModule, { production: environment.production, appType: 'shell', });
Remarks: This special bootstrap function takes care of some workarounds necessary to run several versions of Angular side by side.
-
Start your
shell
and themfe1
project (e. g. by callingnpm run run:all
) and try it out.
In this part of the lab, we will investigate the loaded micro frontend that has been called "MF Angular #3" before. We want to draw your attention to the following details:
-
The application is bootstrapped with the bootstrap function already used above. Please note that here,
appType
is set tomicrofrontend
. -
The
AppModule
is wrapping some components as web components using Angular Elements in it's ngDoBootstrap method. -
The webpack config exposes the whole
bootstrap.ts
file. Hence, everyone importing it can use the provided web components. -
The webpack config shares libraries like
@angular/core
.
So far, we just hardcoded the URLs pointing to our Micro Frontends. However, in a real world scenario, we would rather get this information at runtime from a config file or a registry service. This is what this part of the lab is about.
- Switch to the shell and create a file
mf.manifest.json
in itsassets
folder (projects\shell\src\assets\mf.manifest.json
):
{
"mfe1": "http://localhost:4201/remoteEntry.js"
}
-
Adjust the shell's
main.ts
(projects/shell/src/main.ts
) as follows:import { loadManifest } from '@angular-architects/module-federation'; loadManifest('assets/mf.manifest.json') .catch((err) => console.error('Error loading remote entries', err)) .then(() => import('./bootstrap')) .catch((err) => console.error(err));
The imported loadManifest
function also loads the remote entry points.
- Adjust the shell's lazy route pointing to the Micro Frontend as follows (
projects/shell/src/app/app.routes.ts
):
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
type: 'manifest',
remoteName: 'mfe1',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
-
Restart both, the
shell
and the micro frontend (mfe1
). -
The shell should still be able to load the micro frontend.
Hint: The ng add
command used initially also provides an option --type dynamic-host
. This makes ng add
to generate the mf.manifest.json
as well as the call to loadManifest
in the main.ts
.
Solution for this lab: Please find the solution for this lab in the branch
ngconf-2022-safepoint-step4c
.
Have a look at this article series about Module Federation