Webpack Module Federation: Micro-Frontend Powered by Modern Technologies

Ilya Isupov
netcracker
Published in
10 min readDec 9, 2021

Hi Medium! At Netcracker, we’ve been using a micro-frontend architecture for a long time, and since 2017 we’ve started developing our own platform tool for building micro-frontends.

Recently in the scope of a meetup, we have shown how to create complex applications developed by different teams in different release cycles and even technologies. In the live coding mode, we have combined Angular, React, and Vue into one SPA. There have been a lot of questions about the Webpack Module Federation. Since we are already migrating to this framework, here we will share our experience on how to create Angular host application + React/Angular/Vue micro-frontends with the option of independent versioning of the dependencies.

Goals

So the task is to build a working prototype according to all the rules of the microservice world in the frontend. This means that our prototype must have:

1. No coupling between plugins;

2. A common event bus for communication between plugins;

3. True-routing in host application;

4. Maximum re-use of repetitive “dumb” components.

It seems easy, but there are some interesting nuances. First of all, we should ensure the unlimited choice of frameworks and their versions. Secondly, there should be an option to add a dependency on any necessary library for each component. Thirdly, we should have a solution for how to arrange all of this into one big application with the sharing option as well as with maximum components re-use.

Minimum Requirements

For a high-level description of the future prototype, we have defined the following requirements:

1. Each service must be implemented in a separate repository and have its own CI/CD;

2. At the build stage, no one should know about the future neighbors. In this case, we are referring, of course, to the technical settings of the builder;

3. Plugins must be loaded dynamically, at runtime;

4. The plugins location should be dynamically selected;

5. There should be no customization of the current open-source solutions;

6. We should develop only a conceptual idea, a prototype, but without any complicated frameworks;

7. The host application should be able to embed plugins written in different frameworks, without restrictions by dependencies and their versions;

8. The prototype must dynamically configure a set of plugins in the application;

9. Each plugin should be able to use any library, regardless of what libraries are available in the host application.

This was the set of requirements we started our development with. But there is much of coding ahead!

Host Prototype

Our eager readers who like visual content, take a look at the repository:) But you’d better stay with us and watch all the steps!

The steps for migrating to the Module Federation concept do not vary much for different applications. The only difference will be the set of shared packages and modules, which we post for future use.

Below you can find step-by-step instructions and an example of how to migrate an existing Angular application. We use it to create a host that will aggregate plugins by routes.

In package.json, add the option to resolve dependencies using Webpack 5:

"resolutions": {"webpack": "^5.0.0"}

Set Yarn as the default package manager:

ng config cli.packageManager yarn

Add the package @angular-architects/module-federation to the project:

yarn add @angular-architects/module-federation

Configure the host application so that it can share its dependencies. At this stage, we use the default scope only.

// webpack.config.jsconst webpack = require("webpack");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const dependencies = require("./package.json").dependencies;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
path.join(__dirname, 'tsconfig.json'),
[/* mapped paths to share */]);

module.exports = {
output: {
uniqueName: "angularShell",
publicPath: "auto"
},
optimization: {
runtimeChunk: false
},
resolve: {
alias: {
...sharedMappings.getAliases(),
}
},
plugins: [
new webpack.ProvidePlugin({
"React": "react",
}),
new ModuleFederationPlugin({
shared: {
'@angular/common/http': {
requiredVersion: dependencies['@angular/common'],
singleton: false,
eager: true
},
'@angular/common': {
version: dependencies['@angular/common'],
singleton: false,
eager: true
},
'@angular/core': {
version: dependencies['@angular/core'],
requiredVersion: dependencies['@angular/core'],
singleton: false,
eager: true
},
'@angular/platform-browser': {
version: dependencies['@angular/platform-browser'],
requiredVersion: dependencies['@angular/platform-browser'],
singleton: false,
eager: true
},
'@angular/platform-browser-dynamic': {
version: dependencies['@angular/platform-browser-dynamic'],
requiredVersion: dependencies['@angular/platform-browser-dynamic'],
singleton: false,
eager: true
},
'@angular/router': {
version: dependencies['@angular/router'],
requiredVersion: dependencies['@angular/router'],
singleton: false,
eager: true
},
'@angular/cdk/a11y': {
version: dependencies['@angular/cdk/a11y'],
requiredVersion: dependencies['@angular/cdk/a11y'],
singleton: false,
eager: true
},
'@angular/animations': {
version: dependencies['@angular/animations'],
requiredVersion: dependencies['@angular/animations'],
singleton: false,
eager: true
},
}

})
],
};

In technical terms, we have configured the Module Federation for the host application. Now let’s add some code that implements the idea of the dynamics at runtime.

1. The next step: configuring the lazy route for the module loaded from a remote plugin.

2. Adding the Angular (v.) component to the Angular (v.) host application.

3. Adding the React component to the Angular (v.*) host application.

To ensure the loading of a module from a remote plugin, we should know:

1. the remote plugin URL;

2. the remote plugin name (specified in library.name in webpack.config.js);

3. the name of the module, which we expose in the remote plugin;

4. the name of the Angular module to correctly load it in the loadChildren method;

5. the name of the Angular route to use in the host application;

6. a human-readable name for the text of the link that navigates to the loaded module.

As a result, there is such a configuration for a specific route:

//sample-configuration.tsexport const ROUTES_CONFIGURATION: ReadonlyArray<FederationPlugin> = [
{
type: 'angular',
subType: 'routeModule',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
remoteName: 'angular_mfe_1',
exposedModule: 'MfeModule',
displayName: 'Notes',
routePath: 'notes',
moduleClassName: 'BusinessModule',
navigationAlias: 'notesList'
},
{
type: 'angular',
subType: 'routeModule',
remoteEntry: 'http://localhost:4201/remoteEntry.js',
remoteName: 'angular_mfe_1',
exposedModule: 'MfeModule',
displayName: 'Notes',
routePath: 'notesOlga',
moduleClassName: 'BusinessModule',
navigationAlias: 'notesList'
},
{
type: 'angular',
subType: 'routeModule',
remoteEntry: 'http://localhost:4203/remoteEntry.js',
remoteName: 'angular_mfe_3',
exposedModule: 'NotesCounterModule',
displayName: 'Notes Counter 12 Angular',
routePath: 'notesCounter12Angular',
moduleClassName: 'NotesCounterModule',
navigationAlias: 'notesCounter12Angular'
}
];

It is loaded when the application launches. Then, the routes in the application are updated.

// federation-utils.tsconst appRoutes: Routes = buildRoutes(routes);
this.router.resetConfig(appRoutes);

To make it clear, let’s take a look at the configuration of this plugin.

new ModuleFederationPlugin({
name: "angular_mfe_1",
library: {type: "var", name: "angular_mfe_1"},
filename: "remoteEntry.js",
exposes: {
MfeModule: "./src/app/modules/business-module/business.module.ts",
BusinessComponent: "./src/app/modules/business-module/business/business.component.ts"
},
shared: {....}

})

I guess we should not discuss the details of mapping configuration values onto the properties of the Module Federation plugin.

To use components written in other frameworks, you will need adapters. In our case, there are two of them: Angular in Angular and React in Angular.

The listing of the adapter over the Angular component is provided below. I doubt you will find any extraordinary lines there, because this solution is mostly based on Angular official documentation on dynamic component initialization. But if you don’t want to read the documentation again, just click the spoiler — you can find a ready-made solution over there.

import {
AfterContentInit,
Compiler,
Component,
ComponentFactory,
ComponentFactoryResolver,
ComponentRef,
EventEmitter,
Injector,
Input,
Output,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {loadRemoteModule} from '../../utils/federation-utils';
import {FederationPlugin} from '../../microfrontends/microfrontend.model';

@Component({
selector: 'angular-mf-adapter',
template: '<div class=\'angular-mf-adapter\'><ng-container #container></ng-container></div>'
})
export class AngularWrapperComponent implements AfterContentInit {
@Input() configuration: FederationPlugin;
@Output() outputs: EventEmitter<Record<string, EventEmitter<any>>> = new EventEmitter();
@ViewChild('container', {read: ViewContainerRef}) container: ViewContainerRef;
inputsBeforeComponentCreate: Record<string, unknown> = {};
inputsInternal: Record<string, unknown> = {};
private componentReference: ComponentRef<any>;
private isOutputsRegistered = false;
private componentInputs: Array<string>;
private componentOutputs: Array<string>;

constructor(private componentFactoryResolver: ComponentFactoryResolver,
private compiler: Compiler,
private injector: Injector) {
}

@Input() set inputs(props: Record<string, unknown>) {
this.inputsInternal = props;
this.setProps();
}

async ngAfterContentInit(): Promise<void> {
await this.renderComponent();
}

private async renderComponent(): Promise<void> {
const configuration = this.configuration;
if (configuration) {
const component = await loadRemoteModule({
remoteEntry: configuration.remoteEntry,
remoteName: configuration.remoteName,
exposedModule: configuration.exposedModule
});

switch (configuration.subType) {
case 'componentModule': {
this.compiler.compileModuleAndAllComponentsAsync(component[configuration.moduleClassName])
.then(async (module) => {
const moduleReference = module.ngModuleFactory.create(this.injector);
const innerComponent = await loadRemoteModule({
remoteEntry: configuration.remoteEntry,
remoteName: configuration.remoteName,
exposedModule: configuration.exposedComponent
});
// TODO: Can cache compiled modules to reuse they in second time
const moduleInjectorComponentFactory = moduleReference
.componentFactoryResolver
.resolveComponentFactory(innerComponent[configuration.componentClassName]);
this.saveInputsOutputs(moduleInjectorComponentFactory);
if (moduleInjectorComponentFactory) {
this.createComponent(moduleInjectorComponentFactory);
}
});
break;
}
case 'component': {
const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component[configuration.componentClassName]);
this.saveInputsOutputs(componentFactory);
this.createComponent(componentFactory);
break;
}
}
}
}

private saveInputsOutputs(componentFactory: ComponentFactory<unknown>): void {
this.componentInputs = componentFactory.inputs.map((input) => input.templateName);
this.componentOutputs = componentFactory.outputs.map((output) => output.templateName);
}

private createComponent(componentFactory: ComponentFactory<any>): void {
this.container.clear();
this.componentReference = this.container.createComponent(componentFactory);
this.setProps();
}

private setProps(): void {
if (this.componentReference?.instance) {
const eventEmitters: Record<string, EventEmitter<any>> = {};
for (const propName in this.componentReference.instance) {
if (this.componentOutputs.includes(propName)) {
eventEmitters[propName] = this.componentReference.instance[propName];
}
}
const props = {...this.inputsInternal, ...this.inputsBeforeComponentCreate};
for (const propName in props) {
if (this.componentInputs.includes(propName)) {
this.componentReference.instance[propName] = props[propName];
this.componentReference.changeDetectorRef.markForCheck();
}
}
if (!this.isOutputsRegistered) {
this.outputs.emit(eventEmitters);
this.isOutputsRegistered = true;
}
this.inputsBeforeComponentCreate = {};
} else {
this.inputsBeforeComponentCreate = {
...this.inputsBeforeComponentCreate,
...this.inputsInternal
};
}
}
}

The adapter over the React component looks like this:

import {AfterContentInit, Component, ElementRef, Input, OnDestroy} from '@angular/core';
import React from 'react';
import ReactDOM from 'react-dom';
import {ActivatedRoute, Data} from '@angular/router';
import {take} from 'rxjs/operators';
import {FederationPlugin} from '../../../../microfrontends/microfrontend.model';
import {EventBusService} from '../../../../microfrontends/event-bus.service';
import {GlobalNavigationService} from '../../../../microfrontends/global-navigation.service';
import {loadRemoteModule} from '../../../../utils/federation-utils';



@Component({
selector: 'react-wrapper',
template: '',
styles: [':host {height: 100%; overflow: auto;}']
})
export class ReactWrapperComponent implements AfterContentInit, OnDestroy {
propsInternal: Record<string, unknown>;

@Input() set props(props: Record<string, unknown>) {
this.propsInternal = props;
this.updateComponentProps(props);
}

@Input() configuration: FederationPlugin;

private reactMFEModule;


constructor(private hostRef: ElementRef,
private route: ActivatedRoute,
private eventBusService: EventBusService,
private globalNavigationService: GlobalNavigationService
) {
}

async ngAfterContentInit(): Promise<void> {
if (!this.configuration) {
this.route.data
.pipe(take(1))
.subscribe(async (data: Data) => {
const configuration: FederationPlugin = data.configuration;
await this.renderComponent(configuration, data.props);
});
}
await this.renderComponent(this.configuration, this.propsInternal);
}

private async renderComponent(configuration: FederationPlugin, props: Record<string, unknown>): Promise<void> {
this.configuration = configuration;
const component = await loadRemoteModule({
remoteEntry: configuration.remoteEntry,
remoteName: configuration.remoteName,
exposedModule: configuration.exposedModule
});
this.reactMFEModule = component[configuration.moduleClassName];
const ReactElement = React.createElement(
this.reactMFEModule,
this.constructProps({
...props,
basename: this.configuration.routePath
})
);
ReactDOM.render(ReactElement, this.hostRef.nativeElement);
}

ngOnDestroy(): void {
ReactDOM.unmountComponentAtNode(this.hostRef.nativeElement);
}

private updateComponentProps(props: Record<string, unknown>): void {
if (this.reactMFEModule) {
const ReactElement = React.createElement(this.reactMFEModule, this.constructProps({
...props,
basename: this.configuration.routePath
}));
ReactDOM.hydrate(ReactElement, this.hostRef.nativeElement);
}
}

private constructProps(routeProps): Record<string, unknown> {
if (!routeProps) {
routeProps = {};
}
if (!this.propsInternal) {
this.propsInternal = {};
}

return {...this.props, ...routeProps, eventBus: this.eventBusService, globalNavigation: this.globalNavigationService};
}
}

Additionally, for React to work in an Angular host application, you should add ProvidePlugin to the Webpack configuration:

new webpack.ProvidePlugin({"React": "react",}),

We will write the plugin selection orchestrator so that in the configuration, you can specify, which framework was used to write the component/module. This will allow you to understand exactly which way of plugin initialization should be used. The scheme for building routes is as follows:

And below you can find the code listing that we use for building routes according to the previously described scheme:

export function buildRoutes(options: ReadonlyArray<FederationPlugin>): Routes {
const lazyRoutes: Routes = options?.map((mfe: FederationPlugin) => {
switch (mfe.type) {
case 'angular': {
switch (mfe.subType) {
case 'routeModule': {
return {
path: mfe.routePath,
loadChildren: () => loadRemoteModule(mfe).then((m) => m[mfe.moduleClassName]),
};
}
default: {
return {
path: mfe.routePath,
loadChildren: () => loadRemoteModule(mfe).then((m) => m[mfe.moduleClassName]),
};
}
}
}
case 'react': {
return {
path: mfe.routePath,
children: [
{
path: '**',
loadChildren: () => import('../modules/react-wrapper/react-wrapper.module').then((m) => {
return m.ReactWrapperModule;
}),
data: {configuration: mfe},
}
]
};
}
case 'vue': {
return {
path: mfe.routePath,
children: [
{
path: '**',
component: VueWrapperComponent,
data: {configuration: mfe}
}
]
};
}
default: {
return {
path: mfe.routePath, // TODO: add UnknownPluginType component to catch incorrect configuration
children: [
{
path: '**',
component: SelfRunWrapperComponent,
data: {configuration: mfe}
}
]
};
}
}
});

return [...(lazyRoutes || []), ...APPLICATION_ROUTES];
}

Plugin Prototype

The plugin will differ from the host application in just a couple of lines in webpack.config.js.

The exposes section deserves special attention. Here we specify the key names of the modules and paths to the classes implementing them. The class name and module name in the exposes section may be different.

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const mf = require("@angular-architects/module-federation/webpack");
const path = require("path");
const dependencies = require("./package.json").dependencies;

const sharedMappings = new mf.SharedMappings();
sharedMappings.register(
path.join(__dirname, 'tsconfig.json'),
[/* mapped paths to share */]);

module.exports = {
output: {
uniqueName: "angular_mfe_1",
publicPath: "auto"
},
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
}
]
},
optimization: {
runtimeChunk: false
},
resolve: {
alias: {
...sharedMappings.getAliases(),
}
},
plugins: [
new ModuleFederationPlugin({
name: "angular_mfe_1",
library: {type: "var", name: "angular_mfe_1"},
filename: "remoteEntry.js",
exposes: {
MfeModule: "./src/app/modules/business-module/business.module.ts",
BusinessComponent: "./src/app/modules/business-module/business/business.component.ts"
},
shared: {
'@angular/common/http': {
version: dependencies['@angular/common'],
requiredVersion: dependencies['@angular/common'],
singleton: true,

},
'@angular/common': {
version: dependencies['@angular/common'],
requiredVersion: dependencies['@angular/common'],
singleton: true,

},
'@angular/core': {
version: dependencies['@angular/core'],
requiredVersion: dependencies['@angular/core'],
singleton: true,

},
'@angular/platform-browser': {
version: dependencies['@angular/platform-browser'],
requiredVersion: dependencies['@angular/platform-browser'],
singleton: true,

},
'@angular/platform-browser-dynamic': {
version: dependencies['@angular/platform-browser-dynamic'],
requiredVersion: dependencies['@angular/platform-browser-dynamic'],
singleton: true,

},
'@angular/router': {
version: dependencies['@angular/router'],
requiredVersion: dependencies['@angular/router'],
singleton: true,

},
'@angular/cdk/a11y': {
version: dependencies['@angular/cdk/a11y'],
requiredVersion: dependencies['@angular/cdk/a11y'],
singleton: true,

},
'@angular/animations': {
version: dependencies['@angular/animations'],
requiredVersion: dependencies['@angular/animations'],
singleton: true,

}
}

}),
],
};

Specifics

The `uniqueName` property should have a unique name throughout the application, otherwise, there will be problems when loading plugins. In our case, the `publicPath` property should have the auto value as URL to our plugins is set in the dynamic configuration and we do not know it when building the application.

output: {
uniqueName: "angular_mfe_1",
publicPath: "auto"
}

Support

One year after the Module Federation release, we came up with the question about Webpack 5 support in angular-cli. Currently, the experimental support has been added in Angular 12.

As for the projects created using CRA, react-scripts do not support Webpack 5 now. To create the Module Federation in these projects, we need to do the react-scripts eject to be able to change webpack.config.js.

You can monitor the progress of migrating to Webpack 5 in react-scripts at github.com. When studying the seamless migration to the Module Federation in react-scripts projects, I came across some react-app-rewired or craco solutions for partial changing the Webpack configuration. But they did not work :( We are waiting for the full support in react-scripts!

Improvement Plans

Our prototype turned out to be well-functioning but there are still many ways to develop our idea. The things we are planning to do in the nearest future:

1. Add processing of non-existing plugin type;

2. Add recursion for nested routes;

3. Write a bus for communication between plugins;

4. Correctly handle the case of navigation between fragments without hardcoding;

5. Add adapter for the Vue components;

6. Develop a validator for checking the uniqueness of the plugin names and intersection of dependencies to optimize the bundle.

Feel free to share your feedback, ideas, and information on how you are dealing with it in the comments.

--

--