DEV Community

Ildar Sharafeev
Ildar Sharafeev

Posted on • Originally published at thesametech.com on

Micro-frontend Migration Journey — Part 2: Toolkit

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.)

Deployment kit utility

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;
Enter fullscreen mode Exit fullscreen mode
  • app  — reference to CDK application
  • pipeline  — definition of CDK pipeline
  • packageName  — source package name of micro-frontend app
  • bucketName  — name of the S3 bucket where app bundle should be deployed to
  • artifactsRoot  — root folder in the source package to discover the bundle

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);
   }
}
Enter fullscreen mode Exit fullscreen mode

Let’s examine the steps involved:

  • We create a secure S3 bucket with no public read access and CORS rules that only allow access from the *.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).
  • We retrieve artifacts from the artifactsRoot, which represents the build directory containing the manifest.json file and the folder with static assets.
  • We deploy the artifacts to the S3 bucket. We need to create two instances of 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.
  • Lastly, we create a CloudFront distribution that targets the S3 bucket location specified by the 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'
});
Enter fullscreen mode Exit fullscreen mode

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.

Micro-frontend loader

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];
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. If the application bundle has been loaded before, no action is required. The loader will retrieve it from the global window scope.
  2. Otherwise, it attempts to discover the corresponding manifest file. There are two scenarios:

    • If the application has been loaded before, the manifest metadata will be stored in the cache (browser memory or local storage). If found, the loader uses the “stale-while-revalidate” technique: it sends a network request to fetch the latest manifest file from the CDN while passing the entry file location from the manifest metadata downstream. This step ensures resilience to failures, as even in the worst-case scenario, the user will still use the previous version of the bundle, and the next page refresh will resolve the latest manifest.
    • If it’s the first load of the application (no manifest in the cache), the loader attempts a network request to the CDN to fetch it. If successful, it saves the manifest to the cache and the browser’s local storage with a meaningful TTL (time to live) value. If unsuccessful, the load fails.
  3. 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;
    });
};
Enter fullscreen mode Exit fullscreen mode

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')
        });
Enter fullscreen mode Exit fullscreen mode

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.

Bundler

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();
      },
    ],
  };
}
Enter fullscreen mode Exit fullscreen mode

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\//, ''))]
        }
     }]
   }
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
      }
   },
}
Enter fullscreen mode Exit fullscreen mode

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,
      }),

   ]
}
Enter fullscreen mode Exit fullscreen mode
  • 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",
    "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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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.

Distributed Dev Server

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');
    });
}
Enter fullscreen mode Exit fullscreen mode

Shared configuration

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:

  • Browserlist config: this config I highly recommend having as a mandatory config shared with all tenants in your architecture since for the end user your system is a single look-and-feel UI and it would make sense to have this UI support the same list of browsers. You can look at it as some sort of SLA your users should be aware of (see an example for AWS Console). Here is the link to the tutorial on how to create a shareable browserlist config: https://github.com/browserslist/browserslist#shareable-configs
  • ESLint Config: Sharing an ESLint config helps maintain code consistency and reduces the likelihood of bugs. You can create your own shareable ESLint config that teams can use as a baseline. Refer to the ESLint documentation for instructions on creating and using shareable configs: https://eslint.org/docs/latest/extend/shareable-configs
  • Prettier Config: To ensure consistent code formatting across different projects, a shared Prettier config can be provided. This helps maintain a unified style across the codebase.
  • Jest Config: For testing standards, you can define a shared Jest config. Jest has recently introduced project-level configuration, which is especially useful if you are using a monorepo for your code. This allows you to define different runner configurations for each app.

Conclusion

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!

Top comments (0)