Module Federation in Node.js
There have been some significant developments in Web Development over the past few years. One of the developments that has been brewing for a number of years, and which has started to hit the mainstream, is the advent of microfrontends. Basically, microfrontends allow disparate components / projects to be incorporated into a single user interface. This provides developers with the ability to independently manage, deploy and consume artifacts.
There are several microfrontend tools / libraries, both open source and commercial, which have been around for a number of years. However, this year some of the functionality that was initially baked into Webpack (Module Federation 1.0) was released as an excellent standalone project (Module Federation 2.0). This extraction enables it to be more widely adopted outside of Webpack and, as we'll discuss, outside of the realm of microfrontends.
Let's discuss how to use it, with some step-by-step code examples.
Webpack Dev Server (Remote)
First, we need an export (class / value) from a remote which we wish to consume from within a host application. Below are a class and a string we'd like to consume from another application:
And here is a minimal webpack.config.js which is used to build and run the dev server:
const webpack = require('webpack');
const path = require("path");
module.exports = {
entry: './src/index.js', // empty entrypoint file
mode: 'development',
target: 'async-node',
output: {
publicPath: 'auto',
},
devServer: {
static: path.join(__dirname, 'dist'),
hot: true,
port: 3002,
devMiddleware: {
writeToDisk: true, // force writing files to disk for dev
},
}
};
We can then get our dev server running using webpack serve --config webpack.config.js
. This doesn't do much of anything at this point, so we'll set up Module Federation next.
First, we need to install the Module Federation packages
npm install @module-federation/enhanced \
@module-federation/node \
@module-federation/runtime
Second, we'll use the UniversalFederationPlugin webpack plugin to expose our class and value we defined above.
const { UniversalFederationPlugin } = require('@module-federation/node');
const webpack = require('webpack');
const path = require("path");
module.exports = {
entry: './src/index.js',
mode: 'development',
target: 'async-node',
output: {
publicPath: 'auto',
},
devServer: {
static: path.join(__dirname, 'dist'),
hot: true,
port: 3002,
devMiddleware: {
writeToDisk: true, // Force writing files to disk
},
},
plugins: [
new UniversalFederationPlugin({
remoteType: 'script',
isServer: true,
name: 'remote',
useRuntimePlugin: true,
library: { type: 'commonjs-module' },
filename: 'remoteEntry.js',
exposes: {
'./string': './src/expose-string.js',
'./class': './src/expose-class.js',
},
}),
],
};
Now, when we do a webpack serve, we'll have two exposed components available for our application to consume. Here's what that output looks like:
Dynamic Loading (Host)
Let's work on wiring up the consuming side, which will also need to be built with webpack as the @module-federation/node package we're going to use currently relies on webpack.
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
mode: 'development',
target: 'async-node',
externals: [],
output: {
library: { type: 'commonjs-module' },
},
};
Next, we'll breakdown how to create the JavaScript that will consume the remote class / value. This consists of several parts:
- Initialize Module Federation with remotes
- Load the remote components from the initialized remotes
- Watch for changes in the remote
- Reload the remote
First, let's show how to initialize Module Federation. Note that the remote entry here uses the same port that we configured above in the webpack dev server and we've assigned the name 'remote' to this remote.
const { loadRemote, init } = require('@module-federation/runtime');
init({
name: 'host',
remotes: [
{
name: 'remote',
entry: 'http://localhost:3002/remoteEntry.js',
},
],
});
We'll then load specific components from the remote, which we exported by name, and then use them.
loadRemote('remote/class').then(async value => {
loadedClass = value;
loadedClassInstance = new value();
loadedClassInstance.getTestValue();
loadedClassInstance.runTest();
});
loadRemote('remote/string').then(value => {
var loadedString = value;
});
Now we need to detect when the remote has changes that require a reload. In our case, we're going to poll on an interval to check for changes, as we don't yet have full support for async notification of changes from the remote.
const {performReload, revalidate} = require('@module-federation/node/utils');
setInterval(async () => {
console.log('host(): checking remote for updates');
const shouldReload = await revalidate();
if (shouldReload) {
// we detected changes and should reload from the remote
console.log('host(): should reload');
initAndLoad();
} else {
// no changes have been detected
console.log('host(): should not reload');
}
}, 5000);
We call initAndLoad() in the above code, which would contain the above initialization, loadRemote() calls, and one more Module Federation call: performReload(). This call is necessary to clear out webpack caches of the remote components we loaded and allow them to be reloaded.
Let's put it all together:
const { loadRemote, init } = require('@module-federation/runtime');
const {performReload, revalidate} = require('@module-federation/node/utils');
let instance;
let loadedString;
let loadedClass;
let loadedClassInstance;
async function initAndLoad() {
// clear module caches and reset remotes, so we can reload the remotes
await performReload(true)
instance = init({
name: 'host',
remotes: [
{
name: 'remote',
entry: 'http://localhost:3002/remoteEntry.js',
},
],
});
loadRemote('remote/string').then(value => {
loadedString = value;
});
loadRemote('remote/class').then(async value => {
loadedClass = value;
loadedClassInstance = new value();
loadedClassInstance.getTestValue());
loadedClassInstance.runTest());
});
}
initAndLoad();
setInterval(async () => {
console.log('host(): checking remote for updates');
const shouldReload = await revalidate();
// do something extra after revalidation
if (shouldReload) {
// reload the server
console.log('host(): should reload');
initAndLoad();
} else {
console.log('host(): should not reload');
}
}, 5000);
Now, when changes are made to the remote, the host application will detect the changes and reload the necessary bits.
Conclusion
Module Federation has some pretty powerful use cases that extend beyond microfrontends and the browser. There's a lot of potential to leverage it more broadly on the backend to help developers realize some of the powerful features that frontend developers have enjoyed for years.
For a complete runnable example, you can go check out the module-federation-example project, which has both a vanilla JavaScript and TypeScript versions. And if you want to delve further into Module Federation use cases, you can view the quick start docs.