From 2edac4ad4ca391c6a78dc2db4f44acc57bf24289 Mon Sep 17 00:00:00 2001 From: Daniil Gaponov Date: Sun, 31 May 2026 11:36:24 +0300 Subject: [PATCH] docs(examples): add module-federation example with host and 3 microfrontends Adds an end-to-end Module Federation playground under `examples/module-federation` showing how to drive a host shell with three independent microfrontends through `@gravity-ui/app-builder`'s `moduleFederation` config. Each microfrontend lives in its own subfolder with its own `app-builder.config.ts`, exposes a federated component and can also be opened standalone on its dedicated port. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/module-federation/README.md | 149 +++++++++++++++++ .../host/app-builder.config.ts | 46 ++++++ .../module-federation/host/public/index.html | 11 ++ .../module-federation/host/src/ui/App.tsx | 27 +++ .../module-federation/host/src/ui/Header.tsx | 30 ++++ .../host/src/ui/entries/host.tsx | 17 ++ .../host/src/ui/pages/Home.tsx | 23 +++ .../host/src/ui/remotes.d.ts | 14 ++ .../module-federation/host/src/ui/styles.css | 113 +++++++++++++ .../host/src/ui/tsconfig.json | 10 ++ .../mf-cart/app-builder.config.ts | 40 +++++ .../mf-cart/public/index.html | 11 ++ .../module-federation/mf-cart/src/ui/App.tsx | 57 +++++++ .../mf-cart/src/ui/entries/mf_cart.tsx | 19 +++ .../mf-cart/src/ui/tsconfig.json | 10 ++ .../mf-products/app-builder.config.ts | 40 +++++ .../mf-products/public/index.html | 11 ++ .../mf-products/src/ui/App.tsx | 53 ++++++ .../src/ui/entries/mf_products.tsx | 19 +++ .../mf-products/src/ui/tsconfig.json | 10 ++ .../mf-profile/app-builder.config.ts | 40 +++++ .../mf-profile/public/index.html | 11 ++ .../mf-profile/src/ui/App.tsx | 82 ++++++++++ .../mf-profile/src/ui/entries/mf_profile.tsx | 19 +++ .../mf-profile/src/ui/tsconfig.json | 10 ++ examples/module-federation/package.json | 38 +++++ examples/module-federation/tsconfig.json | 14 ++ pnpm-lock.yaml | 154 +++++++++++++----- 28 files changed, 1035 insertions(+), 43 deletions(-) create mode 100644 examples/module-federation/README.md create mode 100644 examples/module-federation/host/app-builder.config.ts create mode 100644 examples/module-federation/host/public/index.html create mode 100644 examples/module-federation/host/src/ui/App.tsx create mode 100644 examples/module-federation/host/src/ui/Header.tsx create mode 100644 examples/module-federation/host/src/ui/entries/host.tsx create mode 100644 examples/module-federation/host/src/ui/pages/Home.tsx create mode 100644 examples/module-federation/host/src/ui/remotes.d.ts create mode 100644 examples/module-federation/host/src/ui/styles.css create mode 100644 examples/module-federation/host/src/ui/tsconfig.json create mode 100644 examples/module-federation/mf-cart/app-builder.config.ts create mode 100644 examples/module-federation/mf-cart/public/index.html create mode 100644 examples/module-federation/mf-cart/src/ui/App.tsx create mode 100644 examples/module-federation/mf-cart/src/ui/entries/mf_cart.tsx create mode 100644 examples/module-federation/mf-cart/src/ui/tsconfig.json create mode 100644 examples/module-federation/mf-products/app-builder.config.ts create mode 100644 examples/module-federation/mf-products/public/index.html create mode 100644 examples/module-federation/mf-products/src/ui/App.tsx create mode 100644 examples/module-federation/mf-products/src/ui/entries/mf_products.tsx create mode 100644 examples/module-federation/mf-products/src/ui/tsconfig.json create mode 100644 examples/module-federation/mf-profile/app-builder.config.ts create mode 100644 examples/module-federation/mf-profile/public/index.html create mode 100644 examples/module-federation/mf-profile/src/ui/App.tsx create mode 100644 examples/module-federation/mf-profile/src/ui/entries/mf_profile.tsx create mode 100644 examples/module-federation/mf-profile/src/ui/tsconfig.json create mode 100644 examples/module-federation/package.json create mode 100644 examples/module-federation/tsconfig.json diff --git a/examples/module-federation/README.md b/examples/module-federation/README.md new file mode 100644 index 0000000..762c65d --- /dev/null +++ b/examples/module-federation/README.md @@ -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/.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 + + } /> + } /> + } /> + } /> + +``` + +`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.) diff --git a/examples/module-federation/host/app-builder.config.ts b/examples/module-federation/host/app-builder.config.ts new file mode 100644 index 0000000..990a985 --- /dev/null +++ b/examples/module-federation/host/app-builder.config.ts @@ -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 ?? []; + config.plugins.push( + new rspack.HtmlRspackPlugin({ + filename: '../../index.html', + template: 'public/index.html', + inject: 'body', + scriptLoading: 'defer', + publicPath: `/build/${HOST_NAME}/`, + }), + ); + return config; + }, + }, +}); diff --git a/examples/module-federation/host/public/index.html b/examples/module-federation/host/public/index.html new file mode 100644 index 0000000..993e846 --- /dev/null +++ b/examples/module-federation/host/public/index.html @@ -0,0 +1,11 @@ + + + + + + Module Federation · Host + + +
+ + diff --git a/examples/module-federation/host/src/ui/App.tsx b/examples/module-federation/host/src/ui/App.tsx new file mode 100644 index 0000000..6cc91c6 --- /dev/null +++ b/examples/module-federation/host/src/ui/App.tsx @@ -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 ( +
+
+
+ Loading microfrontend…

}> + + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/examples/module-federation/host/src/ui/Header.tsx b/examples/module-federation/host/src/ui/Header.tsx new file mode 100644 index 0000000..22a37a2 --- /dev/null +++ b/examples/module-federation/host/src/ui/Header.tsx @@ -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 ( +
+
🛰 Module Federation Demo
+ +
+ ); +} diff --git a/examples/module-federation/host/src/ui/entries/host.tsx b/examples/module-federation/host/src/ui/entries/host.tsx new file mode 100644 index 0000000..029cf96 --- /dev/null +++ b/examples/module-federation/host/src/ui/entries/host.tsx @@ -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( + + + , +); diff --git a/examples/module-federation/host/src/ui/pages/Home.tsx b/examples/module-federation/host/src/ui/pages/Home.tsx new file mode 100644 index 0000000..930707d --- /dev/null +++ b/examples/module-federation/host/src/ui/pages/Home.tsx @@ -0,0 +1,23 @@ +export function Home() { + return ( +
+

Welcome to the host shell

+

+ This page is rendered by the host app. Use the navigation above to load + one of three remote microfrontends. Each microfrontend is built and served + independently with @gravity-ui/app-builder. +

+
    +
  • + mf-products — http://localhost:3001 +
  • +
  • + mf-cart — http://localhost:3002 +
  • +
  • + mf-profile — http://localhost:3003 +
  • +
+
+ ); +} diff --git a/examples/module-federation/host/src/ui/remotes.d.ts b/examples/module-federation/host/src/ui/remotes.d.ts new file mode 100644 index 0000000..8d76240 --- /dev/null +++ b/examples/module-federation/host/src/ui/remotes.d.ts @@ -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; +} diff --git a/examples/module-federation/host/src/ui/styles.css b/examples/module-federation/host/src/ui/styles.css new file mode 100644 index 0000000..ab30120 --- /dev/null +++ b/examples/module-federation/host/src/ui/styles.css @@ -0,0 +1,113 @@ +:root { + color-scheme: light dark; + font-family: + system-ui, + -apple-system, + Segoe UI, + Roboto, + sans-serif; +} + +body { + margin: 0; + background: #0e1116; + color: #e6edf3; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: center; + gap: 24px; + padding: 16px 24px; + background: #161b22; + border-bottom: 1px solid #30363d; +} + +.app-header__brand { + font-weight: 600; + font-size: 16px; +} + +.app-header__nav { + display: flex; + gap: 8px; +} + +.app-header__link { + padding: 6px 12px; + border-radius: 6px; + color: #c9d1d9; + text-decoration: none; + transition: background-color 0.15s ease; +} + +.app-header__link:hover { + background: #21262d; +} + +.app-header__link--active { + background: #1f6feb; + color: white; +} + +.app-shell__main { + flex: 1; + padding: 32px; + max-width: 960px; + width: 100%; + margin: 0 auto; +} + +.app-shell__loader { + color: #8b949e; +} + +.home h1 { + margin-top: 0; +} + +.home code, +.mf-card code { + background: #161b22; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +.mf-card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 10px; + padding: 24px; +} + +.mf-card h2 { + margin-top: 0; +} + +.mf-card__counter { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.mf-card__button { + background: #238636; + color: white; + border: none; + padding: 8px 14px; + border-radius: 6px; + font-size: 14px; + cursor: pointer; +} + +.mf-card__button:hover { + background: #2ea043; +} diff --git a/examples/module-federation/host/src/ui/tsconfig.json b/examples/module-federation/host/src/ui/tsconfig.json new file mode 100644 index 0000000..b378590 --- /dev/null +++ b/examples/module-federation/host/src/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*"] +} diff --git a/examples/module-federation/mf-cart/app-builder.config.ts b/examples/module-federation/mf-cart/app-builder.config.ts new file mode 100644 index 0000000..d6e86c9 --- /dev/null +++ b/examples/module-federation/mf-cart/app-builder.config.ts @@ -0,0 +1,40 @@ +import {defineConfig} from '@gravity-ui/app-builder'; +import {rspack} from '@rspack/core'; + +const MF_NAME = 'mf_cart'; +const PORT = 3002; + +export default defineConfig({ + target: 'client', + client: { + bundler: 'rspack', + devServer: { + port: PORT, + writeToDisk: (target) => target.endsWith('index.html'), + }, + moduleFederation: { + name: MF_NAME, + exposes: { + './App': './src/ui/App', + }, + shared: { + react: {singleton: true, requiredVersion: false}, + 'react-dom': {singleton: true, requiredVersion: false}, + 'react-router': {singleton: true, requiredVersion: false}, + }, + }, + rspack: (config) => { + config.plugins = config.plugins ?? []; + config.plugins.push( + new rspack.HtmlRspackPlugin({ + filename: '../../index.html', + template: 'public/index.html', + inject: 'body', + scriptLoading: 'defer', + publicPath: `/build/${MF_NAME}/`, + }), + ); + return config; + }, + }, +}); diff --git a/examples/module-federation/mf-cart/public/index.html b/examples/module-federation/mf-cart/public/index.html new file mode 100644 index 0000000..a0f0fbe --- /dev/null +++ b/examples/module-federation/mf-cart/public/index.html @@ -0,0 +1,11 @@ + + + + + + mf-cart · standalone + + +
+ + diff --git a/examples/module-federation/mf-cart/src/ui/App.tsx b/examples/module-federation/mf-cart/src/ui/App.tsx new file mode 100644 index 0000000..be34066 --- /dev/null +++ b/examples/module-federation/mf-cart/src/ui/App.tsx @@ -0,0 +1,57 @@ +import {useState} from 'react'; + +type CartItem = {id: number; name: string; quantity: number}; + +const INITIAL_CART: CartItem[] = [ + {id: 1, name: 'Mechanical keyboard', quantity: 1}, + {id: 2, name: 'USB-C dock', quantity: 2}, +]; + +export default function CartApp() { + const [items, setItems] = useState(INITIAL_CART); + + const updateQuantity = (id: number, delta: number) => { + setItems((current) => + current + .map((item) => + item.id === id ? {...item, quantity: item.quantity + delta} : item, + ) + .filter((item) => item.quantity > 0), + ); + }; + + return ( +
+

🧺 Cart

+

+ Owned by the mf-cart microfrontend. +

+ {items.length === 0 ? ( +

Your cart is empty.

+ ) : ( +
    + {items.map((item) => ( +
  • + {item.name} + + {item.quantity} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/module-federation/mf-cart/src/ui/entries/mf_cart.tsx b/examples/module-federation/mf-cart/src/ui/entries/mf_cart.tsx new file mode 100644 index 0000000..1f5bebd --- /dev/null +++ b/examples/module-federation/mf-cart/src/ui/entries/mf_cart.tsx @@ -0,0 +1,19 @@ +import {createRoot} from 'react-dom/client'; + +import App from '../App'; + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('Root container #root is missing in index.html'); +} + +createRoot(container).render( +
+

+ Standalone preview of mf-cart. In production this module is loaded + inside the host shell. +

+ +
, +); diff --git a/examples/module-federation/mf-cart/src/ui/tsconfig.json b/examples/module-federation/mf-cart/src/ui/tsconfig.json new file mode 100644 index 0000000..b378590 --- /dev/null +++ b/examples/module-federation/mf-cart/src/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*"] +} diff --git a/examples/module-federation/mf-products/app-builder.config.ts b/examples/module-federation/mf-products/app-builder.config.ts new file mode 100644 index 0000000..9b55b5b --- /dev/null +++ b/examples/module-federation/mf-products/app-builder.config.ts @@ -0,0 +1,40 @@ +import {defineConfig} from '@gravity-ui/app-builder'; +import {rspack} from '@rspack/core'; + +const MF_NAME = 'mf_products'; +const PORT = 3001; + +export default defineConfig({ + target: 'client', + client: { + bundler: 'rspack', + devServer: { + port: PORT, + writeToDisk: (target) => target.endsWith('index.html'), + }, + moduleFederation: { + name: MF_NAME, + exposes: { + './App': './src/ui/App', + }, + shared: { + react: {singleton: true, requiredVersion: false}, + 'react-dom': {singleton: true, requiredVersion: false}, + 'react-router': {singleton: true, requiredVersion: false}, + }, + }, + rspack: (config) => { + config.plugins = config.plugins ?? []; + config.plugins.push( + new rspack.HtmlRspackPlugin({ + filename: '../../index.html', + template: 'public/index.html', + inject: 'body', + scriptLoading: 'defer', + publicPath: `/build/${MF_NAME}/`, + }), + ); + return config; + }, + }, +}); diff --git a/examples/module-federation/mf-products/public/index.html b/examples/module-federation/mf-products/public/index.html new file mode 100644 index 0000000..582f93a --- /dev/null +++ b/examples/module-federation/mf-products/public/index.html @@ -0,0 +1,11 @@ + + + + + + mf-products · standalone + + +
+ + diff --git a/examples/module-federation/mf-products/src/ui/App.tsx b/examples/module-federation/mf-products/src/ui/App.tsx new file mode 100644 index 0000000..203b147 --- /dev/null +++ b/examples/module-federation/mf-products/src/ui/App.tsx @@ -0,0 +1,53 @@ +import {useMemo, useState} from 'react'; + +const ALL_PRODUCTS = [ + {id: 1, name: 'Mechanical keyboard', price: 129}, + {id: 2, name: 'Ergonomic mouse', price: 49}, + {id: 3, name: '4K monitor', price: 399}, + {id: 4, name: 'USB-C dock', price: 89}, + {id: 5, name: 'Noise-cancelling headphones', price: 259}, +]; + +export default function ProductsApp() { + const [query, setQuery] = useState(''); + + const filtered = useMemo( + () => + ALL_PRODUCTS.filter((product) => + product.name.toLowerCase().includes(query.trim().toLowerCase()), + ), + [query], + ); + + return ( +
+

🛒 Products

+

+ Owned by the mf-products microfrontend. +

+ setQuery(event.target.value)} + placeholder="Search products…" + style={{ + padding: '8px 12px', + borderRadius: 6, + border: '1px solid #30363d', + background: '#0e1116', + color: 'inherit', + width: '100%', + marginTop: 12, + }} + /> +
    + {filtered.map((product) => ( +
  • + {product.name} — ${product.price} +
  • + ))} + {filtered.length === 0 ?
  • No results
  • : null} +
+
+ ); +} diff --git a/examples/module-federation/mf-products/src/ui/entries/mf_products.tsx b/examples/module-federation/mf-products/src/ui/entries/mf_products.tsx new file mode 100644 index 0000000..f320497 --- /dev/null +++ b/examples/module-federation/mf-products/src/ui/entries/mf_products.tsx @@ -0,0 +1,19 @@ +import {createRoot} from 'react-dom/client'; + +import App from '../App'; + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('Root container #root is missing in index.html'); +} + +createRoot(container).render( +
+

+ Standalone preview of mf-products. In production this module is loaded + inside the host shell. +

+ +
, +); diff --git a/examples/module-federation/mf-products/src/ui/tsconfig.json b/examples/module-federation/mf-products/src/ui/tsconfig.json new file mode 100644 index 0000000..b378590 --- /dev/null +++ b/examples/module-federation/mf-products/src/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*"] +} diff --git a/examples/module-federation/mf-profile/app-builder.config.ts b/examples/module-federation/mf-profile/app-builder.config.ts new file mode 100644 index 0000000..2e38421 --- /dev/null +++ b/examples/module-federation/mf-profile/app-builder.config.ts @@ -0,0 +1,40 @@ +import {defineConfig} from '@gravity-ui/app-builder'; +import {rspack} from '@rspack/core'; + +const MF_NAME = 'mf_profile'; +const PORT = 3003; + +export default defineConfig({ + target: 'client', + client: { + bundler: 'rspack', + devServer: { + port: PORT, + writeToDisk: (target) => target.endsWith('index.html'), + }, + moduleFederation: { + name: MF_NAME, + exposes: { + './App': './src/ui/App', + }, + shared: { + react: {singleton: true, requiredVersion: false}, + 'react-dom': {singleton: true, requiredVersion: false}, + 'react-router': {singleton: true, requiredVersion: false}, + }, + }, + rspack: (config) => { + config.plugins = config.plugins ?? []; + config.plugins.push( + new rspack.HtmlRspackPlugin({ + filename: '../../index.html', + template: 'public/index.html', + inject: 'body', + scriptLoading: 'defer', + publicPath: `/build/${MF_NAME}/`, + }), + ); + return config; + }, + }, +}); diff --git a/examples/module-federation/mf-profile/public/index.html b/examples/module-federation/mf-profile/public/index.html new file mode 100644 index 0000000..9b7bc94 --- /dev/null +++ b/examples/module-federation/mf-profile/public/index.html @@ -0,0 +1,11 @@ + + + + + + mf-profile · standalone + + +
+ + diff --git a/examples/module-federation/mf-profile/src/ui/App.tsx b/examples/module-federation/mf-profile/src/ui/App.tsx new file mode 100644 index 0000000..6bae7fd --- /dev/null +++ b/examples/module-federation/mf-profile/src/ui/App.tsx @@ -0,0 +1,82 @@ +import {useState} from 'react'; + +type Profile = { + name: string; + email: string; + role: string; +}; + +const DEFAULT_PROFILE: Profile = { + name: 'Ada Lovelace', + email: 'ada@example.com', + role: 'Software Engineer', +}; + +export default function ProfileApp() { + const [profile, setProfile] = useState(DEFAULT_PROFILE); + const [editing, setEditing] = useState(false); + + if (editing) { + return ( +
+

👤 Edit profile

+
{ + event.preventDefault(); + setEditing(false); + }} + style={{display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12}} + > + {(['name', 'email', 'role'] as const).map((field) => ( + + ))} +
+ +
+
+
+ ); + } + + return ( +
+

👤 Profile

+

+ Owned by the mf-profile microfrontend. +

+
+
Name
+
{profile.name}
+
Email
+
{profile.email}
+
Role
+
{profile.role}
+
+ +
+ ); +} diff --git a/examples/module-federation/mf-profile/src/ui/entries/mf_profile.tsx b/examples/module-federation/mf-profile/src/ui/entries/mf_profile.tsx new file mode 100644 index 0000000..963da68 --- /dev/null +++ b/examples/module-federation/mf-profile/src/ui/entries/mf_profile.tsx @@ -0,0 +1,19 @@ +import {createRoot} from 'react-dom/client'; + +import App from '../App'; + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('Root container #root is missing in index.html'); +} + +createRoot(container).render( +
+

+ Standalone preview of mf-profile. In production this module is loaded + inside the host shell. +

+ +
, +); diff --git a/examples/module-federation/mf-profile/src/ui/tsconfig.json b/examples/module-federation/mf-profile/src/ui/tsconfig.json new file mode 100644 index 0000000..b378590 --- /dev/null +++ b/examples/module-federation/mf-profile/src/ui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*"] +} diff --git a/examples/module-federation/package.json b/examples/module-federation/package.json new file mode 100644 index 0000000..7364866 --- /dev/null +++ b/examples/module-federation/package.json @@ -0,0 +1,38 @@ +{ + "name": "@examples/module-federation", + "version": "1.0.0", + "description": "Module Federation example with a host shell and three microfrontends", + "private": true, + "scripts": { + "dev:host": "cd host && app-builder dev", + "dev:products": "cd mf-products && app-builder dev", + "dev:cart": "cd mf-cart && app-builder dev", + "dev:profile": "cd mf-profile && app-builder dev", + "dev": "run-p dev:products dev:cart dev:profile dev:host", + "build:host": "cd host && app-builder build", + "build:products": "cd mf-products && app-builder build", + "build:cart": "cd mf-cart && app-builder build", + "build:profile": "cd mf-profile && app-builder build", + "build": "run-s build:products build:cart build:profile build:host", + "preview:host": "cd host/dist/public && npx serve -l 3000 -s .", + "preview:products": "cd mf-products/dist/public && npx serve -l 3001 -s .", + "preview:cart": "cd mf-cart/dist/public && npx serve -l 3002 -s .", + "preview:profile": "cd mf-profile/dist/public && npx serve -l 3003 -s ." + }, + "license": "MIT", + "devDependencies": { + "@gravity-ui/app-builder": "workspace:*", + "@gravity-ui/tsconfig": "^1.0.0", + "@rspack/core": "1.7.11", + "@types/node": "^22", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "npm-run-all": "^4.1.5", + "typescript": "~5.6.3" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.0.2" + } +} diff --git a/examples/module-federation/tsconfig.json b/examples/module-federation/tsconfig.json new file mode 100644 index 0000000..2b94072 --- /dev/null +++ b/examples/module-federation/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@gravity-ui/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc39e23..84b3804 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -402,6 +402,43 @@ importers: specifier: ^29.1.2 version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.15.5)(@types/node@18.19.130)(typescript@5.6.3)))(typescript@5.6.3) + examples/module-federation: + dependencies: + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-router: + specifier: ^7.0.2 + version: 7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@gravity-ui/app-builder': + specifier: workspace:* + version: link:../.. + '@gravity-ui/tsconfig': + specifier: ^1.0.0 + version: 1.0.0 + '@rspack/core': + specifier: 1.7.11 + version: 1.7.11 + '@types/node': + specifier: ^22 + version: 22.19.15 + '@types/react': + specifier: ^18.3.2 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + typescript: + specifier: ~5.6.3 + version: 5.6.3 + examples/ssr: dependencies: '@babel/runtime': @@ -10655,7 +10692,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -10668,14 +10705,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@18.19.130)(ts-node@10.9.2(@swc/core@1.15.5)(@types/node@18.19.130)(typescript@5.6.3)) + jest-config: 29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.5)(@types/node@18.19.130)(typescript@5.6.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -10700,7 +10737,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -10718,7 +10755,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -10734,7 +10771,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-regex-util: 30.0.1 '@jest/reporters@29.7.0': @@ -10745,7 +10782,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -10819,7 +10856,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/yargs': 17.0.11 chalk: 4.1.2 @@ -10829,7 +10866,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -12518,11 +12555,11 @@ snapshots: '@types/bonjour@3.5.13': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/circular-dependency-plugin@5.0.8(@swc/core@1.15.5)': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 webpack: 5.95.0(@swc/core@1.15.5) transitivePeerDependencies: - '@swc/core' @@ -12535,7 +12572,7 @@ snapshots: '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.8 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/connect@3.4.38': dependencies: @@ -12543,7 +12580,7 @@ snapshots: '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/cookie-parser@1.4.10(@types/express@5.0.6)': dependencies: @@ -12559,7 +12596,7 @@ snapshots: '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -12587,16 +12624,16 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.28)': dependencies: @@ -12609,7 +12646,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/istanbul-lib-coverage@2.0.6': {} @@ -12632,7 +12669,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/md5@2.3.6': {} @@ -12658,7 +12695,7 @@ snapshots: '@types/nodemon@1.19.6': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/normalize-package-data@2.4.4': {} @@ -12695,11 +12732,11 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/send@1.2.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/serve-index@1.9.4': dependencies: @@ -12708,7 +12745,7 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/send': 0.17.6 '@types/serve-static@2.2.0': @@ -12718,7 +12755,7 @@ snapshots: '@types/sockjs@0.3.36': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/source-list-map@0.1.6': {} @@ -12736,7 +12773,7 @@ snapshots: '@types/webpack-assets-manifest@5.1.4(@swc/core@1.15.5)': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 tapable: 2.3.2 webpack: 5.95.0(@swc/core@1.15.5) transitivePeerDependencies: @@ -12747,7 +12784,7 @@ snapshots: '@types/webpack-bundle-analyzer@4.7.0(@swc/core@1.15.5)': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 tapable: 2.3.2 webpack: 5.95.0(@swc/core@1.15.5) transitivePeerDependencies: @@ -12763,7 +12800,7 @@ snapshots: '@types/webpack-node-externals@3.0.4(@swc/core@1.15.5)': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 webpack: 5.95.0(@swc/core@1.15.5) transitivePeerDependencies: - '@swc/core' @@ -12773,13 +12810,13 @@ snapshots: '@types/webpack-sources@3.2.3': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/source-list-map': 0.1.6 source-map: 0.7.6 '@types/webpack@4.41.40': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/tapable': 1.0.12 '@types/uglify-js': 3.17.5 '@types/webpack-sources': 3.2.3 @@ -12788,7 +12825,7 @@ snapshots: '@types/webpack@5.28.5(@swc/core@1.15.5)': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 tapable: 2.3.2 webpack: 5.95.0(@swc/core@1.15.5) transitivePeerDependencies: @@ -12799,7 +12836,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@types/yargs-parser@21.0.3': {} @@ -15787,7 +15824,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -15857,6 +15894,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@22.19.15)(ts-node@10.9.2(@swc/core@1.15.5)(@types/node@18.19.130)(typescript@5.6.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.15 + ts-node: 10.9.2(@swc/core@1.15.5)(@types/node@18.19.130)(typescript@5.6.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -15881,7 +15949,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -15891,7 +15959,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 18.19.130 + '@types/node': 22.19.15 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -15930,7 +15998,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -15967,7 +16035,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -15995,7 +16063,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -16041,7 +16109,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -16050,7 +16118,7 @@ snapshots: jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 18.19.130 + '@types/node': 22.19.15 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 @@ -16069,7 +16137,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 18.19.130 + '@types/node': 22.19.15 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -16078,20 +16146,20 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@30.3.0: dependencies: - '@types/node': 18.19.130 + '@types/node': 22.19.15 '@ungap/structured-clone': 1.3.0 jest-util: 30.3.0 merge-stream: 2.0.0