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

🍱 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:
In short, micro frontends are all about slicing up big and scary things into smaller, more manageable pieces, and then being explicit about the dependencies between them. Our technology choices, our codebases, our teams, and our release processes should all be able to operate and evolve independently of each other, without excessive coordination.
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.
How to plug an app written using one framework into another, incompatible app, that uses different framework? You need an abstract orchestrator!
Before we start, let’s first figure out the basic terms:
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.
Consider this screenshot, taken from the demo:
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).
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
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); });
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); });
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.
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 constsync= 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:
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.
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.