Portal for micro frontends using Webpack Module Federation and Single SPA
Why look into Module Federation? Let's start with a traditional monolithic application product. Team A wrote CommonComponent1
that Team B wants to make an update to. The product needs a new version to be built. Slow and annoying. Let's upgrade to multiple projects that get bundled together and are hosted from a single webserver. Now Team A updates CommonComponent1
to v2 in their own app. Team B does not have to take that update because their app is isolated. However, the product still needs a new version, and the webserver needs to be redeployed. Annoying, still, and perhaps just as slow, just an organized slowness.
Let's now imagine that we have dozens of applications, perhaps a handful that depend on common packages, but all depend on a singular framework. The customer asks for an enhancement which requires a particular framework change, that'll need to make it to a new version of the product (a SPA). Great... Some sorry group of developers needs to make that framework change, propogate a new package version to all common component packages, get them through CI, and continue the process down the chain into the applications, and then the deployment project. What the heck? Did we exchange organization for efficiency?
Knowing what we know today, can we do better?
I'd like to think we can do better, but define better. What is better?
Opinionated "better":
- Fewer network calls (faster load times)
- Organized repositories (responsibilities, maintenance, teams)
- Continuous deployment (low downtime, saas)
- Easy adoption (scalability, complexity)
- Proper error handling (consumer issue reporting)
- React 18 - for rendering, context, local state, and lifecycle methods
- SolidJS - alternative to React, to test Single-SPA capabilities (below)
- Redux - for application state container
- Redux Toolkit - for better DX on asynchronous operation calls and communications
- React Router 6 - for local routing between apps
- Semantic UI React - user interface component library
- Webpack 5 - asset/module bundler and server
- Single-SPA* - microservice liaison (*only required if not all rendering done in React, e.g., SolidJS)
- Express OIDC - login server
- MongoDB - user database
- Vercel - edge deployment (hobby plan)
UC-1: As a user, I want to access the application from a single URL
UC-2: As a user, I need to have my own account
UC-3: As a user, I need to be able to access features that I am allowed to
R-1: Main entrypoint shall redirect to login screen (Express OIDC, e.g., http://localhost:3000)
R-2: Main application shall supply some form of global navigation (React, React Router 6)
R-3: Feature applications shall only be exposed to permitted users
R-4: Feature applications shall receive session information from Main application (Redux)
D-1: Feature applications may have their own internal router and sub pages
D-2: Feature applications should use the same shared UI components from a container (Semantic UI React)
D-3: Feature applications should consume a global state store (Redux)
D-4: Feature applications can provide their own local state store
D-5: Feature application runtimes and (non-shared) dependencies should be bundled (Webpack 5)
D-6: Shared Application dependencies should be federated (Webpack 5)
D-7: Feature applications shall be hosted separately in true distributed fashion (Vite)
D-8: Feature applications shall be configurable via YAML or JSON files
D-9: Configuration should be done at the time of deployment (Vercel)
The future of JavaScript is modules, right? An overwhelming majority of the community is in agreement that the standardization and improvements towards modules and lazy imports is headed in the right direction.1 The ways of importing <script>
tags and building runtime binaries locally are going to be left to a bygone era. Everyone wants in on the hip new asynchronous operation loading of remote modules via chunks. Users want to access their apps in the cloud via single-sign-on (SSO). Developers want to create cooperatively and efficiently via code sharing. Deployment teams want to support scalability.
It all boils down to if it is feasible, productive, and profitable - at least from a business perspective. I want to do my part by being a professional frontend software engineer and dive into the scalability solutions offered by the Webpack team2.
Module Federation is an interface that allows a JavaScript application to dynamically load code from another application — in the process, sharing dependencies, if an application consuming a federated module does not have a dependency needed by the federated code — Webpack will download the missing dependency from that federated build origin.
That sounds awesome! Code can be shared, but fallbacks exist for each case, always prioritizing the federated code before attempting to request more. Less code duplication, less network traffic, better performance, happier users, happier devs.
Here's a simple visual:
graph BT
subgraph App
B1(Libraries)-->B2(Store)-->B3(Components)
B4(Pages)
end
subgraph Internals
A1(Libraries)-->A2(Store)-->A3(Component A)
A2-->A5(Component B)
A6(Component C)
A3--federated modules-->B4
A1--federated modules-->B1
end
In traditional web applications, we serve the assets (HTML, CSS, JavaScript) to render. Now, with module federation we can separately serve our internals, like from a Design System library, and see updates near real-time in all consumer applications. Now, we may want multiple rendering libraries or multiple versions of applications being used in the same frontend. Not having that would make it simpler.
To get different rendering frameworks together, like Vue and React, or React 16 and 18, we can leverage a library called Single-SPA. Single-SPA relies on SystemJS to get the modules onto the DOM. Once the modules are loaded, they should all be using the same lifecycle methods. That won't necessarily work with Webpack 5's Module Federation, however we can replace SystemJS with federated modules.
Here's how that might look:
graph BT
subgraph Replace
O(Orchestrator)
O-->MF(Module Federation)
S(SystemJS)-->T(-Bin-)
P1(Parcel)
P2(Parcel)
P1-->O
P2-->O
end
subgraph Application
A1(Libraries)-->A2(Store)-->A3(Component A)
A2-->A4(Component B)
A5(Component C)
A6(Single-SPA)
MF-->A6
PP(=Parcel=)
A3-->PP
A4-->PP
A5-->PP
end
The code for the Parcels moves back into the applications themselves.
These applications become:
"...bi-directional hosts. Any application that's loaded first, becomes a host - as you change routes and move through an application, loading federated modules in the same way you would implement dynamic imports. However if you were to refresh the page, whatever application first starts on that load, becomes a host."3
Load the Landing page first? That's the host. Navigate from there to the About page? That's a remote. Refresh on the About? Now About is the host. The fetching between hosts and remotes only requires small portions of runtime code, not an entire entrypoint or entire application.
This host/remote debacle can be streamlined in architecture as treating a simple head entrypoint on top of an application. This main entrypoint connects all the other Webpack runtimes and provisions from the orchestration layer at runtime. It's not a normal app entrypoint; only a few KB. We'll call this simple package a Remote Entry. Remote Entries are our special entrypoints that will contain a special Webpack runtime that can interface with a host, we'll call a Portal.
A Portal App is a frontend application, built with Webpack, and will be consumed by the host. In order to be consumed, it must declare what it will expose. It can expose anything from lowest-level components like a particular Button to it's highest-level component like an Initializer. While there is support for bi-directional hosting, I don't see a point in using it from a more traditional app structure, so Portal Apps will not become a host - the user will only access the main entry.
A Remote Entry, as described above, will act as an entry to its corresponding Portal App. The goal is to have this be as small as possible to avoid network overhead, and provide just enough configuration in order to allow the orchestration from Webpack to perform its operations.
The Portal shell application is going to act as a host/hub for all downstream micro frontends to make up a full modular application. Built with Webpack, it will be initialized on the first page load.
graph LR;
P(Portal :3000/)
S1(:3001/remoteEntry.js)
S2(:3002/remoteEntry.js)
A1(:3001/portalApp.js)
A2(:3002/portalApp.js)
P--router-->S1--federation-->A1--bootstrapper-->S1--promise-->P
P-->S2-->A2-->S2-->P
Let's look at some code that demonstrates a remote app "app_two_remote" exposing a component called Dialog
, that is going to be consumed in another remote app "app_one_remote".
//app_one_remote/src/AppRouter.js
import * as React from 'react';
import { Route, Routes } from 'react-router-dom';
import Page1 from './pages/page1';
import Page2 from './pages/page2';
export default function AppRouter() {
return (
<Routes>
<Route path='/page1' element={<Page1 />} />
<Route path='/page2' element={<Page2 />} />
</Routes>
);
};
//--------------------------
//app_one_remote/src/App.js
import * as React from "react";
import AppRouter from './AppRouter'
const AppContainer = React.lazy(() => import("app_one_remote/AppContainer"));
export default function App() {
return (
<div>
<React.Suspense fallback="Loading App Container from Host">
<AppContainer routes={AppRouter}/>
</React.Suspense>
</div>
);
}
//app_two_remote/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "app_two_remote",
library: { type: "var", name: "app_two_remote" },
filename: "remoteEntry.js",
exposes: {
“./Dialog”: "./src/Dialog"
},
remotes: {
app_one: "app_one_remote", //Not necessary, but demonstrates bi-directional hosting
},
shared: ["react", "react-dom", "react-router-dom"]
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["main"]
})
]
};
//app_one_remote/src/pages/Page1.js
const Dialog = React.lazy(() => import('app_two_remote/Dialog'));
export default function Page1() {
return (
<div>
<h1>Page 1</h1>
<React.Suspense fallback='Loading Material UI Dialog...'>
<Dialog />
</React.Suspense>
</div>
);
}
There is little to no dependency duplication. Through the shared option — remotes will depend on host dependencies, if the host does not have a dependency, the remote will download its own. No code duplication, but built-in redundancy.3
In a true distributed fashion, these micro-frontends should be deployed separately via their own webservers. Each webserver can be on the same host IP, and different ports, or different IPs depending on the networking configuration (blah blah insert CORS/Certs hand-waiving).
5 Misconceptions4
Module Federation is a code transport layer, getting runtimes across boundaries. Micro-frontends on the other hand, is an architectural style that suggests sharing code between applications (particularly UI). Typically, micro-frontends are capable of being deployed standalone, while sharing pieces of those frontends can be made easier with federation.
Exposed modules/components that have internal state management will end up being isolated in terms of state.
E.g., a Counter
exposed from a "remote" project to a "host" project - the "host" import
s the Counter
from the "remote" and what happens when we increase count? Nothing! All we're doing is sharing the objects and their properties, not runtime mutations.
Projects that share code will have to be deployed to operate as static assets. It becomes a static application. We aim to make an asset store, like S3, to host our modules so our app doesn't fail to load scripts or crash if a particular remote goes down. Docker is a poor choice to host these bits. The consuming applications can be hosted from Docker though, so long as they do not intend to expose modules.
Let's say Team A makes an update to an exposed component where function signatures were changed, i.e. parameters or return types. Team B uses that exposed component, and refreshes their page, when boom it breaks. One good way to make it obvious is with React's ErrorBoundary. Unlike traditional NPM published packages being consumed on all apps, using federated modules may break at runtime after deployment because of the unsafe version-control. The huge tradeoff here is safety for efficiency.
Since exposed modules are purely compiled JavaScript, there is no typing information involved, at all! That's a big bummer! How can we remedy this? Well, we can add a local @types/**/index.d.ts
within our project, then massage the consuming tsconfig.json
with jsx: "react"
and paths: ['./src/@types]
. It'd be better if we could define some sort of contract between the two, ergo a shared library. Our shared library will have all it's functions, components, modules, and type declarations provided as part of the index.
You can define those types in any build-time available resource, could be an NPM library, a local file, etc. The issue here is build-time vs run-time and it's not specific to Module Federation. - Jack Herrington