Welcome to Part 2 of the Micro-frontend Migration Journey series! In the previous part, we discussed the strategies and high-level design implementations for migrating to a micro-frontend architecture. We also explored different frameworks we can use for client-side orchestration. Now, it’s time to take the next step on our journey and focus on building the toolkit that will support our migration and future micro-frontend endeavors.
Creating a robust toolkit is crucial for a successful migration of existing apps and the smooth adoption of new micro-frontends in the future. In this article, we will dive into building an opinionated and batteries-included toolset for efficient bootstrapping and enhancement of micro-frontend architecture. From bundlers and module loaders to testing frameworks and build pipelines, we will explore the tools and technologies that will empower you to embrace the micro-frontend paradigm effectively.
(Note: As in the previous article, please be aware that while I share my personal experiences, I am not able to disclose any proprietary or internal details of tools, technologies, or specific processes. The focus will be on general concepts and strategies to provide actionable insights.)
To enhance deployability and isolation, it is essential for every micro-frontend application to deploy its asset bundles through its own pipeline. As we explored in Part 1 of this article series, each app must produce a build with a unified format that the deployment pipeline can comprehend. To streamline this process and minimize code duplication, we require a library that provides an all-in-one solution, exposing a single API for developers to utilize.
I have previously discussed the benefits of employing a declarative Infrastructure-as-Code (IaC) approach to manage and provision system infrastructure through definition files. AWS CDK can be leveraged to define the components of our deployment pipelines.
Below is a minimal interface that our utility can expose:
export interface PipelineProps {
app: App;
pipeline: DeploymentPipeline;
packageName: string;
bucketPrefix: string;
artifactsRoot: string;
}
export type buildPipeline = (props: PipelineProps) => void;
The buildPipeline
function can create a MicrofrontendStack
that performs the following tasks:
export class MicrofrontendStack extends DeploymentStack {
constructor(parent: App, id: string, env: DeploymentEnvironment, props: MicrofrontendStackProps) {
super(...);
const bucket = this.createSecureS3Bucket(useS3PublicRead, bucketName);
const artifacts = this.pullArtifacts(packageName, artifactsRoot);
const originPath = this.deployArtifacts(bucket, artifacts, shouldCompressAssets);
this.createCloudFrontDistribution(bucket, originPath);
}
}
Let’s examine the steps involved:
*.amazon.com
domain, where our Amazon CloudFront origin will reside. We can also define lifecycle rules for the bucket to retain only the last N deployments (the number of versions of the manifest file plus the number of directories for static assets).artifactsRoot
, which represents the build directory containing the manifest.json
file and the folder with static assets.BucketDeployment
: one for deploying the manifest.json
file and another for deploying the directory with the relevant assets. It is crucial to define different caching strategies for each of them. The manifest file should never be cached, while the assets prefix can have a meaningful max-age
cache. Don't forget to enable versioning in the S3 bucket, as the manifest file will always be located in the root of the bucket.originPath
.
Imagine the simplicity and convenience of creating pipelines for every app in your micro-frontend architecture. With our toolkit library, all you need to do is call the buildPipeline API, and the rest is taken care of. It's that straightforward!
buildPipeline({
app,
pipeline,
packageName: 'PaymentsAssets',
bucketName: 'payment-app-assets',
artifactsRoot: 'dist'
});
Gone are the days of manually configuring and setting up deployment pipelines for each micro-frontend application. Our utility library empowers developers to streamline the process and reduce repetitive tasks. By abstracting away the complexities, you can focus on what matters most: building exceptional micro-frontends.
The micro-frontend loader plays a vital role in the micro-frontend ecosystem. It is responsible for the dynamic downloading and bootstrapping of distributed applications within the browser’s runtime. This utility exposes a single API that can be utilized by any micro-frontend orchestration library, such as single-spa, to resolve references to target applications.
Here is a simplified implementation of the API:
const lifeCyclesCache= = {};
export const loadMicroFrontend = (
microfrontendKey,
originPath,
entryFileName
) => {
const cacheKey = `${microfrontendKey}/${entryFileName}`;
if(lifeCyclesCache[cacheKey]) return lifeCyclesCache[cacheKey];
lifeCyclesCache[cacheKey] =
downloadBundle(microfrontendKey, originPath, entryFileName);
return lifeCyclesCache[cacheKey];
};
Inputs:
microfrontendKey
is a unique identifier for the application, used for registering it in the global window scope (more on this in the next section).originPath
is the base URL to access the application's manifest file (typically the CloudFront origin URL).entryFileName
is the path to the main entry file of the application (e.g., index.js
).
The main logic resides within the downloadBundle
method:
If the application bundle has been loaded before, no action is required. The loader will retrieve it from the global window scope.
Otherwise, it attempts to discover the corresponding manifest file. There are two scenarios:
Download bundle. Loader will concatenate originPath
and entry file path name received from the manifest to be used as a source for script
HTML tag that will download the bundle:
const loadScript = (originPath, manifest, entryFileName) => {
return new Promise((resolve, reject) => {
const scriptTag = document.createElement('script');
const src = `${originPath}/${manifest[entryFileName]}`;
scriptTag.async = true;
scriptTag.type = 'text/javascript';
scriptTag.crossOrigin = 'anonymous';
scriptTag.onerror = () => {
reject(`Failed to load ${src}`);
};
scriptTag.onload = () => {
const bundle = window[manifest.microfrontendKey][entryFileName];
resolve(bundle);
};
document.body.appendChild(scriptTag);
scriptTag.src = src;
});
};
Here’s an example of how this loader can be used in conjunction with single-spa library:
import {registerApplication} from 'single-spa';
import {loadMicroFrontend, PAYMENT_APP_KEY, ORDERS_APP_KEY} from 'microfrontend-sdk';
registerApplication(
`${ORDERS_APP_KEY}-app`,
() => loadMicroFrontend(ORDERS_APP_KEY, getOriginURL(ORDERS_APP_KEY), 'index.js').toPromise(),
(location) => /\/orders.*/.test(location.pathname),
{
domElementGetter: () => document.getElementById('spa-placeholder')
});
registerApplication(
`${PAYMENT_APP_KEY}-app`,
() => loadMicroFrontend(PAYMENT_APP_KEY, getOriginURL(PAYMENT_APP_KEY), 'app.js').toPromise(),
(location) => /\/payments.*/.test(location.pathname),
{
domElementGetter: () => document.getElementById('app-placeholder')
});
registerApplication(
`${PAYMENT_APP_KEY}-alt-app`,
() => loadMicroFrontend(PAYMENT_APP_KEY, getOriginURL(PAYMENT_APP_KEY), 'alt.app.js').toPromise(),
(location) => /\/alt/payments.*/.test(location.pathname),
{
domElementGetter: () => document.getElementById('app-placeholder')
});
In this example, we demonstrate the combined usage of the micro-frontend loader and the single-spa library. By invoking the registerApplication
function, we register three applications (one entry for orders app and two entries for payments app). To trigger the loading process for each micro-frontend, we make use of the loadMicroFrontend
function, passing the appropriate parameters including the microfrontendKey
, originPath
, and entryFileName
. The loader ensures the dynamic loading and bootstrapping of the micro-frontends based on the specified conditions.
The micro-frontend loader greatly simplifies the process of integrating micro-frontends into our application. It offers a unified API that resolves application references and manages the download and bootstrap operations for the required bundles. Although the loadMicroFrontend
API is primarily used within the container (shell) application, it is crucial to share the micro-frontend keys among the tenant applications living in the container. This enables the app bundlers to expose the individual apps to the global window scope properly, facilitating seamless access and retrieval of bundles by the loader.
To ensure a unified build process across all micro-frontends within the container application, it is essential to have a shared configuration that every app can import and enhance as needed. Here is an example of a minimalistic Webpack configuration that can be easily shared:
module.exports = ({vendorVersion}) => {
const {exclude, include, dependencies} = getVendorConfigByVersion(vendorVersion);
return {
externals: [
dependencies.externals,
function (_, request, callback) {
if (exclude && checkIfPathMatches(request, exclude) || include && !checkIfPathMatches(request, include)) {
return callback();
}
const pattern = dependencies.patterns?.find(({ regex }) => regex.test(request));
if (pattern) {
const exposedImport = pattern.handler(request);
return callback(null, {
root: exposedImport,
commonjs: exposedImport,
commonjs2: exposedImport,
amd: exposedImport,
});
}
callback();
},
],
};
}
This configuration allows us to control the versioning of dependencies, enabling each app to have its own vendor bundle. It caters to various use cases:
Some apps may use different UI rendering frameworks, such as Angular or React, with their own set of transitional dependencies (this is one of the beauties of having micro-frontend architecture). For example:
{
'react-1.0': {
externals: {
"react": "react",
"react-dom": "reactDom"
}
},
'angular-1.0': {
patterns: [{
regex: /^@angular\//,
handler(path) {
return ['ng', camelCase(path.replace(/^@angular\//, ''))]
}
}]
}
}
Suppose all your apps use React.js, but you want to use the latest version in a newly created micro-frontend app. You can define the following configuration:
{
'react-16.0': {
externals: {
"react": "react",
"react-dom": "reactDom"
}
},
'react-18.0': {
externals: {
"react": "react@18",
"react-dom": "reactDom@18"
}
},
}
However, managing this config might becomes tricky if you want to include another library having React as its externalized dependency (let’s say UI components library) — React will not be happy when running 2 different versions in the same app. If you have control over the library, it is possible to create a new version that aligns with the desired dependencies. But in cases where the UI library is owned by a different team or organization (e.g., open-source), you might need to ensure this library exposes a build that does not have React imports externalized.
Additionally, the shared Webpack config can include other features such as:
A plugin to generate a manifest file and unified output. The appName
, which represents the micro-frontend key mentioned earlier, allows direct access to each micro-frontend app via the window scope (e.g., window.PaymentsApp.index
). Having this quick lookup mechanism will help our micro-frontend loader to resolve app assets without need to do network roundtrips.
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
return {
entry: {
index: path.join(sourcePath, `index.tsx`)
},
output: {
libraryTarget: 'umd',
library: [`${appName}`, '[name]'],
filename: '[hash]/[name].js',
path: 'dist',
},
plugins: [
new WebpackManifestPlugin({
fileName: 'manifest.json',
seed: Date.now(),
publicPath: publicPath,
}),
]
}
A plugin to generate an import map for vendor dependencies. While this example is provided for inspiration, it may require a custom plugin to handle bundle versioning effectively, especially when dealing with import-map scopes. For the use case when you might have to maintain multiple versions of React (see example above), import-map configuration might look like this:
{
"imports": {
"react": "https://unpkg.com/react@16/react.production.min.js",
// ensure your bundler ouputs alias for react using react@{version} format
"react@16": "https://unpkg.com/react@16/react.production.min.js",
"react@18": "https://unpkg.com/react@18/react.production.min.js"
}
}
// the same example using the scopes
{
"imports": {
"react": "https://unpkg.com/react@16/react.production.min.js"
},
"scopes": {
// activated when trying to resolve react external dependency
// from https://mywebsite.com/my-new-unicorn-app URL
"/my-new-unicorn-app/": {
"react": "https://unpkg.com/react@18/react.production.min.js"
}
}
}
A shared set of rules to handle different file types such as CSS, SCSS, JS, TS, and more.
Ideally, the provided configuration should require minimal enhancement by the consumer. This ensures that every tenant in your micro-frontend architecture follows the same build pattern, promoting consistency and simplifying maintenance.
Even though you might never need to run more than one app on your local machine, sometimes you might need to ensure that cross-app integration is working as expected before deploying it to Pre-Prod and Prod environments. One of the options you have is to run every app in its own terminal but it might be not the best developer experience (I call this a “command hell” — when you need to remember which commands to use to launch a specific app). What you can do instead is to have CLI commands that will start micro-frontends based on the configuration.
Here is a simplified example of how it can be done using webpack
CLI and express
middleware:
function startApp(config) {
const compiler = webpack(config);
// https://github.com/webpack-contrib/webpack-hot-middleware
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
})
);
//
app.use(
webpackHotMiddleware(compiler, {
name: config.name,
path: `/${config.name}_hot`,
heartbeat: config.updateFreq || 2000,
})
);
}
function start(config) {
const { port, containerConfig, apps } = config;
const app = express();
// start container
startApp(containerConfig);
// start micro-apps you need
apps.forEach(app => {
// here you might want to resolve the config dynamically based on the app directory and fallback to some defaults
const appConfig = resolveWebpackConfig(app);
startApp(appConfig);
});
// add more middlewares you want
// this will start HTTP server listening on port you provided (investigate how to do HTTPS)
app.listen(port, () => {
console.log('Started');
});
}
In your micro-frontend architecture, it may be advantageous to provide shared configuration options that teams can leverage as best practice sources. While this is optional and depends on your organizational structure, it can promote consistency across the system. Here are some examples of shared configuration options:
In Part 2 of this article, we have explored the implementation details of a micro-frontend architecture and discussed the key components and tools involved. The Micro-Frontend Toolkit, with its comprehensive set of APIs and utilities, simplifies the development and integration of micro-frontends. By leveraging the toolkit, developers can efficiently orchestrate and manage their micro-frontends, ensuring a seamless user experience and enabling independent development and deployment.
The micro-frontend loader, a vital component of the architecture, handles the downloading and bootstrapping of distributed applications in the browser’s runtime. Its caching mechanisms, network request strategies, and resilience to failures contribute to optimized loading and enhanced reliability. This results in improved performance and a robust user interface.
The bundler, exemplified through the Webpack configuration, provides a shared build process for all micro-frontends. It allows for efficient versioning of dependencies, controls the externalization of libraries, and generates manifest files and import maps. This standardized approach streamlines the development workflow, promotes consistency, and facilitates maintenance across multiple micro-frontends.
Furthermore, we highlighted the importance of shared configurations in a micro-frontend architecture. By establishing shared configurations such as Browserlist, ESLint, Prettier, and Jest, organizations can enforce coding standards, ensure consistent code formatting, and enhance testing practices. These shared configurations contribute to code quality, collaboration, and maintainability.
Finally, we discussed the local development CLI (distributed dev server), which provides a convenient and efficient way to run and test micro-frontends during local development. By utilizing CLI commands, developers can easily start and manage individual micro-frontends, simplifying the testing and integration process.
By leveraging these tools, utilities, and shared configurations, organizations can successfully implement and manage a micro-frontend architecture. The modular and scalable nature of micro-frontends, combined with the capabilities offered by the Micro-Frontend Toolkit, empowers development teams to build complex frontend systems with greater flexibility, maintainability, and autonomy.
Originally published at https://thesametech.com on June 27, 2023.
You can also follow me on Twitter and connect on LinkedIn to get notifications about new posts!