├── cart ├── src │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── App.tsx │ ├── index.css │ ├── reportWebVitals.ts │ ├── bootstrap.tsx │ ├── App.css │ ├── components │ │ ├── ErrorBoundary │ │ │ └── index.tsx │ │ ├── CartButton │ │ │ └── index.tsx │ │ └── CheckoutPanel │ │ │ └── index.tsx │ ├── features │ │ └── checkout │ │ │ └── index.tsx │ ├── logo.svg │ └── store │ │ └── index.ts ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── tsconfig.json ├── babel.config.js ├── webpack.config.js ├── package.json └── README.md ├── main ├── src │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── utils │ │ ├── remote.ts │ │ └── index.ts │ ├── index.css │ ├── components │ │ ├── RemoteWrapper │ │ │ └── index.tsx │ │ ├── StoreHeader │ │ │ └── index.tsx │ │ ├── ErrorBoundary │ │ │ └── index.tsx │ │ ├── RemoteComponent │ │ │ └── index.tsx │ │ ├── Header │ │ │ └── index.tsx │ │ └── RemoteControls │ │ │ └── index.tsx │ ├── reportWebVitals.ts │ ├── bootstrap.tsx │ ├── App.css │ ├── services │ │ ├── pubsub.test.ts │ │ └── pubsub.ts │ ├── App.tsx │ ├── context │ │ └── remotes.tsx │ └── logo.svg ├── public │ ├── robots.txt │ ├── logo192.png │ ├── logo512.png │ ├── fruit-plate.ico │ ├── fruit-plate.png │ ├── manifest.json │ └── index.html ├── tsconfig.json ├── package.json ├── README.md └── webpack.config.js ├── products ├── src │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── components │ │ ├── Fruit │ │ │ ├── images │ │ │ │ ├── apple.jpg │ │ │ │ ├── bananas.jpg │ │ │ │ ├── guava.jpg │ │ │ │ ├── no-image.png │ │ │ │ ├── bowl_fruit1.jpg │ │ │ │ ├── bowl_fruit2.jpg │ │ │ │ ├── grapefruit.jpg │ │ │ │ ├── pineapple1.jpg │ │ │ │ ├── pineapple2.jpg │ │ │ │ ├── pomegranate.jpg │ │ │ │ ├── watermelon.jpg │ │ │ │ └── passion_fruit.jpg │ │ │ └── index.tsx │ │ ├── ProductsList │ │ │ └── index.tsx │ │ ├── ErrorBoundary │ │ │ └── index.tsx │ │ └── ProductCard │ │ │ └── index.tsx │ ├── setupTests.ts │ ├── index.css │ ├── reportWebVitals.ts │ ├── products.ts │ ├── App.tsx │ ├── bootstrap.tsx │ ├── App.css │ └── logo.svg ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── index.html │ └── manifest.json ├── tsconfig.json ├── package.json ├── README.md └── webpack.config.js ├── vercel.json ├── docs └── demo.png ├── .yarnrc.yml ├── .gitignore ├── turbo.json ├── package.json └── README.md /cart/src/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import("./bootstrap"); 3 | -------------------------------------------------------------------------------- /main/src/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import("./bootstrap"); 3 | -------------------------------------------------------------------------------- /products/src/index.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import('./bootstrap'); -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } -------------------------------------------------------------------------------- /cart/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /main/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /products/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/docs/demo.png -------------------------------------------------------------------------------- /cart/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /main/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /cart/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/cart/public/favicon.ico -------------------------------------------------------------------------------- /cart/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/cart/public/logo192.png -------------------------------------------------------------------------------- /cart/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/cart/public/logo512.png -------------------------------------------------------------------------------- /main/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/main/public/logo192.png -------------------------------------------------------------------------------- /main/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/main/public/logo512.png -------------------------------------------------------------------------------- /products/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /main/public/fruit-plate.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/main/public/fruit-plate.ico -------------------------------------------------------------------------------- /main/public/fruit-plate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/main/public/fruit-plate.png -------------------------------------------------------------------------------- /products/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/public/favicon.ico -------------------------------------------------------------------------------- /products/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/public/logo192.png -------------------------------------------------------------------------------- /products/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/public/logo512.png -------------------------------------------------------------------------------- /products/src/components/Fruit/images/apple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/apple.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/bananas.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/bananas.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/guava.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/guava.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/no-image.png -------------------------------------------------------------------------------- /products/src/components/Fruit/images/bowl_fruit1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/bowl_fruit1.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/bowl_fruit2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/bowl_fruit2.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/grapefruit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/grapefruit.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/pineapple1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/pineapple1.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/pineapple2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/pineapple2.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/pomegranate.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/pomegranate.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/watermelon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/watermelon.jpg -------------------------------------------------------------------------------- /products/src/components/Fruit/images/passion_fruit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rautio/micro-frontend-demo/HEAD/products/src/components/Fruit/images/passion_fruit.jpg -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | -------------------------------------------------------------------------------- /cart/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /main/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /products/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /main/src/utils/remote.ts: -------------------------------------------------------------------------------- 1 | import { Remote } from "../context/remotes"; 2 | 3 | export const findRemoteUrl = ( 4 | remoteName: string, 5 | remotes: Remote[] 6 | ): string => { 7 | const remote = remotes.find((r) => r.name === remoteName); 8 | return remote?.url || ""; 9 | }; 10 | -------------------------------------------------------------------------------- /cart/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // @ts-ignore 3 | import { BrowserRouter } from "react-router-dom"; 4 | import Cart from "./components/CartButton"; 5 | 6 | export const App = () => { 7 | return ( 8 | 9 |

Cart

10 | 11 |
12 | ); 13 | }; 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /cart/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /main/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /products/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /main/src/components/RemoteWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import ErrorBoundary from "../ErrorBoundary"; 3 | 4 | type Props = { 5 | fallback?: string | React.ReactNode; 6 | children: React.ReactNode; 7 | }; 8 | 9 | const RemoteWrapper: FC = ({ children, fallback = null }) => ( 10 | 11 | {children} 12 | 13 | ); 14 | 15 | export default RemoteWrapper; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /**/node_modules 5 | /**/.pnp 6 | /**/.pnp.js 7 | 8 | # testing 9 | /**/coverage 10 | 11 | # production 12 | /**/build 13 | /**/dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | /**/npm-debug.log* 23 | /**/yarn-debug.log* 24 | /**/yarn-error.log* 25 | 26 | # build process 27 | .turbo 28 | 29 | .yarn -------------------------------------------------------------------------------- /cart/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /main/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /products/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /products/src/products.ts: -------------------------------------------------------------------------------- 1 | export type Products = { name: string; price: number }[]; 2 | 3 | export const products: Products = [ 4 | { name: "Grapefruit", price: 1.5 }, 5 | { name: "Apple", price: 0.5 }, 6 | { name: "Guava", price: 0.75 }, 7 | { name: "Passion Fruit", price: 0.75 }, 8 | { name: "Banana", price: 0.1 }, 9 | { name: "Fruit Bowl", price: 5.25 }, 10 | { name: "Pineapple", price: 2.5 }, 11 | { name: "Pomegranate", price: 1.25 }, 12 | { name: "Watermelon", price: 3.75 }, 13 | ]; 14 | 15 | export default products; 16 | -------------------------------------------------------------------------------- /products/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Products App 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "baseBranch": "origin/main", 4 | "pipeline": { 5 | "start": { 6 | "dependsOn": ["^start"] 7 | }, 8 | "build": { 9 | "dependsOn": ["^build"] 10 | }, 11 | "test": { 12 | "dependsOn": ["build"], 13 | "outputs": [], 14 | "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"] 15 | }, 16 | "lint": { 17 | "outputs": [] 18 | }, 19 | "deploy": { 20 | "dependsOn": ["build", "test", "lint"], 21 | "outputs": [] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /products/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ProductsList from "./components/ProductsList"; 3 | import ErrorBoundary from "./components/ErrorBoundary"; 4 | 5 | const Cart = React.lazy( 6 | // @ts-ignore 7 | () => import("CART/Cart") 8 | ); 9 | 10 | export const App = () => { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 |

Products

19 | 20 |
21 | ); 22 | }; 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /cart/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /products/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /main/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "fruit-plate.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "fruit-plage.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "fruit-plage.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /main/src/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /cart/src/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | // import './index.css'; 4 | import App from './App'; 5 | // import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | // reportWebVitals(); 20 | -------------------------------------------------------------------------------- /cart/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": false, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": false, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /products/src/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | // import './index.css'; 4 | import App from './App'; 5 | // import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | // reportWebVitals(); 20 | -------------------------------------------------------------------------------- /products/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": false, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /cart/babel.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Using babel.config.js, which was introduced since babel 7 and it is recommended 3 | * by babel (https://babeljs.io/docs/en/configuration). 4 | * It provides ways to easily create config and be able to compile node_module if needed. 5 | */ 6 | 7 | const babelrc = { 8 | presets: [ 9 | '@babel/preset-react', 10 | '@babel/preset-typescript', 11 | [ 12 | '@babel/env', 13 | { 14 | targets: { 15 | edge: '87', 16 | firefox: '78', 17 | chrome: '87', 18 | safari: '14', 19 | }, 20 | }, 21 | ], 22 | ], 23 | plugins: [ 24 | '@babel/transform-runtime', 25 | ], 26 | }; 27 | 28 | module.exports = babelrc; 29 | 30 | -------------------------------------------------------------------------------- /cart/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /main/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /products/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /products/src/components/ProductsList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import Grid from "@mui/material/Grid"; 3 | import ProductCard from "../ProductCard"; 4 | import items from "../../products"; 5 | 6 | type Props = { 7 | name: string; 8 | price: number; 9 | }; 10 | 11 | const Product: FC = ({ name, price }) => ( 12 | 13 | 14 | 15 | ); 16 | 17 | export const ProductsList = () => { 18 | return ( 19 | 25 | {items.map(({ name, price }) => ( 26 | 27 | ))} 28 | 29 | ); 30 | }; 31 | 32 | export default ProductsList; 33 | -------------------------------------------------------------------------------- /main/src/components/StoreHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AppBar from "@mui/material/AppBar"; 3 | import Box from "@mui/material/Box"; 4 | import Toolbar from "@mui/material/Toolbar"; 5 | import Typography from "@mui/material/Typography"; 6 | import RemoteComponent from "../RemoteComponent"; 7 | 8 | const StoreHeader = () => { 9 | return ( 10 | 11 | 12 | 13 | 18 | Fruit Store 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default StoreHeader; 28 | -------------------------------------------------------------------------------- /cart/src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from "react"; 2 | 3 | interface Props { 4 | children?: ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | } 10 | 11 | class ErrorBoundary extends Component { 12 | public state: State = { 13 | hasError: false 14 | }; 15 | 16 | public static getDerivedStateFromError(_: Error): State { 17 | // Update state so the next render will show the fallback UI. 18 | return { hasError: true }; 19 | } 20 | 21 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 22 | console.error("Uncaught error:", error, errorInfo); 23 | } 24 | 25 | public render() { 26 | if (this.state.hasError) { 27 | return

Sorry.. there was an error

; 28 | } 29 | 30 | return this.props.children; 31 | } 32 | } 33 | 34 | export default ErrorBoundary; -------------------------------------------------------------------------------- /main/src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from "react"; 2 | 3 | interface Props { 4 | children?: ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | } 10 | 11 | class ErrorBoundary extends Component { 12 | public state: State = { 13 | hasError: false 14 | }; 15 | 16 | public static getDerivedStateFromError(_: Error): State { 17 | // Update state so the next render will show the fallback UI. 18 | return { hasError: true }; 19 | } 20 | 21 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 22 | console.error("Uncaught error:", error, errorInfo); 23 | } 24 | 25 | public render() { 26 | if (this.state.hasError) { 27 | return

Sorry.. there was an error

; 28 | } 29 | 30 | return this.props.children; 31 | } 32 | } 33 | 34 | export default ErrorBoundary; -------------------------------------------------------------------------------- /products/src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from "react"; 2 | 3 | interface Props { 4 | children?: ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | } 10 | 11 | class ErrorBoundary extends Component { 12 | public state: State = { 13 | hasError: false 14 | }; 15 | 16 | public static getDerivedStateFromError(_: Error): State { 17 | // Update state so the next render will show the fallback UI. 18 | return { hasError: true }; 19 | } 20 | 21 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 22 | console.error("Uncaught error:", error, errorInfo); 23 | } 24 | 25 | public render() { 26 | if (this.state.hasError) { 27 | return

Sorry.. there was an error

; 28 | } 29 | 30 | return this.props.children; 31 | } 32 | } 33 | 34 | export default ErrorBoundary; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micro-frontend-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "npm": ">=8.0.0", 7 | "node": ">=16.0.0 " 8 | }, 9 | "devDependencies": { 10 | "turbo": "^1.10.12" 11 | }, 12 | "scripts": { 13 | "start": "yarn turbo run start", 14 | "build": "yarn turbo run build", 15 | "test": "yarn turbo run test" 16 | }, 17 | "workspaces": [ 18 | "main", 19 | "cart", 20 | "products" 21 | ], 22 | "eslintConfig": { 23 | "extends": [ 24 | "react-app", 25 | "react-app/jest" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "packageManager": "yarn@3.6.1" 41 | } 42 | -------------------------------------------------------------------------------- /cart/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Cart App 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /main/src/components/RemoteComponent/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import ErrorBoundary from "../ErrorBoundary"; 3 | import { useRemotes } from "../../context/remotes"; 4 | import { loadComponent } from "../../utils"; 5 | import { findRemoteUrl } from "../../utils/remote"; 6 | 7 | type Props = { 8 | fallback?: string | React.ReactNode; 9 | remote: "PRODUCTS" | "CART"; 10 | component: string; 11 | scope?: string; 12 | [key: string]: any; 13 | }; 14 | 15 | const RemoteComponent: FC = ({ 16 | remote, 17 | component, 18 | scope = "default", 19 | fallback = null, 20 | ...props 21 | }) => { 22 | const [remotes] = useRemotes(); 23 | const remoteUrl = findRemoteUrl(remote, remotes); 24 | if (!remoteUrl) return
Unable to Fetch: {`${remote}/${component}`}
; 25 | const Component = React.lazy( 26 | loadComponent(remote, remoteUrl, `./${component}`, scope) 27 | ); 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default RemoteComponent; 38 | -------------------------------------------------------------------------------- /main/src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Toolbar from "@mui/material/Toolbar"; 3 | import AppBar from "@mui/material/AppBar"; 4 | import Box from "@mui/material/Box"; 5 | import IconButton from "@mui/material/IconButton"; 6 | import GithubIcon from "@mui/icons-material/GitHub"; 7 | import Typography from "@mui/material/Typography"; 8 | 9 | export const Header = () => { 10 | return ( 11 | 12 | 13 | 14 | 19 | Micro Frontend Demo 20 | 21 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default Header; 39 | -------------------------------------------------------------------------------- /main/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 17 | Fruit Shop 18 | 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /main/src/services/pubsub.test.ts: -------------------------------------------------------------------------------- 1 | import PubSub from "./pubsub"; 2 | 3 | describe("PubSub should", () => { 4 | test("subscribe and publish messages", () => { 5 | const ps = new PubSub(); 6 | const onMessage = jest.fn(); 7 | ps.subscribe("foo", onMessage); 8 | const message = { foo: "bar" }; 9 | ps.publish("foo", message); 10 | expect(onMessage).toHaveBeenCalledWith(message); 11 | }); 12 | 13 | test("unsubscribe and not receive any more messages", () => { 14 | const ps = new PubSub(); 15 | const onMessage = jest.fn(); 16 | const subID = ps.subscribe("foo", onMessage); 17 | const message = { foo: "bar" }; 18 | ps.publish("foo", message); 19 | ps.unsubscribe(subID); 20 | ps.publish("foo", message); 21 | expect(onMessage).toHaveBeenCalledTimes(1); 22 | }); 23 | 24 | test("store messages for a persisted topic and send them for new subscribers", () => { 25 | const ps = new PubSub({ persistedTopics: ["foo"] }); 26 | const onMessage = jest.fn(); 27 | const message = { foo: "bar" }; 28 | ps.publish("foo", { other: "not bar" }); 29 | ps.publish("foo", { alsoNotBar: "foo" }); 30 | // Persists only the last message 31 | ps.publish("foo", message); 32 | ps.subscribe("foo", onMessage); 33 | expect(onMessage).toHaveBeenCalledWith(message); 34 | // Only called once with the latest message 35 | expect(onMessage).toHaveBeenCalledTimes(1); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /cart/src/components/CartButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import IconButton from "@mui/material/IconButton"; 3 | import ShoppingCart from "@mui/icons-material/ShoppingCart"; 4 | import Badge, { BadgeProps } from "@mui/material/Badge"; 5 | import { styled } from "@mui/material/styles"; 6 | import Drawer from "@mui/material/Drawer"; 7 | import { useCartCount } from "../../store"; 8 | import CheckoutPanel from "../CheckoutPanel"; 9 | 10 | const StyledBadge = styled(Badge)(({ theme }) => ({ 11 | "& .MuiBadge-badge": { 12 | right: -3, 13 | top: 13, 14 | border: `2px solid ${theme.palette.background.paper}`, 15 | padding: "0 4px", 16 | }, 17 | })); 18 | 19 | export const Cart = () => { 20 | const count = useCartCount(); 21 | const [open, setOpen] = useState(false); 22 | return ( 23 | <> 24 | { 31 | setOpen(true); 32 | }} 33 | > 34 | 35 | 36 | 37 | 38 | setOpen(false)}> 39 | setOpen(false)} /> 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default Cart; 46 | -------------------------------------------------------------------------------- /products/src/components/Fruit/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | 3 | import GrapeFruit from "./images/grapefruit.jpg"; 4 | import Apple from "./images/apple.jpg"; 5 | import Banana from "./images/bananas.jpg"; 6 | import FruitBowl from "./images/bowl_fruit1.jpg"; 7 | import Guava from "./images/guava.jpg"; 8 | import PassionFruit from "./images/passion_fruit.jpg"; 9 | import Pineapple from "./images/pineapple1.jpg"; 10 | import Pomegranate from "./images/pomegranate.jpg"; 11 | import Watermelon from "./images/watermelon.jpg"; 12 | import NoImage from "./images/no-image.png"; 13 | 14 | interface Props { 15 | name: string; 16 | width?: string; 17 | height?: string; 18 | } 19 | 20 | export const Fruit: FC = ({ name, width = "200", height }) => { 21 | let imgSrc = ""; 22 | switch (name) { 23 | case "Grapefruit": 24 | imgSrc = GrapeFruit; 25 | break; 26 | case "Apple": 27 | imgSrc = Apple; 28 | break; 29 | case "Banana": 30 | imgSrc = Banana; 31 | break; 32 | case "Fruit Bowl": 33 | imgSrc = FruitBowl; 34 | break; 35 | case "Guava": 36 | imgSrc = Guava; 37 | break; 38 | case "Passion Fruit": 39 | imgSrc = PassionFruit; 40 | break; 41 | case "Pineapple": 42 | imgSrc = Pineapple; 43 | break; 44 | case "Pomegranate": 45 | imgSrc = Pomegranate; 46 | break; 47 | case "Watermelon": 48 | imgSrc = Watermelon; 49 | break; 50 | default: 51 | imgSrc = NoImage; 52 | } 53 | return {name}; 54 | }; 55 | 56 | export default Fruit; 57 | -------------------------------------------------------------------------------- /products/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "products", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@mui/icons-material": "^5.14.3", 7 | "@mui/material": "^5.14.5", 8 | "@mui/styled-engine-sc": "^5.12.0", 9 | "file-loader": "^6.2.0", 10 | "react": "^18.2.0", 11 | "react-dom": "^18.2.0", 12 | "styled-components": "^6.0.7", 13 | "url-loader": "^4.1.1" 14 | }, 15 | "devDependencies": { 16 | "@testing-library/jest-dom": "^6.0.0", 17 | "@testing-library/react": "^14.0.0", 18 | "@testing-library/user-event": "^14.4.3", 19 | "@types/jest": "^29.5.3", 20 | "@types/node": "^20.5.0", 21 | "@types/react": "^18.2.20", 22 | "@types/react-dom": "^18.2.7", 23 | "babel-loader": "^9.1.3", 24 | "dotenv-webpack": "^8.0.1", 25 | "html-webpack-plugin": "^5.5.3", 26 | "react-scripts": "5.0.1", 27 | "ts-loader": "^9.4.4", 28 | "typescript": "^5.1.6", 29 | "web-vitals": "^3.4.0", 30 | "webpack": "^5.88.2", 31 | "webpack-cli": "^5.1.4", 32 | "webpack-dev-server": "^4.15.1", 33 | "webpack-server": "^0.1.2" 34 | }, 35 | "scripts": { 36 | "cra-start": "react-scripts start", 37 | "cra-build": "react-scripts build", 38 | "test": "react-scripts test", 39 | "eject": "react-scripts eject", 40 | "start": "webpack serve --env CART_HOST=http://localhost:9003", 41 | "dev": "webpack serve", 42 | "build": "webpack build" 43 | }, 44 | "eslintConfig": { 45 | "extends": [ 46 | "react-app", 47 | "react-app/jest" 48 | ] 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /main/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // @ts-ignore 3 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 4 | import Header from "./components/Header"; 5 | import StoreHeader from "./components/StoreHeader"; 6 | import RemoteControls from "./components/RemoteControls"; 7 | import RemoteComponent from "./components/RemoteComponent"; 8 | import RemotesProvider from "./context/remotes"; 9 | import PubSub from "./services/pubsub"; 10 | 11 | // Initialize PubSub Event messaging between apps 12 | const events = new PubSub({ persistedTopics: ["cart"] }); 13 | // Set it at global level for all to consume (also passed as props) 14 | // @ts-ignore 15 | window.fsEvents = events; 16 | 17 | export const App = () => { 18 | return ( 19 | 20 |
21 | 22 |
23 | 24 | <> 25 | 26 |
27 | 28 | 36 | } 37 | > 38 | 46 | } 47 | > 48 | 49 |
50 | 51 |
52 |
53 | 54 | ); 55 | }; 56 | export default App; 57 | -------------------------------------------------------------------------------- /main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "main", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.11.1", 7 | "@emotion/styled": "^11.11.0", 8 | "@mui/material": "^5.14.5", 9 | "@mui/styled-engine-sc": "^5.12.0", 10 | "@testing-library/jest-dom": "^6.0.0", 11 | "@testing-library/react": "^14.0.0", 12 | "@testing-library/user-event": "^14.4.3", 13 | "@types/jest": "^29.5.3", 14 | "@types/node": "^20.5.0", 15 | "@types/react": "^18.2.20", 16 | "@types/react-dom": "^18.2.7", 17 | "babel-loader": "^9.1.3", 18 | "html-webpack-plugin": "^5.5.3", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-router-dom": "^6.15.0", 22 | "react-scripts": "5.0.1", 23 | "styled-components": "^6.0.7", 24 | "ts-loader": "^9.4.4", 25 | "typescript": "^5.1.6", 26 | "uuid": "^9.0.0", 27 | "web-vitals": "^3.4.0", 28 | "webpack": "^5.88.2", 29 | "webpack-cli": "^5.1.4", 30 | "webpack-dev-server": "^4.15.1", 31 | "webpack-server": "^0.1.2" 32 | }, 33 | "scripts": { 34 | "cra-start": "react-scripts start", 35 | "cra-build": "react-scripts build", 36 | "test": "react-scripts test", 37 | "eject": "react-scripts eject", 38 | "start": "webpack serve --env PRODUCTS_HOST=http://localhost:9002 --env CART_HOST=http://localhost:9003", 39 | "build": "webpack build" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@babel/plugin-transform-runtime": "^7.22.10", 61 | "css-loader": "^6.8.1", 62 | "dotenv-webpack": "^8.0.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cart/src/features/checkout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // @ts-ignore 3 | import { Link } from "react-router-dom"; 4 | import useStore from "../../store"; 5 | import RemoteComponent from "../../../../main/src/components/RemoteComponent"; 6 | // Importing components and list of products from products app directly 7 | // to simplify import/export. Products list should be taken care of by a communication layer 8 | // And the ProductCard should be a published npm package or shared remote but need 9 | // to figure out how to load multiple versions of the same remote in 1 app. 10 | import products from "../../../../products/src/products"; 11 | 12 | type Product = { 13 | name: string; 14 | price?: number; 15 | quantity?: number; 16 | }; 17 | 18 | type PriceMap = Record; 19 | // @ts-ignore 20 | const priceMap: PriceMap = products.reduce((acc: PriceMap, cur: Product) => { 21 | return { ...acc, [cur.name]: cur.price }; 22 | }, {}); 23 | export const CheckoutPage = () => { 24 | const cart = useStore((store) => store.cart); 25 | let total = 0; 26 | for (let i = 0; i < cart.length; i++) { 27 | const product: Product = cart[i]; 28 | total = total + (product?.quantity || 1) * priceMap[product.name]; 29 | } 30 | return ( 31 |
32 | ← Back 33 |

Checkout

34 |

Total: ${total.toFixed(2)}

35 |
36 | {cart.map(({ name, quantity }) => ( 37 |
38 | 46 |
47 | ))} 48 | {cart.length === 0 &&

No items in cart

} 49 |
50 |
51 | ); 52 | }; 53 | 54 | export default CheckoutPage; 55 | -------------------------------------------------------------------------------- /cart/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); 3 | const Dotenv = require("dotenv-webpack"); 4 | const { dependencies } = require("./package.json"); 5 | 6 | module.exports = (env) => { 7 | const PRODUCTS_HOST = env.PRODUCTS_HOST || "http://localhost:9002"; 8 | return { 9 | mode: "development", 10 | devServer: { 11 | port: 9003, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|jsx|ts|tsx)?$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: "babel-loader", 21 | options: { 22 | presets: ["@babel/preset-env", "@babel/preset-react"], 23 | }, 24 | }, 25 | ], 26 | }, 27 | { 28 | test: /\.(ts|tsx)?$/, 29 | exclude: /node_modules/, 30 | use: [ 31 | { 32 | loader: "ts-loader", 33 | }, 34 | ], 35 | }, 36 | ], 37 | }, 38 | plugins: [ 39 | new Dotenv(), 40 | new ModuleFederationPlugin({ 41 | name: "CART", 42 | filename: "remoteEntry.js", 43 | exposes: { 44 | "./Cart": "./src/components/CartButton", 45 | "./CheckoutPage": "./src/features/checkout", 46 | }, 47 | remotes: { 48 | PRODUCTS: `PRODUCTS@${PRODUCTS_HOST}/remoteEntry.js`, 49 | }, 50 | shared: { 51 | ...dependencies, 52 | react: { 53 | eager: true, 54 | singleton: true, 55 | requiredVersion: dependencies["react"], 56 | }, 57 | "react-dom": { 58 | eager: true, 59 | singleton: true, 60 | requiredVersion: dependencies["react-dom"], 61 | }, 62 | }, 63 | }), 64 | new HtmlWebpackPlugin({ 65 | template: "./public/index.html", 66 | }), 67 | ], 68 | resolve: { 69 | extensions: [".js", ".jsx", ".ts", ".tsx"], 70 | }, 71 | target: "web", 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /main/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | export const fetchRemote = (url, remoteName) => 3 | new Promise((resolve, reject) => { 4 | // We define a script tag to use the browser for fetching the remoteEntry.js file 5 | const script = document.createElement("script"); 6 | script.src = url; 7 | script.onerror = (err) => { 8 | console.error(err); 9 | reject(new Error(`Failed to fetch remote: ${remoteName}`)); 10 | }; 11 | // When the script is loaded we need to resolve the promise back to Module Federation 12 | script.onload = () => { 13 | // The script is now loaded on window using the name defined within the remote 14 | const proxy = { 15 | get: (request) => window[remoteName].get(request), 16 | init: (arg) => { 17 | try { 18 | return window[remoteName].init(arg); 19 | } catch (e) { 20 | console.error(e); 21 | console.error(`Failed to initialize remote: ${remoteName}`); 22 | reject(e); 23 | } 24 | }, 25 | }; 26 | resolve(proxy); 27 | }; 28 | // Lastly we inject the script tag into the document's head to trigger the script load 29 | document.head.appendChild(script); 30 | }); 31 | 32 | export const loadComponent = 33 | (remoteName, remoteUrl, moduleName, scope = "default") => 34 | async () => { 35 | if (!(remoteName in window)) { 36 | // Need to load the remote first 37 | // Initializes the shared scope. Fills it with known provided modules from this build and all remotes 38 | // eslint-disable-next-line no-undef 39 | await __webpack_init_sharing__(scope); // TODO when would you use a different scope? 40 | const fetchedContainer = await fetchRemote( 41 | `${remoteUrl.replace(/\/$/, "")}/remoteEntry.js`, 42 | remoteName 43 | ); 44 | // eslint-disable-next-line no-undef 45 | await fetchedContainer.init(__webpack_share_scopes__[scope]); 46 | } 47 | const container = window[remoteName]; // Assuming the remote has been loaded using the above function 48 | const factory = await container.get(moduleName); 49 | const Module = factory(); 50 | return Module; 51 | }; 52 | -------------------------------------------------------------------------------- /cart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cart", 3 | "version": "0.1.0", 4 | "private": true, 5 | "peerDependencies": { 6 | "react": "*", 7 | "react-dom": "*" 8 | }, 9 | "dependencies": { 10 | "@mui/icons-material": "^5.14.3", 11 | "@mui/material": "^5.14.5", 12 | "@mui/styled-engine-sc": "^5.12.0", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-router-dom": "^6.15.0", 16 | "styled-components": "^6.0.7", 17 | "zustand": "^4.4.1" 18 | }, 19 | "devDependencies": { 20 | "@babel/cli": "^7.22.10", 21 | "@babel/core": "^7.22.10", 22 | "@babel/plugin-transform-runtime": "^7.22.10", 23 | "@babel/plugin-transform-typescript": "^7.22.10", 24 | "@babel/preset-env": "^7.22.10", 25 | "@babel/preset-react": "^7.22.5", 26 | "@babel/preset-typescript": "^7.22.5", 27 | "@testing-library/jest-dom": "^6.0.0", 28 | "@testing-library/react": "^14.0.0", 29 | "@testing-library/user-event": "^14.4.3", 30 | "@types/jest": "^29.5.3", 31 | "@types/node": "^20.5.0", 32 | "@types/react": "^18.2.20", 33 | "@types/react-dom": "^18.2.7", 34 | "babel-loader": "^9.1.3", 35 | "dotenv-webpack": "^8.0.1", 36 | "html-webpack-plugin": "^5.5.3", 37 | "react-scripts": "5.0.1", 38 | "ts-loader": "^9.4.4", 39 | "typescript": "^5.1.6", 40 | "web-vitals": "^3.4.0", 41 | "webpack": "^5.88.2", 42 | "webpack-cli": "^5.1.4", 43 | "webpack-dev-server": "^4.15.1", 44 | "webpack-server": "^0.1.2" 45 | }, 46 | "scripts": { 47 | "cra-start": "react-scripts start", 48 | "cra-build": "react-scripts build", 49 | "test": "react-scripts test", 50 | "eject": "react-scripts eject", 51 | "start": "webpack serve --env CART_HOST=http://localhost:9003 --env PRODUCTS_HOST=http://localhost:9002", 52 | "build": "webpack build" 53 | }, 54 | "eslintConfig": { 55 | "extends": [ 56 | "react-app", 57 | "react-app/jest" 58 | ] 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /cart/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /main/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /products/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /products/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); 3 | const Dotenv = require("dotenv-webpack"); 4 | const { dependencies } = require("./package.json"); 5 | 6 | module.exports = (env) => { 7 | const CART_HOST = env.CART_HOST || "http://localhost:9002"; 8 | return { 9 | mode: "development", 10 | devServer: { 11 | port: 9002, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|jsx|ts|tsx)?$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: "babel-loader", 21 | options: { 22 | presets: ["@babel/preset-env", "@babel/preset-react"], 23 | }, 24 | }, 25 | ], 26 | }, 27 | { 28 | test: /\.(ts|tsx)?$/, 29 | exclude: /node_modules/, 30 | use: [ 31 | { 32 | loader: "ts-loader", 33 | }, 34 | ], 35 | }, 36 | { 37 | test: /\.(jpe?g|png|svg)$/, 38 | loader: "file-loader", 39 | options: { 40 | name: "[path][name].[hash].[ext]", 41 | }, 42 | }, 43 | ], 44 | }, 45 | plugins: [ 46 | new Dotenv(), 47 | new ModuleFederationPlugin({ 48 | name: "PRODUCTS", 49 | filename: "remoteEntry.js", 50 | remotes: { 51 | CART: `CART@${CART_HOST}/remoteEntry.js`, 52 | }, 53 | exposes: { 54 | "./ProductsList": "./src/components/ProductsList", 55 | "./ProductCard": "./src/components/ProductCard", 56 | "./products": "./src/products", 57 | }, 58 | shared: { 59 | ...dependencies, 60 | react: { 61 | eager: true, 62 | singleton: true, 63 | requiredVersion: dependencies["react"], 64 | }, 65 | "react-dom": { 66 | eager: true, 67 | singleton: true, 68 | requiredVersion: dependencies["react-dom"], 69 | }, 70 | }, 71 | }), 72 | new HtmlWebpackPlugin({ 73 | template: "./public/index.html", 74 | }), 75 | ], 76 | resolve: { 77 | extensions: [".js", ".jsx", ".ts", ".tsx"], 78 | }, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /main/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); 3 | const Dotenv = require("dotenv-webpack"); 4 | const webpack = require("webpack"); 5 | const { dependencies } = require("./package.json"); 6 | 7 | module.exports = (env) => { 8 | const PRODUCTS_HOST = env.PRODUCTS_HOST || "http://localhost:9002"; 9 | const CART_HOST = env.CART_HOST || "http://localhost:9002"; 10 | return { 11 | mode: "development", 12 | devServer: { 13 | port: 9001, 14 | historyApiFallback: { index: "/", disableDotRule: true }, 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.(js|jsx|ts|tsx)?$/, 20 | exclude: /node_modules/, 21 | use: [ 22 | { 23 | loader: "babel-loader", 24 | options: { 25 | presets: ["@babel/preset-env", "@babel/preset-react"], 26 | plugins: ["@babel/plugin-transform-runtime"], 27 | }, 28 | }, 29 | ], 30 | }, 31 | { 32 | test: /\.(ts|tsx)?$/, 33 | exclude: /node_modules/, 34 | use: [ 35 | { 36 | loader: "ts-loader", 37 | }, 38 | ], 39 | }, 40 | { 41 | test: /\.css$/i, 42 | use: ["style-loader", "css-loader"], 43 | }, 44 | ], 45 | }, 46 | plugins: [ 47 | new Dotenv(), 48 | new ModuleFederationPlugin({ 49 | name: "MAIN", 50 | remotes: { 51 | // CART: `CART@${CART_HOST}/remoteEntry.js`, 52 | // PRODUCTS: `PRODUCTS@${PRODUCTS_HOST}/remoteEntry.js`, 53 | }, 54 | shared: { 55 | ...dependencies, 56 | react: { 57 | eager: true, 58 | singleton: true, 59 | requiredVersion: dependencies["react"], 60 | }, 61 | "react-dom": { 62 | eager: true, 63 | singleton: true, 64 | requiredVersion: dependencies["react-dom"], 65 | }, 66 | }, 67 | }), 68 | new HtmlWebpackPlugin({ 69 | template: "./public/index.html", 70 | }), 71 | new webpack.DefinePlugin({ 72 | "process.env.PRODUCTS_HOST": JSON.stringify(PRODUCTS_HOST), 73 | "process.env.CART_HOST": JSON.stringify(CART_HOST), 74 | }), 75 | ], 76 | resolve: { 77 | extensions: [".js", ".jsx", ".ts", ".tsx"], 78 | }, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /main/src/context/remotes.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useContext, FC } from "react"; 2 | 3 | export interface Remote { 4 | name: string; 5 | url?: string; 6 | } 7 | 8 | export interface Remotes { 9 | remotes: Remote[]; 10 | updateRemoteUrl: (name: string, newUrl: string) => void; 11 | } 12 | 13 | const initRemotes = [ 14 | { name: "PRODUCTS", url: process.env.PRODUCTS_HOST || "" }, 15 | { name: "CART", url: process.env.CART_HOST || "" }, 16 | ]; 17 | 18 | const initState: Remotes = { 19 | remotes: initRemotes, 20 | updateRemoteUrl: () => {}, 21 | }; 22 | 23 | export const STORAGE_KEY = "fruit-remotes"; 24 | 25 | const storeRemotes = (remotes: Remote[]) => { 26 | localStorage.setItem(STORAGE_KEY, JSON.stringify(remotes)); 27 | }; 28 | 29 | const hydrateRemotes = (): Remote[] => { 30 | try { 31 | const raw = localStorage.getItem(STORAGE_KEY); 32 | if (raw) { 33 | const parsed = JSON.parse(raw); 34 | if (Array.isArray(parsed)) { 35 | return parsed; 36 | } 37 | } 38 | return []; 39 | } catch { 40 | return []; 41 | } 42 | }; 43 | 44 | export const RemotesContext = createContext(initState); 45 | 46 | export const RemotesProvider: FC<{ 47 | children: any; 48 | }> = ({ children }) => { 49 | const storedRemotes = hydrateRemotes(); 50 | const [remotes, setRemotes] = useState( 51 | storedRemotes.length > 0 ? storedRemotes : initRemotes 52 | ); 53 | const updateRemoteUrl = (name: string, newUrl: string): void => { 54 | setRemotes((prevRemotes) => { 55 | const newRemotes = [...prevRemotes]; 56 | const remoteIdx = newRemotes.findIndex((r) => r.name === name); 57 | if (remoteIdx > -1) { 58 | newRemotes[remoteIdx].url = newUrl; 59 | } 60 | storeRemotes(newRemotes); 61 | return newRemotes; 62 | }); 63 | }; 64 | // Storing remote URLs on global object so we can access them in other apps without 65 | // this provider being initialized. 66 | // @ts-ignore 67 | window.fsRemotes = remotes; 68 | const RemotesCtx: Remotes = { 69 | remotes, 70 | updateRemoteUrl, 71 | }; 72 | return ( 73 | 74 | {children} 75 | 76 | ); 77 | }; 78 | export default RemotesProvider; 79 | 80 | export const useRemotes = (): [ 81 | Remote[], 82 | (name: string, newUrl: string) => void 83 | ] => { 84 | const { updateRemoteUrl } = useContext(RemotesContext); 85 | // @ts-ignore 86 | return [window.fsRemotes, updateRemoteUrl]; 87 | }; 88 | -------------------------------------------------------------------------------- /cart/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /products/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cart/src/components/CheckoutPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | // @ts-ignore 3 | import { useNavigate } from "react-router-dom"; 4 | import Button from "@mui/material/Button"; 5 | import useStore from "../../store"; 6 | import RemoteComponent from "../../../../main/src/components/RemoteComponent"; 7 | // Importing components and list of products from products app directly 8 | // to simplify import/export. Products list should be taken care of by a communication layer 9 | // And the ProductCard should be a published npm package or shared remote but need 10 | // to figure out how to load multiple versions of the same remote in 1 app. 11 | import products from "../../../../products/src/products"; 12 | 13 | type PriceMap = Record; 14 | // @ts-ignore 15 | const priceMap: PriceMap = products.reduce((acc: PriceMap, cur: Product) => { 16 | return { ...acc, [cur.name]: cur.price }; 17 | }, {}); 18 | 19 | type Props = { 20 | onClose: () => void; 21 | }; 22 | 23 | type Product = { 24 | name: string; 25 | price?: number; 26 | quantity?: number; 27 | }; 28 | 29 | // TODO: How to do in-browser routing if using different verisons of react-router-dom 30 | export const CheckoutPanel: FC = ({ onClose }) => { 31 | // @ts-ignore 32 | const cart = useStore((store) => store.cart); 33 | const navigate = useNavigate(); 34 | let total = 0; 35 | for (let i = 0; i < cart.length; i++) { 36 | const product: Product = cart[i]; 37 | total = total + (product?.quantity || 1) * priceMap[product.name]; 38 | } 39 | return ( 40 |
41 |

Cart

42 |
43 | 52 |
53 |

Total: ${total.toFixed(2)}

54 |
55 | {cart.map(({ name, quantity }) => ( 56 |
57 | 66 |
67 | ))} 68 | {cart.length === 0 &&

No items in cart

} 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default CheckoutPanel; 75 | -------------------------------------------------------------------------------- /main/src/components/RemoteControls/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import List from "@mui/material/List"; 3 | import Cached from "@mui/icons-material/Cached"; 4 | import Launch from "@mui/icons-material/Launch"; 5 | import ListItem from "@mui/material/ListItem"; 6 | import Button from "@mui/material/Button"; 7 | import TextField from "@mui/material/TextField"; 8 | import { useRemotes, STORAGE_KEY } from "../../context/remotes"; 9 | import { findRemoteUrl } from "../../utils/remote"; 10 | 11 | export const RemoteControls = () => { 12 | const [remotes, updateRemoteUrl] = useRemotes(); 13 | const [productsUrl, setProductsUrl] = useState( 14 | findRemoteUrl("PRODUCTS", remotes) 15 | ); 16 | const [cartUrl, setCartUrl] = useState(findRemoteUrl("CART", remotes)); 17 | const handleProductsChange = (e: React.ChangeEvent) => { 18 | setProductsUrl(e.target.value); 19 | }; 20 | const handleCartChange = (e: React.ChangeEvent) => { 21 | setCartUrl(e.target.value); 22 | }; 23 | const handleReloadApp = () => { 24 | updateRemoteUrl("PRODUCTS", productsUrl); 25 | updateRemoteUrl("CART", cartUrl); 26 | window.location.reload(); 27 | }; 28 | const handleReset = () => { 29 | localStorage.removeItem(STORAGE_KEY); 30 | window.location.reload(); 31 | }; 32 | return ( 33 | 34 | 35 | 43 | 49 | 57 | 58 | 59 | 60 | 68 | 74 | 82 | 83 | 84 | 85 | 95 | 104 | 105 | ); 106 | }; 107 | 108 | export default RemoteControls; 109 | -------------------------------------------------------------------------------- /cart/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import create from "zustand"; 2 | 3 | type Product = { 4 | name: string; 5 | quantity?: number; 6 | }; 7 | 8 | interface State { 9 | cart: Array; 10 | addItem: (item: Product) => void; 11 | removeItem: (item: Product["name"], quantity?: number) => void; 12 | } 13 | 14 | const validItem = (item: Product) => { 15 | return !!item && typeof item === "object" && typeof item?.name === "string"; 16 | }; 17 | const STORAGE_ID = "fruit-shop-cart"; 18 | 19 | // Global event bus for communicating between micro apps 20 | // @ts-ignore 21 | const eventBus = window.fsEvents; 22 | 23 | const getLocalState = (): Array => { 24 | const rawData = localStorage.getItem(STORAGE_ID); 25 | if (rawData) { 26 | const parsed = JSON.parse(rawData); 27 | if (Array.isArray(parsed)) { 28 | return parsed; 29 | } 30 | } 31 | return []; 32 | }; 33 | 34 | const updateLocalState = (newCart: Array): void => { 35 | eventBus.publish("cart", { cart: newCart }); 36 | localStorage.setItem(STORAGE_ID, JSON.stringify(newCart)); 37 | }; 38 | 39 | // Initialize event bus with cart data 40 | if (eventBus) { 41 | eventBus.subscribe("addItem", function (item: Product) { 42 | const { getState } = store; 43 | const { addItem } = getState(); 44 | addItem(item); 45 | }); 46 | eventBus.subscribe("removeItem", function (item: Product) { 47 | const { getState } = store; 48 | const { removeItem } = getState(); 49 | removeItem(item.name, item?.quantity); 50 | }); 51 | eventBus.publish("cart", { cart: getLocalState() }); 52 | } 53 | 54 | export const store = create((set) => ({ 55 | cart: getLocalState(), 56 | addItem: (newItem) => { 57 | if (validItem(newItem)) { 58 | set((state) => { 59 | const cart = [...state.cart]; 60 | const existingIndex = cart.findIndex( 61 | (item) => item.name === newItem.name 62 | ); 63 | if (existingIndex > -1) { 64 | const existing = cart[existingIndex]; 65 | cart[existingIndex] = { 66 | ...existing, 67 | quantity: (existing?.quantity || 0) + 1, 68 | }; 69 | } else { 70 | cart.push({ quantity: 1, ...newItem }); 71 | } 72 | updateLocalState(cart); 73 | return { cart }; 74 | }); 75 | } 76 | }, 77 | removeItem: (itemName, quantity = 1) => { 78 | if (itemName && typeof itemName === "string") { 79 | set((state) => { 80 | const cart = [...state.cart]; 81 | const existingIndex = cart.findIndex((item) => item.name === itemName); 82 | if (existingIndex > -1) { 83 | const existing = cart[existingIndex]; 84 | if (!existing?.quantity || existing?.quantity <= quantity) { 85 | cart.splice(existingIndex, 1); 86 | } else { 87 | cart[existingIndex] = { 88 | ...existing, 89 | quantity: existing.quantity - quantity, 90 | }; 91 | } 92 | } 93 | updateLocalState(cart); 94 | return { cart }; 95 | }); 96 | } 97 | }, 98 | })); 99 | export const useStore = store; 100 | 101 | export const useCartCount = (product?: string): number => { 102 | const products = useStore((store) => store.cart); 103 | const count = products.reduce((acc, cur) => { 104 | let curCount = acc; 105 | if (!product || product === cur.name) { 106 | curCount += cur?.quantity || 0; 107 | } 108 | return curCount; 109 | }, 0); 110 | return count || 0; 111 | }; 112 | 113 | export default useStore; 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micro Frontend Demo 2 | 3 | A project for demoing a micro frontend architecture in React with dynamic remote modules. 4 | 5 | Check out the live version: [https://micro-frontend-demo-main.vercel.app/](https://micro-frontend-demo-main.vercel.app/) 6 | 7 | ![Micro Frontend Fruit Store](./docs/demo.png) 8 | 9 | ## Getting started 10 | 11 | 1. Run: `yarn start` 12 | 2. Navigate to `http://localhost:9001/` 13 | 14 | Main Host App: `http://localhost:9001/` 15 | Products Remote: `http://localhost:9002/` 16 | Cart Remote: `http://localhost:9003/` 17 | 18 | ## Demo 19 | 20 | Once you have the local app running go to the live instance [https://micro-frontend-demo-main.vercel.app/](https://micro-frontend-demo-main.vercel.app/) and change the URLs for the Products or Cart remote to the localhost version. Make some code changes and watch as the production version of `main` reflects your local changes! 21 | 22 | Of course, this is only visible to you but this pattern opens the door for a lot of interesting and helpful developer experiences. 23 | 24 | ## Dynamic Remotes 25 | 26 | We are taking advantage of Webpack Module Federation's [Dynamic Remote Containers](https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers) to dynamically update the remote apps within a React micro frontend. 27 | 28 | This works by injecting a script tag into the DOM to fetch the remote app at run time using the fetchRemote function found in: `main/src/utils/index.js` 29 | 30 | RemoteComponent is a React component we can re-use throughout our application to render modules from a remote app. It encapsulates: 31 | 32 | ErrorBoundary to safely render remote code without breaking our host app. 33 | Lazy loading using React.Lazy to fetch and resolve the remote code as needed without blocking the rest of our app rendering. 34 | Fetching and managing remote containers. 35 | 36 | ```javascript 37 | 45 | ``` 46 | 47 | The implementation can be found in: `main/src/components/RemoteComponent/index.tsx` 48 | 49 | Within the implementation we call a loadComponent function that acts as a middle man between the RemoteComponent and fetchRemote functions to manage our loaded remote containers. 50 | 51 | ## Tech Stack 52 | 53 | - [Turborepo](https://turborepo.org/) 54 | - React 55 | - Typescript 56 | - Webpack v5 (w/ Module Federation) 57 | 58 | ## Iterations 59 | 60 | - Dynamic remote containers: https://github.com/rautio/micro-frontend-demo/tree/dynamic-remote-react 61 | - Zustand global state: https://github.com/rautio/micro-frontend-demo/tree/zustand-remote-state 62 | - Deploy using environment variables: https://github.com/rautio/micro-frontend-demo/tree/env-variable-deploy 63 | 64 | ## Related Blog Posts 65 | 66 | - How to use Webpack Module Federation in React: https://betterprogramming.pub/how-to-use-webpack-module-federation-in-react-70455086b2b0 67 | - Zustand in a Micro Frontend: https://betterprogramming.pub/zustand-in-a-micro-frontend-b92d02a51577 68 | 69 | ### Rendering Remote React Components 70 | 71 | To safely load react components you need an `ErrorBoundary` and should wrap the fetch logic within lazy loading and optionally use `React.Suspense` to render a friendly loading message. 72 | 73 | ```javacsript 74 | const RemoteComponent = React.lazy(() => import("Remote/Component")); 75 | 76 | export const App = () => ( 77 | 78 | 79 | 80 | 81 | 82 | ) 83 | 84 | ``` 85 | -------------------------------------------------------------------------------- /products/src/components/ProductCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from "react"; 2 | import Card from "@mui/material/Card"; 3 | import CardActions from "@mui/material/CardActions"; 4 | import CardContent from "@mui/material/CardContent"; 5 | import Button from "@mui/material/Button"; 6 | import IconButton from "@mui/material/IconButton"; 7 | import Typography from "@mui/material/Typography"; 8 | import { 9 | AddShoppingCart, 10 | Check, 11 | Add, 12 | Remove, 13 | Delete, 14 | } from "@mui/icons-material"; 15 | import Fruit from "../Fruit"; 16 | 17 | interface Props { 18 | name: string; 19 | price: number; 20 | cartView?: boolean; 21 | } 22 | 23 | interface Product { 24 | name: string; 25 | quantity?: number; 26 | } 27 | 28 | // @ts-ignore 29 | const events = window.fsEvents; 30 | 31 | export const ProductCard: FC = ({ name, price, cartView = false }) => { 32 | const [cart, setCart] = useState<{ name: string; quantity: number }[]>([]); 33 | useEffect(() => { 34 | // @ts-ignore 35 | const subID = events?.subscribe("cart", function ({ cart }) { 36 | setCart(cart); 37 | }); 38 | return () => { 39 | if (subID) { 40 | events?.unsubscribe(subID); 41 | } 42 | }; 43 | }, []); 44 | let action = ( 45 | 56 | ); 57 | const itemIndex = cart.findIndex((product: Product) => product.name === name); 58 | const isItemInCart = itemIndex > -1; 59 | if (isItemInCart) { 60 | const { quantity } = cart[itemIndex]; 61 | action = ( 62 |
69 |
70 | { 72 | events?.publish("removeItem", { name, quantity: 1 }); 73 | }} 74 | > 75 | 76 | 77 | {quantity} 78 | { 80 | events?.publish("addItem", { name }); 81 | }} 82 | > 83 | 84 | 85 |
86 | 95 |
96 | ); 97 | } 98 | 99 | return ( 100 | 107 | 108 | 109 | {name}{" "} 110 | {isItemInCart && !cartView && } 111 | 112 |
113 | 114 |
115 | 116 | ${(price || 0).toFixed(2)} 117 | 118 |
119 | {action} 120 |
121 | ); 122 | }; 123 | 124 | export default ProductCard; 125 | -------------------------------------------------------------------------------- /main/src/services/pubsub.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { v4 as uuid, validate as validateUUID } from "uuid"; 3 | 4 | type Topic = string; 5 | type Message = Record; 6 | type ID = string; 7 | type OnMessageFn = (message: Message) => void; 8 | 9 | export type Subscribe = (topic: Topic, onMessage: OnMessageFn) => ID; 10 | export type Publish = (topic: Topic, message: Record) => void; 11 | export type UnSusbscribe = (id: ID) => void; 12 | 13 | export interface Events { 14 | subscribe: Subscribe; 15 | publish: Publish; 16 | unsubscribe: UnSusbscribe; 17 | } 18 | 19 | export class PubSub { 20 | constructor({ persistedTopics }: { persistedTopics?: Topic[] } = {}) { 21 | if (persistedTopics && !Array.isArray(persistedTopics)) { 22 | throw new Error("Persisted topics must be an array of topics."); 23 | } 24 | if (persistedTopics) { 25 | this.persistedMessages = persistedTopics.reduce( 26 | (acc: Record, cur: Topic) => { 27 | acc[cur] = {}; 28 | return acc; 29 | }, 30 | {} 31 | ); 32 | } 33 | this.subscribe.bind(this); 34 | this.publish.bind(this); 35 | } 36 | // Keep track of all onMessage listeners with easy lookup by subscription id 37 | private subscriberOnMsg: Record = {}; 38 | // Keep track of the topic for each subscription id for easier cleanup 39 | private subscriberTopics: Record = {}; 40 | // Keep track of all topics and subscriber ids for each topic 41 | private topics: Record = {}; 42 | // Keep track of messages that are persisted and sent to new subscribers 43 | private persistedMessages: Record = {}; 44 | 45 | /** 46 | * Subscribe to messages being published in the given topic. 47 | * @param topic Name of the channel/topic where messages are published. 48 | * @param onMessage Function called whenever new messages on the topic are published. 49 | * @returns ID of this subscription. 50 | */ 51 | public subscribe(topic: Topic, onMessage: OnMessageFn): ID { 52 | // Validate inputs 53 | if (typeof topic !== "string") throw new Error("Topic must be a string."); 54 | if (typeof onMessage !== "function") 55 | throw new Error("onMessage must be a function."); 56 | // Each subscription has a unique id 57 | const subID = uuid(); 58 | if (!(topic in this.topics)) { 59 | // New topic 60 | this.topics[topic] = [subID]; 61 | } else { 62 | // Topic exists 63 | this.topics[topic].push(subID); 64 | } 65 | // Store onMessage and topic separately for faster lookup 66 | this.subscriberOnMsg[subID] = onMessage; 67 | this.subscriberTopics[subID] = topic; 68 | // If the topic is persisted and there are existing messages, trigger the onMessage handler immediately 69 | if (topic in this.persistedMessages) { 70 | onMessage(this.persistedMessages[topic]); 71 | } 72 | return subID; 73 | } 74 | 75 | /** 76 | * Unsusbscribe for a given subscription id. 77 | * @param id Subscription id 78 | */ 79 | public unsubscribe(id: ID): void { 80 | // Validate inputs 81 | if (typeof id !== "string" || !validateUUID(id)) { 82 | throw new Error("ID must be a valid UUID."); 83 | } 84 | // If the id exists in our subscriptions then clear it. 85 | if (id in this.subscriberOnMsg && id in this.subscriberTopics) { 86 | // Delete message listener 87 | delete this.subscriberOnMsg[id]; 88 | // Remove id from the topics tracker 89 | const topic = this.subscriberTopics[id]; 90 | // Cleanup topics 91 | if (topic && topic in this.topics) { 92 | const idx = this.topics[topic].findIndex((tID) => tID === id); 93 | if (idx > -1) { 94 | this.topics[topic].splice(idx, 1); 95 | } 96 | // If there are no more listeners clean up the topic as well 97 | if (this.topics[topic].length === 0) { 98 | delete this.topics[topic]; 99 | } 100 | } 101 | // Delete the topic for this id 102 | delete this.subscriberTopics[id]; 103 | } 104 | } 105 | 106 | /** 107 | * Publish messages on a topic for all subscribers to receive. 108 | * @param topic The topic where the message is sent. 109 | * @param message The message to send. Only object format is supported. 110 | */ 111 | public publish(topic: Topic, message: Record) { 112 | if (typeof topic !== "string") throw new Error("Topic must be a string."); 113 | if (typeof message !== "object") { 114 | throw new Error("Message must be an object."); 115 | } 116 | // If topic exists post messages 117 | if (topic in this.topics) { 118 | const subIDs = this.topics[topic]; 119 | subIDs.forEach((id) => { 120 | if (id in this.subscriberOnMsg) { 121 | this.subscriberOnMsg[id](message); 122 | } 123 | }); 124 | } 125 | if (topic in this.persistedMessages) { 126 | this.persistedMessages[topic] = message; 127 | } 128 | } 129 | } 130 | 131 | export default PubSub; 132 | --------------------------------------------------------------------------------