Skip to main content

Module federation

Webpack 5 release brought many improvements concentrated around optimization. Hopefully, it was the last major one that brings breaking changes. But the most exciting feature introduced is module federation.

Motivation

Module federation is about managing different physical parts of the application(s) in runtime. We already looked at this problem in code splitting chapter. Back then, it was mostly around mitigating single bundle file size. Both static and dynamic split address the problem slightly differently with their own pros and cons. Module federation goes one step further and is not a substitute to any of the code splitting techniques.

Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually.

Legacy approaches

From this pretty dry explanation we can see that federation is about managing different bundles coming from different applications in runtime. The whole end result might look like a single application in front of the end user, but in reality, we are talking different physical apps with completely isolated/independent lifecycle.

In a more old fashioned way, multiple bundles could be solved via iframes, reverse proxy or just a script tag inside hosting HTML page. All of these solutions require specific mechanism of communication between bundles (DOM lifecycle event, messages, session storage, etc.) With module federation, the mechanism stays the same as if the code was part of the original application. Components from different bundles can continue to communicate just like they have before. It's the power of webpack and a configuration change that makes it all work.

Concepts

Every webpack build ends with a bundle. As we already know, it could be one file or multiple files (or in-memory bundle for dev mode). We can split that one single file bundle into multiple files using chunks (dynamic split). Now, imagine that some of these chunks form a separate module. In the context of the same application this gives us no much benefits besides those we already have. But what about the case were that module belongs to a bundle from a completely different application or different remote in our case? That is exactly what module federation does for us. It is able to load a remote module as if it was a local one and plug it into existing running application lifecycle.

Local modules are normal modules which are part of the current build. Remote modules are modules that are not part of the current build and loaded from a so-called container at the runtime. Loading remote modules is considered asynchronous operation. When using a remote module these asynchronous operations will be placed in the next chunk loading operation(s) that is between the remote module and the entry point. It's not possible to use a remote module without a chunk loading operation.

So every application is a container of modules. It can provide or consume modules or both. When it provides a module, that would be a local module to be consumed by another application as a remote one. When it consumes a module, it's remote one that will be downloaded and evaluated to be consumed as a local one.

The exposed access is separated into two steps:

* loading the module (asynchronous)
* evaluating the module (synchronous).

Step 1 will be done during the chunk loading. Step 2 will be done during the module evaluation interleaved with other (local and remote) modules. This way, evaluation order is unaffected by converting a module from local to remote or the other way around.

Implementation

Webpack exposes a bunch of plugins for module federation implementation. For simplicity, we are going to go with those indicated as high level - ModuleFederationPlugin. Also, we'll create a second application that acts as a remote.

For our initial application, ModuleFederationPlugin will be used both in dev and prod builds, so it makes sense to implement it in parts.ts:

webpack/parts.ts

import webpack, { container} from 'webpack'

const { ModuleFederationPlugin } = container

export const getParts = (): Parts => ({

// ...

plugins: ({ cleanVerbose, remoteAppUrl}) => {
if(!remoteAppUrl) {
throw new Error('Missing remoteAppUrl value')
}

return [
// ...
new ModuleFederationPlugin({
name: "app1",
remotes: {
app2: remoteAppUrl,
},
})
]
}
})

We've extended plugins method with remoteAppUrl parameter indicating remote application URL providing modules to be consumed. For the plugin itself we have:

  • name - The name of the container. It's an optional field and is not needed for the consumer (our case), but it's a good practice anyway.
  • remotes - an object indicating container locations from which modules should be resolved and loaded at runtime. Object key represents a remote module name as it will be used in a source code. Object value is a remote container location.

webpack/environments.js

module.exports = (env) => {

return {
// ...

get remoteAppUrl() {
return process.env.REMOTE_APP_URL
}
}
}

Environments object was extended with the remoteAppUrl getter. REMOTE_APP_URL is passed by an environment variable mostly because chances are it would be different between local and prod configurations. For the local development it is a nice way to configure things in case specific ports are busy.

webpack/webpack.config.dev.ts

import Environments from './environments'

const environment = Environments()

const config: Configuration = {

// ...

plugins: [
...parts.plugins({
cleanVerbose: true,
remoteAppUrl: environment.remoteAppUrl || 'app2@http://localhost:8081/remoteEntry.js'
}),
// ...
],

First, we imported Environments to read remoteAppUrl parameter. Next, we are passing it to the plugins method with the convenient default value in case none was provided. Important thing to notice is that the URL starts with app2@. This value should match the remote container name we've setup in remotes section of the ModuleFederationPlugin.

webpack/webpack.config.ts

import Environments from './environments'

const environment = Environments()

const config: Configuration = {

// ...

plugins: [
...parts.plugins({
remoteAppUrl: environment.remoteAppUrl
}),
// ...
],

Very similar implementation. Just this time we pass the value as is.

src/app/index.tsx

Recall the remote modules work only via chunks. As a result, the only way to load a remote module is to use the same technique we were using for dynamic chunk split (import):

import('app2/Text')
.then(({ Text }) => {
list.addItem(Text('Hello webpack', true))
})

In this example remote module app2 provides a function Text that takes two parameters - string and boolean. At this point it doesn't really matter what these parameters do. What matters is the caller should know it's a function (could be anything a module can expose) it needs to invoke and pass all the needed params. Also, just like with local modules, import takes a path to a module. In case of federation, it's a container name that was specified in the config (exposes value) and the module name the container exposes. The caller also needs to know the return type of the Text function. In our case it's a string.

src/global.d.ts

declare module "app2/Text" {
declare function Text (data: string, isRemote?: boolean): string
}

Because TypeScript knows nothing about remote module provided functionality, we have to instruct it what app2/Text means.

Second application

For the full module federation example we need at least one more application. We'll set it up with the help of webpack just like we did with the first one, but this time it is going to be simpler, slimmer setup.

Dependencies

Here is what we are going to install:

mkdir app2 && cd "$_"
npm init -y
npm i @babel/core @babel/preset-typescript babel-loader clean-webpack-plugin \
mini-html-webpack-plugin typescript webpack webpack-nano webpack-plugin-serve

There are only few differences with the first setup: mini-html-webpack-plugin, webpack-nano and webpack-plugin-serve

  • mini-html-webpack-plugin - a miniature version of html-webpack-plugin with only necessary features
  • webpack-nano - operates as a simpler, smaller webpack-cli alternative.
  • webpack-plugin-serve - another alternative to webpack-dev-server that works well with webpack-nano

At the time of this writing, webpack-plugin-serve has webpack 4 as peer dependency. However, npm 7 changed peer dependency algorithm: prior to npm 7 developers needed to manage and install their own peer dependencies. The new peer dependency algorithm ensures that a validly matching peer dependency is found at or above the peer-dependent’s location in the node_modules tree. As a result, when using npm 7, you might encounter following error: npm ERR! Could not resolve dependency: npm ERR! peer webpack@"^4.20.2" from webpack-plugin-serve@1.2.1. For our needs, webpack-plugin-serve is perfectly fine in combination with webpack 5. So, as a workaround, install npm 6, install all the packages mentioned above, and move back to npm 7.

package.json

"scripts": {
"build:dev": "wp --mode development",
"start": "wp --mode development --watch",
"build:prod": "wp --mode production",
}

All the scripts operate via wp - webpack-nano executable.

webpack.config.js

const path = require("path");
const { MiniHtmlWebpackPlugin } = require("mini-html-webpack-plugin");
const { WebpackPluginServe } = require("webpack-plugin-serve");
const argv = require('webpack-nano/argv');
const { ModuleFederationPlugin } = require("webpack").container;
const { CleanWebpackPlugin } = require('clean-webpack-plugin')

const { mode, watch } = argv;

module.exports = {
watch,
mode,
entry: ['./src/index', 'webpack-plugin-serve/client'],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [{
test: /\.tsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['@babel/preset-typescript'],
}
}]
},
plugins: [
new ModuleFederationPlugin({
name: "app2",
filename: "remoteEntry.js",
exposes: {
"./Text": "./src/text-component",
}
}),
new MiniHtmlWebpackPlugin(),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [
path.resolve(__dirname, 'dist')
],
verbose: true
}),
new WebpackPluginServe({
port: process.env.PORT || 8081,
static: "./dist",
liveReload: true,
waitForBuild: true,
hmr: true
})
]
};

We are reading mode and watch parameters via argv object provided by webpack-nano. The entry option consist of two elements, one for the app itself, another for the webpack-plugin-serve development mode. WebpackPluginServe options are self explanatory. The most import part here is ModuleFederationPlugin

  • name - The name of the container. In this case name is important. It will be used to address the container by external containers.
  • filename - The filename of the container as relative path inside the output.path directory
  • exposes - Modules that should be exposed by this container. When provided, property name is used as public name, otherwise public name is automatically inferred from request

tsconfig.json

{
"compilerOptions": {
"sourceMap": true,
"strict": true,
"skipLibCheck": true

},
"include": ["src/**/*"]
}

A new option skipLibCheck - instructs compiler to skip checking files inside node_modules folder.

scr/index.ts

import { Text } from './text-component'

function component() {
const element = document.createElement('div');

element.innerHTML = Text('Hello webpack');

return element;
}

document.body.appendChild(component());

index.ts exist because we need to make sure second application works by itself. That means we cun run it and inspect independently.

src/text-component.ts

export const Text = (data: string, isRemote: boolean = false) =>
`${data}${isRemote ? ' from Remote' : ''}`

text-components is exactly that component we were using in the main application above.

At this point we can build and run the app in a dev mode. But we also need to serve the static prod build as well when we load the remote module inside the first app. As we already have a static HTTP server implementation, let's just reuse it.

package.json

"scripts": {

"serve": "npm run serve --prefix ../analysis -- http://localhost:8081 ../app2/dist",
"serve:https": "npm run serve:https --prefix ../analysis -- https://localhost:8081 ../app2/dist"

}

We added two parameters to the serve script: hosting URL and the path to physical files on disc. Before these parameters were hardcoded. We had to change params because static server is used by two separate applications.

Test run

It's time to check how module federation works. Open two terminals and navigate to app and app2 respectively.

app2 terminal:

npm run build:prod
npm run serve

app terminal:

npm run build:prod
npm run serve

Navigate to http://localhost:8080

Loading remote module

As you can see in the Network tab, first remoteEntry.js module file was loaded from the remote application (app2). And right after, 302.js file chunk that accompanies it in the same build was loaded remotely as well.

Shared modules

Module federation gives possibility for multiple modules from different applications to be shared. This might bring a problem of overinflating the amount of files the browser downloads.

For example, application A loads remote module b from application B. Module b uses some 3rd party dependency - module c. Both b and c get downloaded by A. If A also has c as its own 3rd party dependency for local modules, browser will download c twice (once for local modules, once for remote c). If module c is used in different versions (one for A, one for B), that is fine. By different versions we mean SemVer rules. But overinflating happens when versions are the same (SemVer compatible).

Webpack solves this problem with shared modules.

A container is able to flag selected local modules as "overridable". A consumer of the container is able to provide "overrides", which are modules that replace one of the overridable modules of the container. All modules of the container will use the replacement module instead of the local module when the consumer provides one. When the consumer doesn't provide a replacement module, all modules of the container will use the local one.

And regarding versioning specifically:

Apps A, B, C all have lodash shared. However, B has a newer version installed, and according to the SemVer specifications of A & C, they are compatible with a more up to date minor version of lodash. At this point, remote B will vend its version of lodash to the host and all other remotes who meet the requirements and can consume a slightly newer version of lodash.

Let's see how we can simulate this problem and solved it with webpack.

src/utils/index.ts

Say that our initial application app uses 3rd party dependency - date-fns for the formatted Date output. Assuming application app2 also uses date-fns for its own purposes (same SemVer), we can configure both apps to share date-fns module.

// ...
export const formatOClockDate = (date: Date): Promise<string> =>
import('date-fns')
.then(({ format }) => format(date, "h 'o''clock'"))

We added a new formatOClockDate lambda toapp/utils that returns formatted o'clock time.

Notice that we import date-fns lazily via import instead of the direct import at the top of the file. Recall: It's not possible to use a remote module without a chunk loading operation. So we have to load date-fns lazily.

src/app/index.ts

import { toCapital, createFrame, formatOClockDate } from '@utils'

//...

formatOClockDate(new Date())
.then(list.addItem)
.catch(console.log)

Using the method above, we are adding its output to the screen using list addItem method.

webpack/parts.ts

// ...
new ModuleFederationPlugin({
name: "app1",
remotes: {
app2: remoteAppUrl,
},
shared: ["date-fns"]
})

For the config, we just need to define shared module(s).

app2/webpack.config.js

// ...
new ModuleFederationPlugin({
name: "app2",
filename: "remoteEntry.js",
exposes: {
"./Text": "./src/text-component",
},
shared: ["date-fns"]

})

app2 config looks very similar. In fact, we are defining exactly the same line for shared modules.

app2/src/text-component.ts

import { format } from 'date-fns'

export const Text = (data: string) => `${data}: Today is ${format(new Date(), 'EEEE')}`;

We changed the body of the Text component to use date-fns formatting to display day of the week.

Notice how date-fns is imported directly and not lazily. We moved lazy loading logic to the file that uses text-component directly.

app2/src/index.ts

import('./bootstrap')

We changed the body of the index file by moving it into the file called bootstrap and import it lazily. This is only needed if we want to check how Text component looks as part of the app2. The app needs to load it, but because it is marked as shared it has to be loaded lazily.

app2/src/bootstrap.ts

import { Text } from './text-component'

function component() {
const element = document.createElement('div');

element.innerHTML = Text('Hello webpack');

return element;
}

document.body.appendChild(component());

Test run shared modules

This time, when we build both applications in production mode, the output includes shared module entries:

app

...
provide shared module (default) date-fns@2.17.0 = ./node_modules/date-fns/esm/index.js 42 bytes [built] [code generated]
remote app2/Text 6 bytes (remote) 6 bytes (share-init) [built] [code generated]
external "app2@http://localhost:8081/remoteEntry.js" 42 bytes [built] [code generated]
consume shared module (default) date-fns@^2.17.0 (strict) (fallback: ./node_modules/date-fns/esm/index.js) 42 bytes [built] [code generated]

app2

...
./node_modules/date-fns/esm/index.js + 232 modules 513 KiB [built] [code generated]
./src/text-component.ts 113 bytes [built] [code generated]
provide shared module (default) date-fns@2.17.0 = ./node_modules/date-fns/esm/index.js 42 bytes [built] [code generated]
consume shared module (default) date-fns@^2.17.0 (strict) (fallback: ./node_modules/date-fns/esm/index.js) 42 bytes [built] [code generated]

But at the same time, when the application loads, date-fns is downloaded only once from local modules

Loading shared module

Use cases

What we've seen so far is just a fraction of what cold be designed using module federation. Here are some ideas:

  • Vendor sharing - share exact vendor modules across different non connected applications
  • Component library - similar to vendor sharing but is specific to proprietary modules to be used across multiple applications.
  • Micro-frontends via host - different applications are loaded dynamically via a host application upon some user actions.
  • Independent micro-frontends - different applications form a chain were an entry loads only entries it knows about (decentralized host).
  • Dynamic behavior overriding - different remote modules are loaded upton specific logic within the application to provide different behavior. For example, for different users/tenants application might look/behave different (plug-able architecture).

Final version

Reference implementation