Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions examples/module-federation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Module Federation example

End-to-end Module Federation setup powered by `@gravity-ui/app-builder`: one
host shell with route-driven navigation in the header and three independent
microfrontends, each built and served by its own `app-builder` instance.

## Layout

```
examples/module-federation/
├── package.json # workspace root with run-all scripts
├── tsconfig.json # shared TS settings
├── host/ # root application (port 3000)
├── mf-products/ # microfrontend (port 3001) → /products
├── mf-cart/ # microfrontend (port 3002) → /cart
└── mf-profile/ # microfrontend (port 3003) → /profile
```

Every app is a self-contained `app-builder` project — its own
`app-builder.config.ts`, `src/ui/tsconfig.json`,
`src/ui/entries/<federation-name>.tsx`, `src/ui/App.tsx` and
`public/index.html`. The workspace `package.json` wires them together via
`npm-run-all` scripts.

> ⚠ Important: `app-builder` requires the entry file to be named exactly the
> same as the federation name. That is why the entry files are
> `host.tsx`, `mf_products.tsx`, `mf_cart.tsx` and `mf_profile.tsx`
> (not `main.tsx`).

## How the federation is configured

Each microfrontend `exposes: { './App': './src/ui/App' }` under a unique
federation name (`mf_products`, `mf_cart`, `mf_profile`).

The host declares them via `originalRemotes` so the URLs are explicit and the
dev environment does not depend on a discovery mechanism:

```ts
moduleFederation: {
name: 'host',
originalRemotes: {
mf_products:
'mf_products@http://localhost:3001/build/mf_products/mf-manifest.json',
mf_cart: 'mf_cart@http://localhost:3002/build/mf_cart/mf-manifest.json',
mf_profile:
'mf_profile@http://localhost:3003/build/mf_profile/mf-manifest.json',
},
shared: {
react: {singleton: true, requiredVersion: false, eager: true},
'react-dom': {singleton: true, requiredVersion: false, eager: true},
'react-router': {singleton: true, requiredVersion: false, eager: true},
},
},
```

Routes are switched inside `host/src/ui/App.tsx` with `react-router`:

```tsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/*" element={<ProductsApp />} />
<Route path="/cart/*" element={<CartApp />} />
<Route path="/profile/*" element={<ProfileApp />} />
</Routes>
```

`ProductsApp`, `CartApp` and `ProfileApp` are loaded with `React.lazy` from
their federated entries (`import('mf_products/App')` etc.). Ambient module
declarations for these specifiers live in `host/src/ui/remotes.d.ts`.

`HashRouter` is used to keep the example dependency-free of any custom
dev-server history fallback — open `http://localhost:3000/#/products` and you
will see the products remote rendered inside the host.

### `index.html`

`app-builder` does not ship its own HTML generation, so each app appends an
`HtmlRspackPlugin` through the `client.rspack` hook. The plugin reads
`public/index.html` and writes the result to `dist/public/index.html` (i.e.
two levels up from the MF asset folder).

To make sure the generated HTML is available from the dev server (which
otherwise serves bundles from memory), each `devServer` config opts the
HTML file into disk emission:

```ts
devServer: {
port: 3001,
writeToDisk: (target) => target.endsWith('index.html'),
},
```

## Install

```sh
pnpm install
```

## Run all four apps in dev

```sh
pnpm dev
```

The remotes start before the host (host depends on their manifests). Open:

| App | URL |
| ----------- | ------------------------------- |
| host | http://localhost:3000 |
| mf-products | http://localhost:3001 (preview) |
| mf-cart | http://localhost:3002 (preview) |
| mf-profile | http://localhost:3003 (preview) |

You can also start them individually:

```sh
pnpm dev:products
pnpm dev:cart
pnpm dev:profile
pnpm dev:host
```

## Production build

```sh
pnpm build
```

Outputs land in each app's `dist/public/`:

```
host/dist/public/index.html
host/dist/public/build/host/...
mf-products/dist/public/build/mf_products/mf-manifest.json
mf-cart/dist/public/build/mf_cart/mf-manifest.json
mf-profile/dist/public/build/mf_profile/mf-manifest.json
```

For a quick preview of the production bundle:

```sh
pnpm preview:products
pnpm preview:cart
pnpm preview:profile
pnpm preview:host
```

(`preview:*` uses `npx serve` so all four servers serve their `dist/public/`
folders on the same ports as in dev.)
46 changes: 46 additions & 0 deletions examples/module-federation/host/app-builder.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {defineConfig} from '@gravity-ui/app-builder';
import {rspack} from '@rspack/core';

const HOST_NAME = 'host';
const HOST_PORT = 3000;

const REMOTES = {
mf_products: 'http://localhost:3001/build/mf_products/mf-manifest.json',
mf_cart: 'http://localhost:3002/build/mf_cart/mf-manifest.json',
mf_profile: 'http://localhost:3003/build/mf_profile/mf-manifest.json',
};

export default defineConfig({
target: 'client',
client: {
bundler: 'rspack',
devServer: {
port: HOST_PORT,
writeToDisk: (target) => target.endsWith('index.html'),
},
moduleFederation: {
name: HOST_NAME,
originalRemotes: Object.fromEntries(
Object.entries(REMOTES).map(([name, url]) => [name, `${name}@${url}`]),
),
shared: {
react: {singleton: true, requiredVersion: false, eager: true},
'react-dom': {singleton: true, requiredVersion: false, eager: true},
'react-router': {singleton: true, requiredVersion: false, eager: true},
},
},
rspack: (config) => {
config.plugins = config.plugins ?? [];

Check warning on line 33 in examples/module-federation/host/app-builder.config.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Assignment to property of function parameter 'config'
config.plugins.push(
new rspack.HtmlRspackPlugin({
filename: '../../index.html',
template: 'public/index.html',
inject: 'body',
scriptLoading: 'defer',
publicPath: `/build/${HOST_NAME}/`,
}),
);
return config;
},
},
});
11 changes: 11 additions & 0 deletions examples/module-federation/host/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Module Federation · Host</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
27 changes: 27 additions & 0 deletions examples/module-federation/host/src/ui/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {Suspense, lazy} from 'react';
import {Route, Routes} from 'react-router';

import {Header} from './Header';
import {Home} from './pages/Home';

const ProductsApp = lazy(() => import('mf_products/App'));
const CartApp = lazy(() => import('mf_cart/App'));
const ProfileApp = lazy(() => import('mf_profile/App'));

export function App() {
return (
<div className="app-shell">
<Header />
<main className="app-shell__main">
<Suspense fallback={<p className="app-shell__loader">Loading microfrontend…</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/*" element={<ProductsApp />} />
<Route path="/cart/*" element={<CartApp />} />
<Route path="/profile/*" element={<ProfileApp />} />
</Routes>
</Suspense>
</main>
</div>
);
}
30 changes: 30 additions & 0 deletions examples/module-federation/host/src/ui/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {NavLink} from 'react-router';

const NAV_ITEMS = [
{to: '/', label: 'Home', end: true},
{to: '/products', label: 'Products'},
{to: '/cart', label: 'Cart'},
{to: '/profile', label: 'Profile'},
];

export function Header() {
return (
<header className="app-header">
<div className="app-header__brand">🛰 Module Federation Demo</div>
<nav className="app-header__nav">
{NAV_ITEMS.map(({to, label, end}) => (
<NavLink
key={to}
to={to}
end={end}
className={({isActive}) =>
`app-header__link${isActive ? ' app-header__link--active' : ''}`
}
>
{label}
</NavLink>
))}
</nav>
</header>
);
}
17 changes: 17 additions & 0 deletions examples/module-federation/host/src/ui/entries/host.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {createRoot} from 'react-dom/client';
import {HashRouter} from 'react-router';

import {App} from '../App';
import '../styles.css';

const container = document.getElementById('root');

if (!container) {
throw new Error('Root container #root is missing in index.html');
}

createRoot(container).render(
<HashRouter>
<App />
</HashRouter>,
);
23 changes: 23 additions & 0 deletions examples/module-federation/host/src/ui/pages/Home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function Home() {
return (
<section className="home">
<h1>Welcome to the host shell</h1>
<p>
This page is rendered by the <code>host</code> app. Use the navigation above to load
one of three remote microfrontends. Each microfrontend is built and served
independently with <code>@gravity-ui/app-builder</code>.
</p>
<ul>
<li>
<code>mf-products</code> — http://localhost:3001
</li>
<li>
<code>mf-cart</code> — http://localhost:3002
</li>
<li>
<code>mf-profile</code> — http://localhost:3003
</li>
</ul>
</section>
);
}
14 changes: 14 additions & 0 deletions examples/module-federation/host/src/ui/remotes.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare module 'mf_products/App' {
const App: React.ComponentType;
export default App;
}

declare module 'mf_cart/App' {
const App: React.ComponentType;
export default App;
}

declare module 'mf_profile/App' {
const App: React.ComponentType;
export default App;
}
Loading
Loading