DEV Community

Cover image for Tutorial - A Guide to Module Federation for Enterprise
Matthew Waldron
Matthew Waldron

Posted on • Updated on

Tutorial - A Guide to Module Federation for Enterprise

Update: 4/17/2022

See Part 2 of my Module Federation for Enterprise series for a much easier way to implement a multi-environment setup than the approach described below.

Update: 9/11/2021

Hard coded URLs and environment variables can be avoided completely. See Zack Jackson's comment below illuminating the usage of promise new Promise to infer the remote at runtime.

An Approach to Module Federation for Enterprise

Who is this Guide For?

If you are part of an organization that has the following requirements, this guide may be of interest:

  • Multiple development environments (local, dev, staging, prod, etc.)
  • Multiple applications shared across multiple domains (URLs)

Introduction

Advantages

Module Federation is an exciting new addition to Webpack 5. As described by its creator Zack Jackson:

Module Federation allows a JavaScript application to dynamically load code from another application and  in the process, share dependencies.

This powerful orchestration micro-frontend architecture will make it easier for organizations to decouple their applications and share across teams.

Limitations

Despite Module Federation's advantages, we can see limitations when applying this to organizations with more complex environment requirements.

Let's look at the following example:

webpack.dev.js

new ModuleFederationPlugin({
  remotes: {
    FormApp: "FormApp@http://localhost:9000/remoteEntry.js",
    Header: "Header@http://localhost:9001/remoteEntry.js",
    Footer: "Footer@http://localhost:9002/remoteEntry.js",
  },
  ...
}),
Enter fullscreen mode Exit fullscreen mode

webpack.prod.js

new ModuleFederationPlugin({
  remotes: {
    FormApp: "FormApp@http://www.formapp.com/remoteEntry.js",
    Header: "Header@http://www.header.com/remoteEntry.js",
    Footer: "Footer@http://www.footer.com/remoteEntry.js",
  },
  ...
}),
Enter fullscreen mode Exit fullscreen mode

The first thing you may notice is that the URLs are hard-coded in the Webpack configuration. While this setup works, it won't scale well if there are multiple apps distributed across multiple environments.

Another consideration is code deployment. If a remote app URL changes, teams must remember to change both the remote app and host app configurations. Changes required on multiple files in different projects increase the likelihood of mistakes occurring and code breaking in production.

Conclusion

We need a way to dynamically assign the appropriate environment context for both local and remote entrypoints. However, abstracting out logic for assigning environment context will prevent Module Federation from knowing where and how to load remote containers during the Webpack build process; as absolute URL paths will no longer exist in Webpack configurations. We'll need to be able to load remote apps dynamically when environment context has been established.

High Level Overview

This repository employs the modification of several documented techniques to support a fully dynamic, multi-environment setup.

MutateRuntimePlugin.js

This plugin by Module Federation Author Zack Jackson allows for tapping into the Webpack MutateRuntime compilation hook to mutate publicPath dynamically.

This code snippet by devonChurch is an implementation of MutateRuntimePlugin.js where publicPath is intercepted and mutated via variable assignment initialized during runtime.

Multi-Environment Architecture

This discussion thread and code example by devonChurch outlines a method for injecting local and remote entrypoints at runtime through publicPath mutation via the method described above.

This method also employs the use of .json configuration files which hold a global mapping of all local and remote entrypoint URLs and the current environment.

Dynamic Remote Containers

This code snippet via Webpack documentation describes exposed methods for initializing remote containers dynamically at runtime.

Webpack Configurations

When implementing the documented techniques above, I encountered several gotchyas when setting up more advanced Webpack configurations. I documented these issues and fixes so you can avoid these pitfalls.

Project Setup

Before diving in to the project code, let's discuss briefly, the project's structure and underlying configurations.

| dynamic-container-path-webpack-plugin (dcp)
| -----------
Enter fullscreen mode Exit fullscreen mode
| Shared Configs
| -----------
| map.config.json
| bootstrap-entries.js
Enter fullscreen mode Exit fullscreen mode
| Host / Remote
| -----------
| chunks.config.json
| * environment.config.json
| webpack.common.js
| webpack.dev.js
| webpack.prod.js
| index.html
Enter fullscreen mode Exit fullscreen mode
| Host
| -----------
| bootstrap.js
| load-component.js
Enter fullscreen mode Exit fullscreen mode
| Remote
| -----------
| bootstrap.js
Enter fullscreen mode Exit fullscreen mode

dynamic-container-path-webpack-plugin

My modified version of MutateRuntimePlugin.js that mutates publicPath at runtime. This can be installed from npm and can be used as a plugin and customized in your Webpack configuration.

Shared Configs

map.config.json contains a global object of local and remote endpoint URLs.

bootstrap-entries.js bootstraps Webpack chunks with the correct URLs based on the current environment.

Host / Remote

chunks.config.json is an array of Webpack entrypoints required for application initialization and remote application namespaces for consumption.

environment.config.json is a key/value pair indicating the current environment. This can be set by your build pipeline. However, for simplicity, we will set the environment in bootstrap-entries.js in this tutorial.

Webpack configuration files employ webpack-merge so we can reduce Webpack boilerplate code (loaders, common Webpack environment configurations, etc.). This is an architecture choice recommended for streamlining configurations across applications.

index.html will include a script reference to bootstrap-entries.js so that it can bootstrap Webpack chunks at runtime so it can load our federated modules.

Host

bootstrap.js serves as an asynchronous barrier for our local and remote code. This is a required file in order for Module Federation to work correctly. You can read more about this here. We'll also set up logic here to lazy-load our remote app.

load-component.js is code directly lifted from Webpack documentation as referenced in this guide under Dynamic Remote Containers. This file will dynamically load and negotiate shared libraries of our remote app with the host.

Remote

Similarly to Host, bootstrap.js serves as an asynchronous barrier for our local and remote code.

Mutating publicPath via Global Variable Assignment

Discussions on publicPath Assignment Options

Our first step is to identify a method for dynamically mutating publicPath. Before reviewing the solution, let's briefly discuss our options by navigating to the Webpack docs.

We could use DefinePlugin to set environment variables to modify publicPath, however, we won't be able to easily scale over several remotes with several environments.

A promising option involves leveraging Webpack's publicPath: auto to automatically determine the value from context (for example: document.currentScript). We can even this in action in Zack Jackson's dynamic remotes example repo.

While this option does meet our desired requirements of removing the hardcoded URL's from the webpack configuration, unfortunately, now we need to define the remote paths inside the host via App.js, thus defeating the intended purpose of keeping hardcoded URL's out of our code. Another drawback prevents us from using style-loader because it relies on a static publicPath to embed styles inline in the html. See this GitHub issue thread.

This leaves us our last option which involves modifying publicPath on the fly. In the next section we'll discuss how to tap into one of Webpack's complication hooks and write a custom Webpack plugin that supports custom mutation of publicPath during runtime.

Outsourcing logic to runtime reduces hard-coded Webpack build configurations, reduces maintenance, and increases configuration re-usability.

High Level Overview

We can mutate publicPath by referencing and modifying a custom Webpack plugin by Module Federation Author Zack Jackson that uses the MutateRuntime compilation hook to mutate publicPath dynamically.

First let's take a look at the completed plugin's API:

const  DynamicContainerPathPlugin =
    require('dynamic-container-path-webpack-plugin');
const  setPublicPath =
    require('dynamic-container-path-webpack-plugin/set-path');

 new DynamicContainerPathPlugin({
   iife: setPublicPath,
   entry: 'host',
 }),
Enter fullscreen mode Exit fullscreen mode

DynamicContainerPathPlugin accepts two arguments. iife is an immediately invoked function expression that will take in entry as it's argument.

When iife is executed inside the plugin, it will use entry as a key to find the correct environment. When iife is returned, DynamicContainerPathPlugin will assign the resulting value to Webpack's internal publicPath variable.

Tapping into PublicPathRuntimeModule

Let's look under the hood to see how the dynamic-container-path-plugin works.

Note: This guide assumes basic anatomy of how a Webpack plugin works. To read more, reference the Webpack docs found here.

First we call apply(compiler) to access Webpack's compilation lifecycle:

apply(compiler) {

};
Enter fullscreen mode Exit fullscreen mode

Next, we'll need a way to intercept Webpack before finishing the compilation. We can do this using the make hook:

compiler.hooks.make.tap('MutateRuntime', compilation => {});
Enter fullscreen mode Exit fullscreen mode

Within the make hook, we have access to Webpack's compilation hooks that allow us to create a new build. We can use the runtimeModule hook that will allow us to tap directly into publicPath assignment and call a custom method changePublicPath to allow for dynamic publicPath re-assignment:

compilation.hooks.runtimeModule.tap('MutateRuntime', (module, chunk) => {
  module.constructor.name === 'PublicPathRuntimeModule'
      ? this.changePublicPath(module, chunk)
      : false;
  });
});
Enter fullscreen mode Exit fullscreen mode

changePublicPath Method

changePublicPath calls two methods. The first method getInternalPublicPathVariable strips out publicPath's value using Webpack's internal global variable __webpack_require__.p set at build time and returns the internal variable only.

getInternalPublicPathVariable(module) {
  const [publicPath] = module.getGeneratedCode().split('=');
  return [publicPath];
}
Enter fullscreen mode Exit fullscreen mode

The second method setNewPublicPathValueFromRuntime accepts the internal publicPath variable __webpack_require__.p derived from getInternalPublicPathVariable as an argument. The variable is re-assigned a value using custom logic provided to the Webpack plugin.

The new publicPath value is then assigned to module._cachedGeneratedCode which is equal to __webpack_require__.p, our internal Webpack publicPath variable, at build time.

setNewPublicPathValueFromRuntime(module, publicPath) {
  module._cachedGeneratedCode =
    `${publicPath}=${this.options.iife}('${this.options.entry}');`;
  return  module;
}
Enter fullscreen mode Exit fullscreen mode

iife and entry

In the previous section we covered how the method setNewPublicPathValueFromRuntime assigns the new publicPath value. In this section we'll cover the logic contained in iffe:

`${publicPath}=${this.options.iife}('${this.options.entry}');`;
Enter fullscreen mode Exit fullscreen mode

Let's zoom out again to our original API setup using DynamicContainerPathPlugin.

const DynamicContainerPathPlugin =
    require('dynamic-container-path-webpack-plugin');
const setPublicPath =
    require('dynamic-container-path-webpack-plugin/set-path');

 new DynamicContainerPathPlugin({
   iife: setPublicPath,
   entry: 'host',
 }),
Enter fullscreen mode Exit fullscreen mode

DynamicContainerPathPlugin comes with logic for assigning publicPath via setPublicPath, but you can modify to fit your own needs.

dynamic-container-path-webpack-plugin/set-path contains the following code:

module.exports = function (entry) {
  const { __MAP__, __ENVIRONMENT__ } = window;
  const { href } = __MAP__[entry][__ENVIRONMENT__];
  const publicPath = href + '/';
  return publicPath;
};
Enter fullscreen mode Exit fullscreen mode

__MAP__ and __ENVIRONMENT__, which will be covered later, are global variables we will set up at runtime. These global variables values will be assigned the fetched data from our json mapping of URLs (covered below).

entry is used as a key to look up the current entrypoint in __MAP__. href is the resulting value extracted from __MAP__ and assigned to publicPath, which in turn, is assigned to Webpack's internal publicPath variable as we covered in the last section.

Creating a Global Mapping of Endpoints

One disadvantage, as outlined earlier, is Module Federation's dependence on hard-coded URLs that scale poorly with more complex organizational requirements. We will instead define a json object containing a global reference of host and remote entrypoint URLs that will be referenced by the repositories.

{
  "Host": {
    "localhost": {
      "href": "http://localhost:8000"
    },
    "production": {
      "href": "https://dynamic-host-module-federation.netlify.app"
    }
  },
  "RemoteFormApp": {
    "localhost": {
      "href": "http://localhost:8001"
    },
    "production": {
      "href": "https://dynamic-remote-module-federation.netlify.app"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Hostand RemoteFormApp refers to the Webpack entrypoint names we will define later in our repositories.

Each of these entrypoints contain environment URLs; the key referring to the environment name and property href containing the hard-coded URL.

Writing a Script to Bootstrap Chunks

The key to supporting a multi-environment setup is by dynamically assigning the appropriate endpoint URLs based on the current environment at runtime.

We'll create a file called bootstrap-entries.js that will be tasked with the following:

  • Fetch configuration files and assign them to global variables to be used by dynamic-container-path-webpack-plugin to mutate publicPath
  • The configuration files and newly defined publicPath will inject local and remote chunks on the page.

Initial Setup

First we'll define an iife so that it will execute immediately in index.html:

(async () => {
  // our script goes here
})();
Enter fullscreen mode Exit fullscreen mode

Next we'll set up logic to determine the current environment:

Note: Refer to the code snippets in section A Quick Note on environment.config.js for a build pipeline configuration.

const environment = () =>
  location.host.indexOf('localhost') > -1 ? 'localhost' : 'production';
Enter fullscreen mode Exit fullscreen mode

Since we'll be referencing configuration files relative to individual repositories, we have a small function to get the appropriate base path:

const getBasePath = environment() == 'localhost' ? './' : '/';
Enter fullscreen mode Exit fullscreen mode

Next, we'll fetch a file called assets-mainfest.json.

For production builds, assets are commonly cache-busted through the use of Webpack's contentHash feature. This file will get generated by webpack-assets-manifest and will allow us to fetch our chunks without needing to know the dynamically generated contentHash value assigned with each production build:

const getManifest = await fetch('./assets-manifest.json').then(response =>
  response.json()
);
Enter fullscreen mode Exit fullscreen mode

Next, we will define a const array of configuration files:

const configs = [
  `https://cdn.jsdelivr.net/gh/waldronmatt/
        dynamic-module-federation-assets/dist/map.config.json`,
  `${getBasePath}chunks.config.json`,
];
Enter fullscreen mode Exit fullscreen mode

The first configuration references the global mapping of endpoints we defined earlier.

Note: I'm using jsdeliver to serve map.config.json and bootstrap-entries.js so the repositories can reference from one place. Look into more robust cloud alternatives for mission critical applications.

The second configuration is an array of entrypoints required for application initialization and remote application namespaces for consumption. This is unique per repository and will be covered later on.

Fetch Configurations and Assign to Global Variables

Now that our utility functions and configuration file references are defined, the next step is to fetch our configurations and assign them to globally defined variables.

First we'll fetch the configuration files in parallel. We want to ensure all of the configurations are fetched before variable assignment:

const [map, chunks] = await Promise.all(
  configs.map(config => fetch(config).then(response => response.json()))
);
Enter fullscreen mode Exit fullscreen mode

Next we'll assign environment and map to global variables. This step is critical, as it is used by dynamic-container-path-webpack-plugin to re-assign the value of publicPath.

window.__ENVIRONMENT__ = environment();
window.__MAP__ = map;
Enter fullscreen mode Exit fullscreen mode

Fetch JavaScript from entrypoints and Inject on the Page

Lastly, we'll loop through each chunk defined in chunks.config.js and return the code:

Note: As we'll see later in the section, chunks.config.js contains two arrays containing name references to local and remote Webpack chunks.

First we're getting all local chunks and returning the code. Because webpack-assets-manifest doesn't generate an entry for remoteEntry.js (a file used by Module Federation to bootstrap remotes), we will fetch it by name only.

Note: remoteEntry.js is considered a local chunk in the remote repository.

...chunks.entrypoints.map(chunk => {
    return chunk !== 'remoteEntry'
        ? fetch(`./${getManifest[`${chunk}.js`]}`)
            .then(response => response.text())
        : fetch(`${chunk}.js`).then(response => response.text());
}),
Enter fullscreen mode Exit fullscreen mode

Next, we're getting all remote chunks and returning the code. First we grab the appropriate endpoint for each chunk based on the current environment.

Then we use the derived endpoint value and assign it to remoteEntry.js so we can properly fetch the remotes.

...chunks.remotes.map(chunk => {
    const { href } = map[chunk][environment()];
    return fetch(`${href}/remoteEntry.js`).then(response => response.text());
}),
Enter fullscreen mode Exit fullscreen mode

Lastly, for each chunk we create a script tag, assign the returned code to it, and append it to the page for execution.

.then(scripts =>
    scripts.forEach(script => {
        const element = document.createElement('script');
        element.text = script;
        document.querySelector('body').appendChild(element);
    })
);
Enter fullscreen mode Exit fullscreen mode

Altogether, our code should look like the following:

(async () => {
  const environment = () =>
    location.host.indexOf('localhost') > -1 ? 'localhost' : 'production';

  const getBasePath = environment() == 'localhost' ? './' : '/';

  const getManifest = await fetch('./assets-manifest.json').then(response =>
    response.json()
  );

  const configs = [
    `https://cdn.jsdelivr.net/gh/waldronmatt/
        dynamic-module-federation-assets/dist/map.config.json`,
    `${getBasePath}chunks.config.json`,
  ];

  const [map, chunks] = await Promise.all(
    configs.map(config => fetch(config).then(response => response.json()))
  );

  window.__ENVIRONMENT__ = environment();
  window.__MAP__ = map;

  await Promise.all([
    ...chunks.entrypoints.map(chunk => {
      console.log(`Getting '${chunk}' entry point`);
      return chunk !== 'remoteEntry'
        ? fetch(`./${getManifest[`${chunk}.js`]}`).then(response =>
            response.text()
          )
        : fetch(`${chunk}.js`).then(response => response.text());
    }),
    ...chunks.remotes.map(chunk => {
      const { href } = map[chunk][environment()];
      return fetch(`${href}/remoteEntry.js`).then(response => response.text());
    }),
  ]).then(scripts =>
    scripts.forEach(script => {
      const element = document.createElement('script');
      element.text = script;
      document.querySelector('body').appendChild(element);
    })
  );
})();
Enter fullscreen mode Exit fullscreen mode

Later, we'll cover how to implement the code in our repositories.

A Note on environment.config.js

For simplicity, we will define logic for determining the environment in bootstrap-entries.js in this tutorial. However, you may prefer to define it based on your build pipeline instead. If this is the case for you, below you'll find code snippets you can use in place of the environment logic we'll be covering in subsequent sections:

environment.config.js - (Will be created per repository)

{
  "environment": "localhost"
}
Enter fullscreen mode Exit fullscreen mode

bootstrap-entries.js

const configs = [
  `${getBasePath}environment.config.json`,
    ...
]

...

const [{ environment }, ... ] = await Promise.all(
  configs.map(config => fetch(config).then(response => response.json()))
);

...

window.__ENVIRONMENT__ = environment;
Enter fullscreen mode Exit fullscreen mode

Project Setup

It's finally time to put everything we learned into action. As we cover specific files and configurations, you can reference the repository found here. Only important files and configurations will be covered.

config/ directory

We'll set up a file called chunks.config.json inside a folder called config located in the project root. This file contains a list of local and remote entrypoints.

{
  "entrypoints": ["Host"],
  "remotes": ["RemoteFormApp"]
}
Enter fullscreen mode Exit fullscreen mode

Note: This directory is where you can optionally define an environment configuration file set using your build pipeline. See the section A Quick Note on environment.config.js for more information.

environment.config.js - (Will be created per repository)

{
  "environment": "localhost"
}
Enter fullscreen mode Exit fullscreen mode

bootstrap.js

If you are using static imports anywhere in your project, you will need to set up an asynchronous boundary in order for Module Federation to work correctly. You can do this by setting up a file called bootstrap.js and dynamically importing the main .js file of your application.

import('./app.js');
Enter fullscreen mode Exit fullscreen mode

Note: For further reading on this topic, reference the following links:

Dynamically Lazy-load remote containers

Create a file called load-component.js under /src/. We'll be copy/pasting the code found on the Webpack documentation for Dynamic Remote Containers. This code allows for loading in remote containers dynamically.

const loadComponent = (scope, module) => {
  return async () => {
    await __webpack_init_sharing__('default');
    const container = window[scope];
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
};

export default () => loadComponent;
Enter fullscreen mode Exit fullscreen mode

Next, we'll be copy/pasting more code found on the Webpack documentation for Lazy Loading. We'll modify and implement this code in our bootstrap.js file below our dynamic import of app.js.

const lazyLoadDynamicRemoteApp = () => {
  const getHeader = document.getElementById('click-me');
  getHeader.onclick = () => {
    import(/* webpackChunkName: "RemoteFormApp" */ './load-component')
      .then(module => {
        const loadComponent = module.default();
        const formApp = loadComponent('FormApp', './initContactForm');
        formApp();
      })
      .catch(() => `An error occurred while loading ${module}.`);
  };
};

lazyLoadDynamicRemoteApp();
Enter fullscreen mode Exit fullscreen mode

The reason why this works without a hard-coded URL is because we are dynamically assigning publicPath at runtime, getting the appropriate entrypoints, and injecting the code onto the page.

Since this includes remoteEntry.js, which in turn, loads in our remotes, we automatically have access to the remote scope FormApp and now we're able to load it successfully using only the relative path ./initContactForm located in the remote repository.

Note: If you don't want to lazy load your apps and dynamically import them normally, replace the above code with the following in bootstrap.js:

import('./load-component').then(module => {
  const loadComponent = module.default();
  const formApp = loadComponent('FormApp', './initContactForm');
  formApp();
});
Enter fullscreen mode Exit fullscreen mode

Reference the bootstrap-entries.js file

Earlier, we set up custom code to bootstrap Webpack chunks at runtime. Now it's time to reference this in our index.html as we covered in the section Reference for Use in Repositories (reference this for more information). We'll repeat this process for all repositories.

https://cdn.jsdelivr.net/gh/waldronmatt/dynamic-module-federation-assets@1.1.1/dist/bootstrap-entries.js

<script
  preload
  src="https://unpkg.com/regenerator-runtime@0.13.1/runtime.js"
></script>
<script preload <!-- reference the bootstrap-entries.js link above -->
  src=`...`>
</script>
Enter fullscreen mode Exit fullscreen mode

The bootstrap-entries.js file we're serving is a transpiled and minified version of the script to support older browsers and improve performance.

Note: regenerator-runtime is required to provide support for async/await.

Note: We can preload these scripts to improve page performance.

Note: The global mapping of hard-coded URLs we set up earlier is also located in the dynamic-module-federation-assets repository (where bootstrap-entries.js is located). The reasoning is that this file is common among all of our repositories. If we need to add, remove, or change a URL, we do it once in one location.

Webpack Configurations

Webpack Merge

The host and remote repositories use Webpack Merge to reuse common configurations and reduce the number of dependencies needed to be installed. For this tutorial I'm using my own shareable configuration found here.

Development Configuration

At minimum we'll want a development server and hot-reloading set up along with configuration defaults from our Webpack merge configuration.

We're adding a configuration to the development server header to ignore CORS. You can add optional linters and any other configurations needed. The final code for webpack.dev.js for host and remote repositories can be found below:

const commonConfig = require('./webpack.common.js');
const extendWebpackBaseConfig = require('@waldronmatt/webpack-config');
const path = require('path');
const webpack = require('webpack');

const developmentConfig = {
  devServer: {
    contentBase: path.resolve(__dirname, './dist'),
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers':
        'X-Requested-With, content-type, Authorization',
    },
    index: 'index.html',
    port: 8000,
  },
  plugins: [new webpack.HotModuleReplacementPlugin()],
};

module.exports = extendWebpackBaseConfig(commonConfig, developmentConfig);
Enter fullscreen mode Exit fullscreen mode

Production Configuration

We can leverage Webpack's splitchunks functionality for splitting up code alongside dynamically loaded remotes and local code.

Since our remote FormApp will require extra dependencies, we can tell Webpack to split up code belonging to libraries in a separate file.

cacheGroups: {
  vendor: {
    name:  `Vendors-${mainEntry}`,
    chunks:  'async',
    test: /node_modules/,
  },
},
Enter fullscreen mode Exit fullscreen mode

Note: The name of the chunk is important. It must be unique to avoid namespace collisions with remotes. Using the name of the main entrypoint alongside a naming system describing the nature of the code split (vendors in our case) might be a good way to keep names unique.

Note: If you recall earlier, in order for Module Federation to work, we were required to set up an asynchronous boundary so that static imports would be supported. Now all of our code is async which means we'll also need to set chunks to be async for our configuration.

We can repeat this process for splitting up code shared between entry points. The final code for the host and remote repositories can be found below:

const commonConfig = require('./webpack.common.js');
const extendWebpackBaseConfig = require('@waldronmatt/webpack-config');
const chunks = require('./config/chunks.config.json');
const mainEntry = chunks.entrypoints[0];

const productionConfig = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          name: `Vendors-${mainEntry}`,
          chunks: 'async',
          test: /node_modules/,
          priority: 20,
        },
        common: {
          name: `Common-${mainEntry}`,
          minChunks: 2,
          chunks: 'async',
          priority: 10,
          reuseExistingChunk: true,
          enforce: true,
        },
      },
    },
  },
};

module.exports = extendWebpackBaseConfig(commonConfig, productionConfig);
Enter fullscreen mode Exit fullscreen mode

Common Configuration

Lastly, we'll set up core configurations required for Webpack and Module Federation to run properly.

Host Module Federation Configuration

The host will contain our shared contract of dependency versions between remotes. We do this by declaring the shared property. For convenience, we're using an optional plugin called automatic-vendor-federation to make it easier to get version data and exclude libraries from the negotiation process.

const ModuleFederationConfiguration = () => {
  const AutomaticVendorFederation = require('@module-federation/automatic-vendor-federation');
  const packageJson = require('./package.json');
  const exclude = ['express', 'serverless-http'];

  return new ModuleFederationPlugin({
    shared: AutomaticVendorFederation({
      exclude,
      packageJson,
      shareFrom: ['dependencies'],
      jquery: {
        eager: true,
      },
    }),
  });
};
Enter fullscreen mode Exit fullscreen mode

Remote Module Federation Configuration

The remote configuration will contain the scope name, the module exposed alongside its' relative path in the repository, and lastly, the default name of the remote entrypoint used to bootstrap remotes:

const ModuleFederationConfiguration = () => {
  return new ModuleFederationPlugin({
    name: 'FormApp',
    filename: 'remoteEntry.js',
    exposes: {
      './initContactForm': './src/form/init-contact-form',
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

DynamicContainerPathPlugin

Next we configure DynamicContainerPathPlugin to set publicPath at runtime:

const DynamicContainerPathPlugin =
  require('dynamic-container-path-webpack-plugin');
const setPublicPath =
  require('dynamic-container-path-webpack-plugin/set-path');

new  DynamicContainerPathPlugin({
    iife:  setPublicPath,
    entry:  mainEntry,
}),
Enter fullscreen mode Exit fullscreen mode

Essential Configurations

The next step is to configure our entrypoints, output configurations, and remaining plugins. First, we'll set up our main entrypoint. The referenced file should be bootstrap.js, our asynchronous boundary for static imports.

target:  'web',
entry: {
  [mainEntry]: ['./src/bootstrap.js'],
},
Enter fullscreen mode Exit fullscreen mode

The output configuration has a publicPath default value of /. This can be ignored because DynamicContainerPathPlugin will modify the value at runtime.

output: {
  publicPath:  '/',
  path:  path.resolve(__dirname, './dist'),
},
Enter fullscreen mode Exit fullscreen mode

runtimeChunk: single

The Webpack merge configuration used in these repositories has runtimeChunk: single set as an optimization default so that the runtime file is shared across all generated chunks.

At the time of this writing, there is an issue with Module Federation where this setting doesn't empty out federated container runtimes; breaking the build. We override by setting runtimeChunk to false.

optimization: {
  runtimeChunk:  false,
},
Enter fullscreen mode Exit fullscreen mode

HtmlWebpackPlugin

This plugin is used to generate the html. We don't want our js assets duplicated by HtmlWebpackPlugin since we are already dynamically injecting our entrypoints at runtime and no longer need to bootstrap them at compile time. We'll use excludeChunks to do this:

new  HtmlWebpackPlugin({
  filename:  'index.html',
  title:  `${mainEntry}`,
  description:  `${mainEntry} of Module Federation`,
  template:  'src/index.html',
  excludeChunks: [...chunks.entrypoints],
}),
Enter fullscreen mode Exit fullscreen mode

Other Plugins

We're adding ProvidePlugin to define jQuery (we're using this library primarily to test out the Module Federated library negotiation process).

We're also going to add CopyPlugin to copy over the config/ directory containing our chunk mappings and WebpackAssetManifest to generate a mapping of cache-busted assets.

new webpack.ProvidePlugin({
  $:  'jquery',
  jQuery:  'jquery',
}),
new CopyPlugin({
  patterns: [{ from:  'config', to:  '' }],
}),
new WebpackAssetsManifest({}),
Enter fullscreen mode Exit fullscreen mode

The entire code should look like the following:

webpack.common.js

const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const WebpackAssetsManifest = require('webpack-assets-manifest');
const { ModuleFederationPlugin } = require('webpack').container;
const DynamicContainerPathPlugin = require('dynamic-container-path-webpack-plugin');
const setPublicPath = require('dynamic-container-path-webpack-plugin/set-path');
const chunks = require('./config/chunks.config.json');
const mainEntry = chunks.entrypoints[0];

const commonConfig = isProduction => {
  // HOST M.F. Configuration
  const ModuleFederationConfiguration = () => {
    const AutomaticVendorFederation = require('@module-federation/automatic-vendor-federation');
    const packageJson = require('./package.json');
    const exclude = ['express', 'serverless-http'];

    return new ModuleFederationPlugin({
      shared: AutomaticVendorFederation({
        exclude,
        packageJson,
        shareFrom: ['dependencies'],
        jquery: {
          eager: true,
        },
      }),
    });

    // REMOTE M.F. Configuration
    const ModuleFederationConfiguration = () => {
      return new ModuleFederationPlugin({
        name: 'FormApp',
        filename: 'remoteEntry.js',
        exposes: {
          './initContactForm': './src/form/init-contact-form',
        },
      });
    };
  };

  return {
    target: 'web',
    entry: {
      [mainEntry]: ['./src/bootstrap.js'],
    },
    output: {
      publicPath: '/',
      path: path.resolve(__dirname, './dist'),
    },
    optimization: {
      runtimeChunk: false,
    },
    plugins: [
      new webpack.ProvidePlugin({
        $: 'jquery',
        jQuery: 'jquery',
      }),
      new CopyPlugin({
        patterns: [{ from: 'config', to: '' }],
      }),
      new WebpackAssetsManifest({}),
      new HtmlWebpackPlugin({
        filename: 'index.html',
        title: `${mainEntry}`,
        description: `${mainEntry} of Module Federation`,
        template: 'src/index.html',
        excludeChunks: [...chunks.entrypoints],
      }),
      new DynamicContainerPathPlugin({
        iife: setPublicPath,
        entry: mainEntry,
      }),
    ].concat(ModuleFederationConfiguration),
  };
};

module.exports = commonConfig;
Enter fullscreen mode Exit fullscreen mode

Conclusion

If you've made it this far, thank you and congratulations! You can find all of the code covered in the following repositories:

There was a lot to cover, but the end result is a solution that supports fully dynamic, multi-environment configuration.

To recap, this is what we covered in this guide:

  • A high level overview of Module Federation and its' advantages and disadvantages.
  • A summary of the problem and the desired technical outcomes.
  • An overview of various solutions identified and project structure.
  • How to mutate publicPath and bootstrap chunks dynamically.
  • Overview of core project files and Webpack configurations.

Lastly, we'll review the advantages using this method as well as the disadvantages so you can make an informed decision on determining if this is the right approach for you:

Advantages:

  • More easily support multiple testing environments without adding more complexity to your bundle configurations (hard-coded URLs)
  • URLs only need to be updated once in one location (map.config.js).
  • Environment context setting can be deferred to the build pipeline.
  • Despite having remote and host containers initialize at runtime, you can still leverage all of Module Federation's current features (library negotiation, etc.)
  • Most configuration code, including Webpack configurations, can be bundled and reused as scaffolding for other projects.
  • Continue to leverage advanced Webpack features alongside Module Federation including code-splitting, lazy-loading, cache-busting, webpack merge support, etc.

Disadvantages

  • Repositories are dependent on a single global file of URL mappings. Careful planning is required to ensure downtime is kept to a minimum.
  • Renaming entrypoints will require updates at the project level (chunks.config.js) and at the global level (map.config.json). Any host applications referencing remotes will need their references in chunks.config.js updated too.
  • Configurations covered adds a fair amount of complexity and requires a deeper level knowledge of Webpack that teams will need to familiarize themselves with.

Alternative Approaches

Alternative approaches that aim to provide similar functionality to what was described above can be found in the following repositories:

Dynamic Remote Vendor Sharing Example

Module Federation Dynamic Remotes With Runtime Environment Variables

Dynamic Remote with Vendor Sharing and Synchronous imports Example

Additional Readings

I would like to share additional references that helped solidify my understanding of Module Federation:

Module Federtation overview and setup guide

Overview of recent API changes

Detailed review of recent API changes

How static imports are hoisted in Module Federation

Dependency version negotiation/contract guide

List of API options and their descriptions

Module Federation podcast overview

Module Federation podcast slide references

Analysis of Micro Frontends in Enterprise

License

MIT

Discussion (8)

Collapse
scriptedalchemy profile image
Zack Jackson

Heyo! Creator of MF here. This is a great article - very glad to see some enterprise guidance come to light. One note i wanted to point out.. you dont have to use hard coded remotes or .env variables to control the remotes, or even globals through runtime mutation. I support promise new Promise as a remote value which lets you do anything you like to resolve a remote container. I use this with Medusa to fetch the right container or a version of a container for a specific consumer at runtime.

new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // This part depends on how you plan on hosting and versioning your federated modules
      const remoteUrlWithVersion = 'http://localhost:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (arg) => {
            try {
              return window.app1.init(arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // inject this script with the src set to the versioned remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
Enter fullscreen mode Exit fullscreen mode
Collapse
scriptedalchemy profile image
Zack Jackson

Or in Medusa's case we have Remote Module Management.

      remotes: {
        dsl: clientVersion({
          currentHost: "home",
          remoteName: "dsl",
          dashboardURL:
            "http://localhost:3000/api/graphql?token=d9a72038-a1cd-4069-85e2-d8f56d843732e",
        }),
      }
Enter fullscreen mode Exit fullscreen mode
Collapse
waldronmatt profile image
Matthew Waldron Author • Edited on

Hi Zack thank you for sharing! 🙏 I'm looking forward to trying out this approach. With this we can simplify the entire process for getting remotes for different environments.

Collapse
scriptedalchemy profile image
Zack Jackson

Working out a few bugs in RMM still, but you can point your applications to my Medusa which is now hosted in alpha medusa-example-home.vercel.app/

Collapse
rishiagarwal95 profile image
Rishi Agarwal • Edited on

Hey @scriptedalchemy , so can we also make an API call inside this promise new Promise block ,assuming that API returns us the version needed?

Collapse
schirrel profile image
Alan Schio

NIce Matthew, congrats
Another point, the limitation of harded code url, aren't in fact a cons, if using a well knows at organization or large scale apps '.env' approach.
Me and a friend have done a bigger experiment with Module Federation using vue, to simulate in some sort of way, a company process. Here is the repo github.com/mfe-module-federation-vue

Collapse
waldronmatt profile image
Matthew Waldron Author

Thanks for sharing Alan this looks great! This does well to dynamically assign them via environment context. While you would need to update each repo if a shared URL across apps changes this doesn’t seem to be an issue if your organization doesn’t change them often.

Collapse
ecyrbe profile image
ecyrbe

Hello, you can see a simpler setup i recommend to everyone here : dev.to/ecyrbe/webpack-module-feder...