Module Federation - Typescript and Zod
- Published on
- Updated on
In this post we will explore an example of Module Federation with Typescript and Zod schema validation for events.
Download or browse the example project at this commit to follow along.
- Background
- Design Goals
- Implementation
- Checkout Remote Application
- Host Application
- Running the Application(s)
- Local Development with Local Remote
- Local Development with Remote on live CDN
- Conclusion
- Considerations
Background
- Module Federation is a webpack plugin that enables “static module bundling” for sharing code at runtime in a distributed fashion. We’ll specifically be using the new FederatedTypesPlugin that wraps the ModuleFederationPlugin under the hood and provides additional Typescript support on top.
- Typescript is a Javascript meta-language with many benefits.
- Zod is schema validation library with first-class support for Typescript.
Design Goals
When it comes to federation there is the concept of host and remote.
The host application(s) consume remote modules by fetching/loading static bundles at runtime. On the surface this can be seen as risky(er) than downloading modules via package.json the traditional npm packaging way. Our goal is to mitigate these inherent risks by:
- Enabling Typescript integration at build time with the
@module-federation/typescript
package’s FederatedTypesPlugin. - Using @rocket-science/event-client (experimental) for communication between host and remotes via DOM events with built-in support for typescript. This is in place of passing props which involves wiring data directly into the component. Note: you could also just use CustomEvents to do this, although this event-client is built to help do this with Typescript support.
- Using Zod to parse event payloads to ensure data compatibility.
- Adhering to the rules of semantic versioning when publishing our remote applications.
Implementation
The example project repository contains both the host-app and checkout applications but in practice these two applications could be in separate repositories or in a monorepo with team ownership boundaries.
Checkout Remote Application
We’ll start with the checkout remote application.
The first thing we do is define an interface for our Cart component. This interface should be treated as the public API and the rules of semantic versioning should be applied to it. The EventClient
from @rocket-science/event-client
accepts two type parameters:
Listeners
: all events that will be listened for (window.addEventListener
under the hood).Emitters
: all events that will be emitted (window.dispatchEvent
under the hood).
//...
export type Listeners = {
addItemToCart: Event<Item>
removeItemFromCart: Event<Item>
}
export type Emitters = {
itemAddedToCart: Event<Item>
itemRemovedFromCart: Event<Item>
}
We’ll also be using Zod to define an object schema for our events’ payloads. We want to validate that event payloads adhere to our schema since we can’t trust that consumers (hosts) will always send us a correct payload. This is one of the key strategies we’re using to mitigate problems that might arise at runtime.
import { z } from 'zod'
import type { Event } from '@rocket-science/event-client'
export const ItemSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
price: z.number(),
})
export type Item = z.infer<typeof ItemSchema>
//...
So here we have the full file that contains the interface for our remote Cart component.
checkout/components/Cart/Cart.schema.ts
import { z } from 'zod'
import type { Event } from '@rocket-science/event-client'
export const ItemSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
price: z.number(),
})
export type Item = z.infer<typeof ItemSchema>
export type Listeners = {
addItemToCart: Event<Item>
removeItemFromCart: Event<Item>
}
export type Emitters = {
itemAddedToCart: Event<Item>
itemRemovedFromCart: Event<Item>
}
The Cart component should store the state of current items in the cart and listen to events for adding and removing items from state. Notice we’re importing Listeners
and Emitters
from our schema file that we use as type parameters in the event client, as well as our ItemSchema
that we pass as an argument to the eventClient.on
methods. See the EventClient docs full API.
checkout/src/components/Cart/Cart.tsx
import React, { useState, useEffect } from 'react'
import styled from 'styled-components'
import { EventsClient } from '@rocket-science/event-client'
import { Button } from '../Button'
import {
Listeners as CartListeners,
Item,
ItemSchema,
Emitters as CartEmitters,
} from './Cart.schema'
const eventsClient = new EventsClient<CartListeners, CartEmitters>()
export const Cart = () => {
const [items, setItems] = useState<Item[]>([])
const handleRemoveButtonClick = (item: Item) => {
eventsClient.invoke('removeItemFromCart', item)
}
const calculateTotal = (items: Item[]) => {
let sum = 0.0
items.forEach((item) => (sum += item.price))
return parseFloat(sum.toString()).toFixed(2)
}
useEffect(() => {
eventsClient.on(
'addItemToCart',
'addItemToState',
({ detail, error }) => {
if (error) {
console.error(error)
} else {
setItems((current) => [detail, ...current])
eventsClient.emit('itemAddedToCart', detail)
}
},
ItemSchema
)
eventsClient.on(
'removeItemFromCart',
'removeItemFromState',
({ detail, error }) => {
if (error) {
console.error(error)
} else {
setItems((current) => {
const itemIndex = current.findIndex((itemSearched) => itemSearched.id === detail.id)
current.splice(itemIndex, 1)
return [...current]
})
eventsClient.emit('itemRemovedFromCart', detail)
}
},
ItemSchema
)
return () => {
eventsClient.removeAll()
}
}, [])
return (
<CartWrapper>
<div className="cart-header">
<span className="cart-header-title">cart 🛒</span>
</div>
<ul className="item-list">
{items?.length > 0 &&
items.map((item, index) => (
<li key={item.id + '-' + index} className="item-card">
<div className="item-name-desc">
<span className="item-name">{item.name}</span>
<span className="item-desc">{item.description}</span>
</div>
<div className="item-price">
<span>{'$' + item.price}</span>
<Button handleClick={() => handleRemoveButtonClick(item)} text="remove" />
</div>
</li>
))}
</ul>
<div className="cart-footer">
<span className="price-total">{'$' + calculateTotal(items)}</span>
<Button handleClick={() => console.log(`checking out...`)} text="checkout"></Button>
</div>
</CartWrapper>
)
}
const CartWrapper = styled.div`
background-color: white;
border: solid 1px;
padding: 8px;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
.cart-header {
border-bottom: solid 1px;
padding-bottom: 8px;
> .cart-header-title {
text-transform: uppercase;
font-weight: bold;
}
}
.item-list {
list-style: none;
padding: 0;
margin: 0;
height: 100%;
overflow-y: scroll;
}
.item-card {
display: flex;
width: 100%;
justify-content: space-between;
padding: 8px 0;
}
.item-name-desc {
display: flex;
width: 75%;
flex-direction: column;
> .item-name {
font-weight: bold;
}
}
.item-price {
display: flex;
flex-direction: column;
justify-items: end;
}
.cart-footer {
display: flex;
justify-content: space-between;
> .price-total {
min-width: 50%;
}
}
`
Now that we have a schema defined and a Cart component built for our remote app, we need a webpack configuration that will allow us to expose our Cart as a federated component that can be consumed at runtime.
A new plugin in the module federation arsenal is the FederatedTypesPlugin.
checkout/webpack.config.js
const path = require('path')
const { camelCase } = require('camel-case')
const { merge } = require('webpack-merge')
const { FederatedTypesPlugin } = require('@module-federation/typescript')
const pkg = require('./package.json')
const name = camelCase(pkg.name)
const deps = require('./package.json').dependencies
const baseConfig = {
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.json', '.md'],
},
module: {
rules: [
{
test: /\.m?js/,
type: 'javascript/auto',
resolve: {
fullySpecified: false,
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.jsx$/,
loader: 'esbuild-loader',
options: {
loader: 'jsx',
target: 'es2015',
},
},
{
test: /\.tsx?$/,
loader: 'esbuild-loader',
options: {
loader: 'tsx',
target: 'es2015',
},
},
{
test: /\.json$/,
loader: 'json-loader',
},
],
},
}
const federationConfig = {
name,
filename: 'remote-entry.js',
remotes: {},
exposes: {
'./Cart': './src/components/Cart',
},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'styled-components': {
singleton: true,
requiredVersion: deps['styled-components'],
},
'@rocket-science/event-client': {
singleton: true,
requiredVersion: deps['@rocket-science/event-client'],
},
'@zod': {
singleton: true,
requiredVersion: deps['zod'],
},
},
}
const browserConfig = {
output: {
path: path.resolve('./dist/browser'),
},
plugins: [
new FederatedTypesPlugin({
federationConfig,
}),
],
}
module.exports = [merge(baseConfig, browserConfig)]
Awesome! Now that we have our webpack configuration setup, we can move over to the host application that will consume the Cart component.
Host Application
For the host application, let’s start off with it’s webpack configuration.
host-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
const { camelCase } = require('camel-case')
const { FederatedTypesPlugin } = require('@module-federation/typescript')
const federatedRemotes = {
'@ahowardtech/checkout': '^2.0.0',
}
const localRemotes = {
'@ahowardtech/checkout': `${camelCase(
'@ahowardtech/checkout'
)}@http://localhost:3001/browser/remote-entry.js`,
}
const deps = {
...federatedRemotes,
...require('./package.json').dependencies,
}
const unpkgRemote = (name) =>
`${camelCase(name)}@https://unpkg.com/${name}@${deps[name]}/dist/browser/remote-entry.js`
const remotes = Object.keys(federatedRemotes).reduce(
(remotes, lib) => ({
...remotes,
[lib]: unpkgRemote(lib),
}),
{}
)
const federationConfig = {
name: 'host-app',
filename: 'remote-entry.js',
remotes: process.env.LOCAL_MODULES === 'true' ? localRemotes : remotes,
exposes: {},
shared: {
...deps,
react: {
singleton: true,
requiredVersion: deps.react,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
'styled-components': {
singleton: true,
requiredVersion: deps['styled-components'],
},
'@rocket-science/event-client': {
singleton: true,
requiredVersion: deps['@rocket-science/event-client'],
},
'@zod': {
singleton: true,
requiredVersion: deps['zod'],
},
},
}
module.exports = {
entry: './src/index',
mode: 'development',
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
port: 3000,
},
output: {
publicPath: 'auto',
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
module: {
rules: [
{
test: /bootstrap\.js$/,
loader: 'bundle-loader',
options: {
lazy: true,
},
},
{
test: /\.jsx?$/,
loader: 'esbuild-loader',
options: {
loader: 'jsx',
target: 'es2015',
},
},
{
test: /\.tsx?$/,
loader: 'esbuild-loader',
options: {
loader: 'tsx',
target: 'es2015',
},
},
],
},
infrastructureLogging: {
level: 'log',
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new FederatedTypesPlugin({
federationConfig,
}),
],
}
When we run our application, the FederatedTypesPlugin
is going to download all of our remotes’ types into a local directory in our host application - that directory being ./@mf-types by default. To make sure typescript can correctly resolve our remote imports, we need to add some key things to our typescript configuration file.
The two important compiler options are "baseUrl"
and "paths"
.
"baseUrl": ".",
"paths": {
"@ahowardtech/checkout/*": [
"@mf-types/@ahowardtech/*",
"@mf-types/@ahowardtech/checkout/_types/*",
"@mf-types/@ahowardtech/checkout/_types/Cart/*"
]
},
Here is the host app’s full typescript configuration file.
host-app/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ahowardtech/checkout/*": [
"@mf-types/@ahowardtech/*",
"@mf-types/@ahowardtech/checkout/_types/*",
"@mf-types/@ahowardtech/checkout/_types/Cart/*"
]
},
"jsx": "react",
"module": "esnext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"declaration": true,
"noImplicitAny": false,
"noUnusedLocals": false,
"removeComments": true,
"target": "es6",
"sourceMap": true,
"allowJs": true,
"outDir": "dist",
"strict": true,
"lib": ["es7", "dom"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Now for the fun part - integrating the remote Cart component into the host’s App component.
Here are our imports. The FederatedTypesPlugin
has now allowed us to import types from our @ahoward/checkout
remote application, as well as make Typescript happy in our lazy import of the Cart component.
import React, { useEffect } from 'react'
import styled from 'styled-components'
import { ItemList } from './ItemList'
import { items } from './items'
import { EventsClient } from '@rocket-science/event-client'
import {
Item,
Listeners as CartListeners,
Emitters as CartEmitters,
} from '@ahowardtech/checkout/Cart.schema'
const RemoteCart = React.lazy(() => import('@ahowardtech/checkout/Cart'))
Like how we did in our remote Cart component, we instantiate a new EventsClient, but this time we pass the Cart’s Emitters
as the host-app’s client Listeners
, and the Cart’s Listeners
as the host-app’s client Emitters
. If you think about it, this kind of makes sense. Our host will be emitting events that our remote is listening for, and it may also want to listen for events that our remote emits.
Note: in a situation where you have more than one remote and/or you want to define additional Listeners
and Emitters
specific to the host - you can use Intersection Types to do this. See the docs here for the EventClient and how to do this.
const eventsClient = new EventsClient<CartEmitters, CartListeners>()
We also need a function to handle click’s for the add to cart button.
const handleClick = ({ name, description, price }: Item) => {
eventsClient.emit('addItemToCart', {
id: Date.now(), // used Date.now() simply for list key uniqueness
name,
description,
price,
})
}
Here is really where we can illustrate the power of the client consuming the remote’s Listeners
. For the remote’s addItemToCart
event, we get type definitions for the payload ctx
(context) that our Cart is expecting.
Our host is also interested in when an item has successfully been added to the Cart. To do that, we setup a listener in the host-app for the itemAddedToCart
event.
const App = () => {
useEffect(() => {
eventsClient.on("itemAddedToCart", "logDetails", ({ detail }) => {
console.log(detail);
});
return () => {
eventsClient.removeAll();
};
}, []);
return (
{/...}
);
};
Like emitting an event, we also get handy type information when we add a listener for one of the events that the Cart emits.
Here is the full App component file for our host-app.
host-app/src/App.tsx
import React, { useEffect } from 'react'
import styled from 'styled-components'
import { ItemList } from './ItemList'
import { items } from './items'
import { EventsClient } from '@rocket-science/event-client'
import {
Item,
Listeners as CartListeners,
Emitters as CartEmitters,
} from '@ahowardtech/checkout/Cart.schema'
const RemoteCart = React.lazy(() => import('@ahowardtech/checkout/Cart'))
const eventsClient = new EventsClient<CartEmitters, CartListeners>()
const handleClick = ({ name, description, price }: Item) => {
eventsClient.emit('addItemToCart', {
id: Date.now(),
name,
description,
price,
})
}
const App = () => {
useEffect(() => {
eventsClient.on('itemAddedToCart', 'logDetails', ({ detail }) => {
console.log(detail)
})
return () => {
eventsClient.removeAll()
}
}, [])
return (
<AppWrapper>
<h1>Ecomm Store</h1>
<div className="app-content">
<ItemList items={items} handleAddToCart={handleClick} />
<React.Suspense fallback="loading cart">
<RemoteCart></RemoteCart>
</React.Suspense>
</div>
</AppWrapper>
)
}
export default App
const AppWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
width: 100vw;
height: 100vh;
.app-content {
display: flex;
width: 700px;
height: 400px;
}
`
Running the Application(s)
Local Development with Local Remote
If we want to run the remote checkout application and pull in the Cart from our local machine we can run these applications side-by-side.
In terminal one (in checkout app)
yarn build && yarn federate
Great, our checkout application is up and running locally!
If we go to http://localhost:3001, in the dist/browser we’re able to see some key files/directories:
- remote-entry.js: the static bundle generated by the module federation plugin (which is wrapped by the
FederatedTypesPlugin
). - @mf-types: our remote’s types generated by the
FederatedTypesPlugin
. - __types_index.json: an index of our remote’s .d.ts files.
In terminal two (in host-app app)
yarn dev:local
Since we enabled infrastructure
logging in our host’s webpack configuration, we can see the FederatedTypesPlugin
downloading types from our remote checkout application.
Our host-app is now up and running which we can see if we open up http://localhost:3000 in the browser.
We can have proof that Cart is being pulled in locally by checking the network tab in chrome developer tools.
Local Development with Remote on live CDN
We’ve already published versions of the remote checkout application to npm, which makes it available on the unpkg content delivery network.
We can simply run the host-app with a slightly different command to pull in the remote Cart component from the CDN.
yarn dev
Again we can go to the browser at http://localhost:3000 to see our Cart being pulled in again, except this time from the CDN. We can prove it’s coming from the CDN again via chrome developer tools.
In the likely scenario where one team is responsible for the host application while a different team is responsible for the checkout application, every time the host application team runs their application locally, they will be downloading the latest type definitions from the CDN.
Conclusion
THIS.IS.AWESOME!
We’ve been able to:
- Use the FederatedTypesPlugin to pull in a remote component from a different application, and we’ve been able to do so with type safety.
- Emit and listen for events from the remote component, and we’ve been able to do so with type safety.
This is a huge win for teams that are working on a micro-frontend architecture!
Considerations
- The
EventClient
adds and removes event listeners from the window which is only available on the client. You can still use this approach on the server, but events should be emitted and listened for on the client - for example in auseEffect
in React. - Styled-components (css-in-js) is not everyone's cup of tea. You can use other css libraries you want, or you can use plain old css.
- Zod add's additional load to the page, but it may be worth it for the safety it provides.
- Validating all events with Zod may impact performance, but again - it may be worth it for the safety it provides.
- Storybook is included in the checkout application for local development, and while it helps with development and showcasing the component, it is not necessary for the application to run. Storybook is also not everyone's cup of tea.
- The host application must be run at least once so that types are downloaded from remote. This is not ideal, but it is a limitation of the FederatedTypesPlugin.
- Module federation adds complexity, but it may be worth it for larger teams that are working on a micro-frontend architecture.
- Adhering to the rules of semantic versioning when developing remotes is vital. With this approach, all minor and patch version updates are automatically pulled in by the host application. This means backwards compatibility is a must. Setting up appropriate testing and release processes is important to ensure that breaking changes are not accidentally released.