Micro-FE Architecture: Webpack 5, Module Federation, and custom startup code.
Advanced implementation tactics to attach a startup sequence to micro-frontend applications designed using Module Federation.
I’ll get this out the way right from the start, something that’s been requested hundreds of times now. You can set webpack_public_path
dynamically (not hardcoded in the webpack config) with startup code, but there are easier solutions now built into webpack 💯 for dynamic public paths. Startup code has more advanced possibilities, but for the sake of introduction- I’m demonstrating a manual method that uses the startup code pattern. I’ll develop some better examples soon which demonstrate complex integrations.
What is startup code?
In the context of Module Federation, startup code is an implementation tactic to attach additional runtime code to a remote container startup sequence.
This is really useful since ModuleFederation and its runtime are inaccessible via hooks, there's no way to extend it or add a line of code that would do something like set the public path of a remote container on the fly.
This is trivial to do inside a normal webpack application, But pretty hard to do inside an inaccessible custom runtime container that powers module federation remote orchestration.
You could also attach suspense-like data fetching to the federation API itself, a federated import would import both Module and application state, just via import()
— assuming one is familiar with Module federation, this is not a hard thing to do.
An overview of Module Federation internals.
Setting the public path dynamically.
Let's keep it simple since this is the first time its officially introduced as a capability to the public.
In our webpack config, I'm going to leverage how webpack works, and merge an extra file into the webpack runtime.
Here's what we are doing
- Create an
entry point
with the same name as the name you place in ModuleFederationPlugin. - I set the publicPath in either webpack build to whatever I want it to be, and it's now relative (yay!)
- Ill exclude app1 chunk, just incase HTML webpack plugin tries to do something silly. It doesn't have the best MF support right now when it comes to advanced implementations
**_module_**. **_exports_** = {
entry: {
// we add an entrypoint with the same name as our name in ModuleFederationPlugin.
// This merges the two "chunks" together. When a remoteEntry is placed on the page,
// the code in this app1 entrypoint will execute as part of the remoteEntry startup.
app1: "./src/setPublicPath",
main: "./src/index",
},
mode: "development",
output: {
// public path can be what it normally is, not a absolute, hardcoded url
publicPath: "/",
},
plugins: [
new ModuleFederationPlugin({
name: "app1",
remotes: {
app2: "app2@http://localhost:3002/remoteEntry.js",
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
excludeChunks: ["app1"],
}),
],
};
Let us take a look at the entry point.
Let's see what I'm merging into the runtime, this file is the startup code.
This code is inside app2, the code above is the webpack config of app1
"./src/setPublicPath",// nod to parcel, i think they inspired this convention.
\_\_webpack\_public\_path\_\_ = new **_URL_** ( **_document_**.currentScript.src).origin + "/";
**_console_**.log(
"this is webpack startup code, setting public path to:",
\_\_webpack\_public\_path\_\_
);
// since i just added a other module to the runtime, it ends up being the last module returned to the scope.
// For now, we need to re-export the container to the scope.
// you could use webpack internals to make this more dynamic, bue we will likely provide a internalized solution to attach a startup module
**_Object_**.assign( **_window_** , {
app2: \_\_webpack\_require\_\_("webpack/container/entry/app2"),
});
Since we are leveraging “how webpack ‘just’ works”, it's not immediately straightforward.
The entry point naming trick takes advantage of module and chunk merging.
If two entry points have the same group or chunk name, they are merged. That's how cacheGroups
works.
Since the remote container is supposed to immediately return the federated interface, not the appended startup code. A “dirty” workaround is to manually re-attach the interface to the global scope. This could likely be cleaned up to some low-level equivalent of module.exports
What else is possible?
Well, quite a lot..
Startup code and the re-export capability enables us to take advantage of the async mechanics powering MF.
Server-side, this is even more interesting! A remote could export its client-side chunks to another rendering host. This would allow us to take advantage of the years old, but still used for everything — chunk-flushing mechanisms powering next.js, react universal, react-loadable, and nearly all others.
Doing so, we can avoid flicker as the SSRd markup is reflashed due to some chunks not being ready. One could SSR the remote module script paths inside a host render. Giving us immediate client-side hydration.
You could rebuild a next.js like system that uses the async import to also run getInitialProps. For those of you who remember redux-first-router and its async data plus code-split routing, you can leverage something similar with module federation where webpack acts as a routing layer. Avoiding framework limitations by delegating the async challenges to webpack itself.
You could include complex module loading logic inside the startup code. Imagine creating virtual modules, or module level routing inside webpack itself.
Most of the best frameworks around, like next.js — which arguably is the world's best webpack plugin with a server. Deep webpack integrations significantly reduce the amount of manual labor needed to create well-abstracted architecture. Via module federation, we don't need a ton of custom loaders and router plugins to create very large, very complex applications.
Startup code would let you attach tag management bindings to the loading interface, moving very inefficient third party tags and their runtime management to a webpack-orchestrated counterpart. As vendor code keeps taking over, we need modern solutions to integrating third party execution under engineering level systems architecture that is powered by webpack.
This is my latest obsession in an attempt to take back control from tag management teams. The approach isn’t immediately understood, considering tags are external scripts. Luckily, part of module Federation was rewriting externals and how it works. So vendor tags can be added as async external scripts that webpack can load on demand. Analytics code and other integrations could be imported into the application lifecycle or internal state. You’d also be able to async import vendor when needed since webpack is managing the code.
One could add an event system inside the tag manager that calls specific rules, basically sending remote instructions to webpack, where code is loaded efficiently. The event system can act as a familiar pattern to the group managing these tags — since they might not import code, we can wire up a new kind of communication bus, other then the one webpack exposes by default. Something similar was built for Adobe Target integration where AB tests were json payloads that told webpack what to do, allowing modules or components to be replaced with others in a remote repo. This reduced Target runtime overhead to zero milliseconds since this is a federated application and everything is written in react. There’s no dom mutation that needs to be reapplied on each render. Its managed within the virtualDOM, extremely efficient!
That 200kb fbjs library could be required as an external and only loaded as an external when a script using fbjs is loaded. Breaking down enormous vendor tags dumped in mass because they cannot communicate with a SPA as well as engineering can within its webpack closure.
All said and done. Heres the repo this article was based on
Don't forget to follow me on twitter!