4 Ways to Use Dynamic Remotes in Module Federation
Jul 6, 2022 ☕ 7 min readHow can you deploy the same micro frontend application to testing, staging, and production environments? How can your application support on-premise, cloud, and hybrid deployments simultaneously? How can you scale multiple teams working on different parts of the architecture simultaneously? To introduce new remote applications dynamically?
Why use Dynamic Remotes?
In a previous post, we discussed configuring a simple React application using Webpack Module Federation in a host-remote configuration. When we defined the URLs for the remote applications, we used localhost URLs:
plugins: [new ModuleFederationPlugin({name: "Host",remotes: {RemoteA: `RemoteA@http://localhost:4000/remoteEntry.js`,RemoteB: `RemoteB@http://localhost:5000/remoteEntry.js`,},}),];
The above works great for local development, but this would not work if we deployed and hosted the application. Of course, we could replace the strings with production URLs - but then what happens to local development? Do we keep flip-flopping back and forth manually, hoping no one merges in the wrong URLs?
This pattern would also not work if you have multiple deployment stages with different URLs and environments. If we could not test remote apps in isolated environments in deployment pipelines, we would run into chaos and frustration as developers introduce breaking changes. Everyone would lose all confidence in the development flow.
Instead of localhost
we want to define URLs dynamically to point to the correct host depending on the environment.
Dynamic Remotes in Webpack Module Federation
Luckily Webpack Module Federation supports dynamically defining URLs for our remote applications. We are going to consider four solutions available to us:
- Environment variables
- Webpack plugin
external-remotes-plugin
- Promise Based Dynamic Remotes: docs
- Dynamic Remote Containers: docs
Environment Variables
The most straightforward choice is using environment variables at build time. We replace localhost
in our Webpack config with environment variables locally or a deployment pipeline:
module.exports = (env) => ({// ...plugins: [new ModuleFederationPlugin({name: "Host",remotes: {RemoteA: `RemoteA@${env.A_URL}/remoteEntry.js`,RemoteB: `RemoteB@${env.B_URL}/remoteEntry.js`,},}),],});
You can define each remote app's URL as a local or hosted production deployment at build time. The advantage of this approach is its' simplicity, but there is a problem. We need to build a new version for each environment to update the URLs. For our enterprise use cases and multiple deployment models, this would not work.
What if you could change the remote URLs at runtime?
Webpack External Remotes Plugin
Under the hood, Module Federation allows us to load remote containers dynamically. We'll cover that in a minute. But first, there is a handy Webpack plugin developed by Zack Jackson, one of the creators of Module Federation, called external-remotes-plugin
. It allows us to resolve the URLs at runtime using templating:
plugins: [new ModuleFederationPlugin({name: "Host",remotes: {RemoteA: "RemoteA@[window.appAUrl]/remoteEntry.js",RemoteB: "RemoteB@[window.appBUrl]/remoteEntry.js",},}),new ExternalTemplateRemotesPlugin(),],
All you need to do is define window.appAUrl
and window.appBUrl
within our application before you load any code from the remote applications.
Now you have the flexibility to define our URLs however you want. For example, you could maintain a map of the host URLs to remote URLs for each environment, use server-side rendering to allow the backend to define the URLs, create a custom configuration management service, and many other methods.
This approach is fully dynamic and would solve our use cases, but there is still a slight limitation with this approach. We do not have complete control over the loading lifecycle.
Promise Based Dynamic Remotes
Module Federation allows us to define the remote URLs as promises instead of URL strings. You can use any promise as long as it fits the get/init
interface defined by Module Federation:
// Webpack config:module.exports = {// ...plugins: [new ModuleFederationPlugin({name: "Host",remotes: {RemoteA: `promise new Promise(${fetchRemoteA.toString()})`,},}),],};// Fetch Remote A dynamically:const fetchRemoteA = (resolve) => {// We define a script tag to use the browser for fetching the remoteEntry.js fileconst script = document.createElement("script");script.src = window.appAUrl; // This could be defined anywhere// When the script is loaded we need to resolve the promise back to Module Federationscript.onload = () => {// The script is now loaded on window using the name defined within the remoteconst module = {get: (request) => window.RemoteA.get(request),init: (arg) => {try {return window.RemoteA.init(arg);} catch (e) {console.log("Remote A has already been loaded");}},};}resolve(module);}// Lastly we inject the script tag into the document's head to trigger the script loaddocument.head.appendChild(script);}
Within the promise, we create a new script tag and inject it into the DOM to fetch the remote javascript file. window.appAUrl
contains the URL for the remote app.
The Webpack remote configuration must still be in string format, so I've defined the promise separately from the config and stringified it later for better readability. Unfortunately, this approach is not the easiest to debug or maintain because it is stringified code inside of a config file.
There is another limitation with all of the approaches so far. We are limited to only loading the RemoteA
and RemoteB
apps, which could be a security advantage. But what if a developer is working on a new remote app that we don't know exists yet? What if a partner or customer wants to inject their remote module into their deployment of our app?
Dynamic Remote Containers
Module Federation allows us to load remote applications programmatically without needing to define any URLs in our Webpack config:
plugins: [new ModuleFederationPlugin({name: "Host",remotes: {},}),],
But how?
Similar to the above approach, before you try to load any remote apps, you first need to fetch the remote module using a dynamic script tag. Then you can manually initialize the remote container.
From the Webpack docs:
(async () => {// Initializes the shared scope. Fills it with known provided modules from this build and all remotesawait __webpack_init_sharing__("default");const container = window.someContainer; // or get the container somewhere else// Initialize the container, it may provide shared modulesawait container.init(__webpack_share_scopes__.default);const module = await container.get("./module");})();
container
is referring to a remote app we configured in the "remotes"
field in our host app's webpack configuration.
module
refers to one of the items we defined in the "exposes"
field in our remote app's webpack configuration.
Using the approach of injecting a script tag you can fetch the remote container and store it in window.someContainer
as long as the code resolves to the same get/init
pattern we used earlier.
When you want to use one of the modules exposed by our remote app all you need to do is call: container.get(moduleName)
like in the example above.
What does this look like if you want to load a React remote app like we did in our basic React host-remote app?
Check out one of my other posts for an example: Rendering Dynamic Remote Containers in a React Micro Frontend
Final Thoughts
Using dynamically loaded remotes, you've learned four different mechanisms to break down your micro frontend deployment. When you deploy our micro frontend, you can fetch our remote applications from any URL you define. You can deploy it to multiple test environments, on-premises, or in the cloud. Developers can choose whether to use production versions of other remote applications or to introduce new ones dynamically.
Which method would you choose?