🍱 Web Apps: Micro Frontend framework with support of Module Federation2020-06-15

🍱 Web Apps: Micro Frontend framework with support of Module Federation

I would like to present a multi-framework, multi-bundle orchestration layer, supporting the oldest to the newest technologies of micro frontends. Framework leverages the power of Webpack to work hand in hand to coordinate and route multi-bundle applications without the overhead.

These days micro frontends are hot and trendy. Here’s how Martin Fowler defines them:

This approach becomes crucial when an application outgrows all reasonable limits — the build process is slow, every minor change results in minutes of compilation in watch mode, and the codebase contains thousands of modules and NPM dependencies. At this point, the support of this monster becomes too expensive.

The problem becomes even worse if the team decides to try a new, improved technology stack. You can attempt to gradually replace the old system with the new one. At RingCentral we’ve been through that: we attempted to refactor one monolith and replace it with another monolith. Both were rapidly growing and receiving addition of new features, so when we finished, the final monolith was enormous. At some point it became obvious that it’s time to divide it before it’s too late, so we introduced embeddable sub-apps, but each was developed by a different team and had its own interface to connect to the host, its own bus, and some of them were IFrames. This approach helped in the short term, but was suboptimal in terms of further scaling.

Before we start, let’s first figure out the basic terms:

Apps can’t talk directly to each other, but host wire them together

Apps can’t talk directly to each other, but host wire them together

  • A Host is an app that contains a bunch of smaller apps
  • A Child App, or just an App, is an IFrame App, a Global App, or a Web Component App which we embed in the Host or another Child App (which can act like a Host, too)
  • An Orchestrator: Web Apps Framework is an agent who helps them talk together

The Web Apps Framework

The Web Apps framework provides exactly this:

A framework-agnostic way to inject any app into any app, with a unified communication interface along with other perks.

Links to repository: https://github.com/ringcentral/web-apps and demo: https://ringcentral-web-apps.vercel.app.

Consider this screenshot, taken from the demo:

https://miro.medium.com/max/2000/1*CnGcyKW39uUJJjrY2R2zwg.png

Arrows are pointing at different apps, that are seamlessly presented together but can be deployed and developed separately. Two of them are IFrames, which is a useful approach in case you need to deal with legacy code, that could be written entirely without JavaScript, or an app that needs maximum isolation of CSS and code.

Of course, there are other frameworks that do similar things, but Web Apps is using a very elegant and minimalistic approach to loading and wrapping different kinds of apps, which provides a unified interface no matter which underlying technologies are being used: Angular, Vue, React, jQuery, no JS at all, you name it.

And Web Apps provides first class support for React, just point to the app’s URL and voila, it will be included in the DOM and all events and handlers will be wired automagically:

import { useApplication, eventType, useListenerEffect, dispatchEvent } from '@ringcentral/web-apps-host-react';

const Page = () => {
  const { Component, node } = useApplication({
    id: 'xxx',
    type: 'script',
    url: [
      'http://example.com/styles.css', // yep, with styles
      'http://example.com/bundle.js',
      'http://example.com/entry.js',
    ],
  });

  useListenerEffect(node, eventType.message, (message) => {
    alert(`message from app: ${message}`);
  });

  return (
    <>
      <Component someProp="someValue" />
      <button onClick={(e) => dispatchEvent(node, eventType.message, { foo: 'bar' })}>Send Message</button>
    </>
  );
};

The concept of this framework is to:

  • Load an App with certain type & URL (one or many)
  • Render the App on the page in any place
  • Wire events between Host and the App, including navigation events

Communication can be event-based or through props (or HTML attributes). Events with will be transmitted to any type of app, including IFrame, through the same interface, so the Host can swap them at any time and should not care what type of app it’s showing. The Host can even swap URL and type of an App dynamically, let’s say, based on location, and show different apps in the main content area (just like in the demo).

https://miro.medium.com/max/480/0*djmvUb3xosr3qCGt.gif

For other frameworks, Web Component helper can be used:

// somewhere in your app
import '@ringcentral/web-apps-host-web-component'; // then just add this
<web-app id="appId" url='["http://example.com"]' type="iframe" />;

How to define an app?

There are no special techniques to prepare apps for embedding; just a tiny wrapper, depending on the type of app:

  • Global Apps — a script that injects something into a given DOM node
  • Web Component Apps—loaded as script and wrapped in Custom Element, provides a good isolation, thanks to Shadow DOM and Shadow CSS
  • IFrame Apps — for extra security and isolation, depending on the degree of trust between host and app

Global Apps

Let’s say you have a React app that you’d like to embed. With Module Federation, the entry point will look like just a simple default export of a callback that will inject the app in a given node:

import React from "react";
import {render, unmountComponentAtNode} from "react-dom";
import {registerAppCallback} from "@ringcentral/web-apps-react";
import App from "./App";

export default (node) => {
    ReactDOM.render(<App foo={node.getAttribute('foo')} />, node);
    return () => ReactDOM.unmountComponentAtNode(node);
});

https://miro.medium.com/max/498/0*397-h5Fov8mypZRm.gif

We’ve worked with Module Federation (kudos to Zack Jackson) to make sure Web Apps have seamless integration.

With a bit of additional code using MutationObserver, all attributes can be synchronized from host to app.

If Module Federation is not available on the host inject your app into a given DOM Node, with a JSONP-style callback, as follows:

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { registerAppCallback } from '@ringcentral/web-apps-react';
import App from './App';
registerAppCallback('%appId%', (node) => {
  ReactDOM.render(<App foo={node.getAttribute('foo')} />, node);
  return () => ReactDOM.unmountComponentAtNode(node);
});

Web Component Apps

Another way is to wrap the app in a Web Component for better isolation of CSS styles using Shadow DOM and Shadow CSS, as follows:

import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { App } from './app';

const template = document.createElement('template');

template.innerHTML = `
    <style>/* shadow CSS */</style>
    <div class="container"></div>
`;

customElements.define(
  'web-app-%appId%',
  class extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
      this.shadowRoot.appendChild(document.importNode(template.content, true));
      this.mount = this.shadowRoot.querySelector('.container');
    }

    connectedCallback() {
      render(<App authtoken={this.getAttribute('authtoken')} node={this} />, this.mount);
    }

    disconnectedCallback() {
      unmountComponentAtNode(this);
    }
  },
);

With observable attributes you can sync them to React app as props.

IFrame Apps

Using IFrames does not require any code at all, but if you want to communicate just add this:

import { IFrameSync, dispatchEvent, eventType } from '@ringcentral/web-apps-sync-iframe';

export const sync = newIFrameSync({
  history: 'html5',
  id: 'iframe',
  origin: window.location,
});

// now you can listen and dispatch:
const node = sync.getEventTarget();
dispatchEvent(node, eventType.message, 'Hello from IFrame');
node.addEventListener(eventType.message, (message) => alert(message));

What about sharing?

Obviously, if each app begins to have its own build, it also carries all the common dependencies, like react or react-dom. This, at times, can be quite heavy. Luckily, there’s a fancy new technology that aims to solve this problem:

Webpack 5 Module Federation

The big win is that the Web Apps framework has out of the box support for Module Federation which allows sharing of modules between host and apps, and apps between other apps. This especially plays well with small footprint of the framework. Module Federation has many features including automatic healing, redundancy and proper versioning. You can just follow the example host and app:

// host's webpack.config.js
module.exports = {
    ..., // all the usual stuff
    plugins: [
        new ModuleFederationPlugin({
            name: 'web-app-host',
            library: {type: 'var', name: 'web-app-host'},
            shared: ['react', 'react-dom'],
        }),
    ]
};

// app's webpack.config.js
module.exports = {
    ..., // all the usual stuff
    plugins: [
        new ModuleFederationPlugin({
            name: 'web_app_federated',
            library: {type: 'var', name: 'web_app_federated'},
            filename: 'remoteEntry.js',
            exposes: {
                './index': './src/index',
            },
            shared: {
                'react-dom': 'react-dom',
                moment: '^2.24.0',
                react: 'react',
            },
        }),
    ]
};

And this is it! Now your host and app can share dependencies in a clean and transparent way.

https://miro.medium.com/max/480/0*AzYxH6zmIObJpHRG.gif

More about IFrames

Of course, one can just display an IFrame with a legacy app, no big deal. But big problems may occur when sub-apps are contained within IFrames and wish to send and receive messages from the host and show popups.

Messaging can be solved via PostMessage, but Web Apps simplifies and standardizes the interface, making sure there’s no flood of broadcast post messages and that only proper targets will receive them.

Dealing with the popups problem is a bit trickier:

Note the visual separation of app and host

Note the visual separation of app and host

… the gray backdrop behind the popup is contained within the IFrame, while the rest of the website has a white background, meh, ugly!

Can we tell the host that the IFrame’d app is showing a popup? What if the host wants the IFrame’d app to hide it?

This is exactly what the Web Apps Framework is doing: it makes sure the Host is aware when to show the backdrop and what color it should be. It can send an event to the IFrame’d App when it’s time to close the popup, so it looks seamless:

Backgrounds inside and outside of IFrame are the same

Backgrounds inside and outside of IFrame are the same

https://miro.medium.com/max/550/0*k3OkRloPx9379d2I.gif

Framework also supports synchronization of IFrame height and it’s contents, so that long texts in frames aren’t cut.

Quick Recap

Let me summarize the key features of the Web Apps Framework:

  • Module Federation support
  • Location synchronization between app and host
  • Ability to deep-link “app to app” or “app to host” or “host to app”
  • Consistent event-based interaction between apps and host
  • IFrame resizing based on the content of an IFrame
  • IFrame popup support
  • Maximum adherence to Web Standards
  • Written in TypeScript
  • React and Web Component host helpers
  • Unlimited nesting of apps within other apps, e.g. each app can become a host for more apps
  • Seamless navigation support

Check out the repository with examples: https://github.com/ringcentral/web-apps and demo: https://ringcentral-web-apps.vercel.app.

Kudos

Zack Jackson, Chris Van Rensburg and Anton Bulyonov