We use ModuleFederation extensively on Autodesk Construction Cloud.
Around 30 single-page-applications that are transforming into federated remotes that will later on be consumed by a single host that is still forming.
In this post I will cover why you would want to dynamically load federated modules, and share some creative ways I found to implement it.
Let's start by understanding how remotes are set and loaded traditionally using ModuleFederation.
Traditions π€·
Traditionally, ModuleFederation allows to set remotes during build-time:
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteA: 'remoteA@https://example.com/remoteA/remoteEntry.js',
},
shared: {
...
},
});
While this will work for most use cases, questions will start to rise once you add another environment to the equation.
If we want to support more than one environment, for an example Staging & Production, the traditional way won't be enough for you.
You can easily build a different output for each environment and well, problem solved.
But in our case, due to technical restrictions, we couldn't do it.
π¨ Personal opinion π¨
In general, I believe code should be agnostic to it's environment, the more it's aware, the more complexities you add for different environments. But that's for a different blog post.
Before we dive into solutions, we need to understand how ModuleFederation is loading remotes behind the scenes.
How remotes are loaded? π€
The URL passed for each remote in the remotes object is a direct URL to the remoteEntry file.
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteA: 'remoteA@https://exmaple.com/remoteA/remoteEntry.js', // <-- this
},
shared: {
...
},
});
The remoteEntry.js file contains references to the exposed modules of an application. It doesnβt contain the code for those modules, but it knows where to go get the code. Thatβs important because it allows those modules to be lazily loaded via import() if that is how you choose to load your code.
Once your app loads, a script tag is created and appended to your <head>
tag.
It would look like this:
<html>
<head>
<script src="https://example.com/remoteA/remoteEntry.js" type="text/javascript"></script>
</head>
</html>
When the script finished loading, the remote would become available on your window via window.remoteA
.
Then, when you lazily load a remote module, this executes behind the scenes:
window.remoteA.get('./remoteComponent')...
.
Now that we understand how remotes are loaded, we can start to think of ways to mimic and improve it.
Promise based dynamic remotes π‘
Back to business, we're trying to find a way to load remotes dynamically.
Well, turns out there's a magical feature that allows us to pass a promise (kind of) to the remote instead of passing a static url as shown in the example above.
That means we can add a layer of logic that'll "decide" where to load the remote from.
Implementation π¨βπ»
Let's start with creating a remoteLoader.js
file in our host app.
That remote loader will be used later on to dynamically load our remotes.
Here's the code:
const remotes = {
remoteA: {
local: 'https://localhost:3000/remoteEntry.js',
qa: 'https://qa.example.com/remoteA/remoteEntry.js',
staging: 'https://staging.example.com/remoteA/remoteEntry.js',
production: 'https://example.com/remoteA/remoteEntry.js',
},
};
const remoteLoader = (scope, remoteURLs = remotes) => {
return new Promise((resolve, reject) => {
if (!window[scope]) {
const existingRemote = document.querySelector(
`[data-webpack="${scope}"]`
);
const getRemoteURL = () => {
if (window.location.hostname.includes('local'))
return remoteURLs[scope]['local'];
if (window.location.includes('qa')) {
return remoteURLs[scope]['qa'];
} else if (window.location.includes('staging')) {
return remoteURLs[scope]['staging'];
} else if (window.location.includes('example.com')) {
return remoteURLs[scope]['production'];
}
};
const remoteEnvURL = getRemoteURL();
const onload = async () => {
// resolve promise so marking remote as loaded
resolve(window[scope]);
};
// if existing remote but not loaded, hook into its onload and wait for it to be ready
if (existingRemote) {
existingRemote.onload = onload;
existingRemote.onerror = reject;
} else {
const script = document.createElement('script');
script.setAttribute('data-webpack', scope);
script.async = true;
script.type = 'text/javascript';
script.src = remoteEnvURL;
script.onload = onload;
script.onerror = () => {
document.head.removeChild(script);
reject(new Error(`[${scope}] error loading remote: ${remoteEnvURL}`));
};
document.head.appendChild(script);
}
} else {
// remote already loaded
resolve(window[scope]);
}
});
};
export default remoteLoader;
Let's break it down.
Initially, the code is checking if the remote is already loaded, that's important since we don't want to load a remote that's already there. (Duh πββοΈ)
Then, we detect the current env according to the hostname.
Yes, we can access the window since the code is lazily ran during run-time!
Once we have the URL, we build a script element and append it to the document head tag, and resolve the promise once the script has finished loaded.
How do we use it?
We're gonna do something pretty cool. NGL.
First, we'll expose the remoteLoader file as a remote from our host.
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteA: 'remoteA@https://exmaple.com/remoteA/remoteEntry.js',
},
exposes: {
'./remoteLoader': './utils/remoteLoader',
},
shared: {
...
},
});
We'll use that remoteLoader
as a runtime module to fetch the remotes.
We need to keep in mind that in order to use it we need to make sure the host's remoteEntry file has finished loading.
To make everything work, we have one last step and that is to create a stringified promise that we'll pass to the remote.
The promise will:
- Wait for the host's remoteEntry file to finish loading
- Get the
remoteLoader
from the hosts exposed modules - Run the
remoteLoader
and resolve.
Here's the code:
const getRemoteLoaderPromise = (scope) => {
return `promise new Promise((resolve, reject) => {
let intervalId;
const waitForHostToLoadAndExecuteRemoteLoader = () => {
if (!window.host) {
return;
}
clearInterval(intervalId);
window['host'].get('./remoteLoader').then((factory) => {
const remoteLoader = factory();
remoteLoader.default('${scope}').then(resolve).catch(reject);
});
}
intervalId = setInterval(waitForHostToLoadAndExecuteRemoteLoader, 10);
})`;
};
new ModuleFederationPlugin({
name: 'host',
remotes: {
remoteA: getRemoteLoaderPromise('remoteA'),
},
exposes: {
'./remoteLoader': './utils/remoteLoader',
},
shared: {
...
},
});
Our code will have an interval that waits for window.host
to become available with its modules.
Then, we'll leverage the exposed remoteLoader
and use it to load remotes dynamically.
That's it!
After you put together all the pieces, you'll be able to use top-level imports for your remotes.
For an example:
import React from 'react';
const MyRemote = React.lazy(() =>
import('remoteA/App')
);
const MyComponent = () => {
return (
<React.Suspense>
<MyRemote />
</React.Suspense>
);
}
export default MyComponent;
Conclusion π΄
There are many approaches to implement dynamic remotes with ModuleFederation.
Honestly, I think I tried all of them.
This one was the most advanced way as it allows to have an actual JavaScript file that we can lint, test, and easily maintain.
Besides my use case, you can use the remoteLoader
technically for everything.
Running code before loading remotes, fetching a manifest, communicating with a DB, tracking analytics, monitoring, you name it.
If you have any ideas on how to improve or share a different approach you took, let me know in the comments!
Top comments (1)
Well written, even I am also doing POC of same and planning to use share components from my applications to other teams.