Module Federation - Typescript and Zod

Authors
  • avatar
    Name
    Austin Howard
Updated on
feature image

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

  • 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.
architecture diagram

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.

addItemToCart event type definitions in-editor

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.

itemAddedToCart event type definitions in-editor
event payload type definitions in-editor

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!

local server running

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.
local server 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.

webpack infrastructure logging enabled

Our host-app is now up and running which we can see if we open up http://localhost:3000 in the browser.

store host application

We can have proof that Cart is being pulled in locally by checking the network tab in chrome developer tools.

chrome devtools remote pulled from local server

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.

chrome devtools remote pulled from cdn

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 a useEffect 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.