create-interview-cockpit 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/components/CanvasLabModal.tsx +585 -0
- package/template/client/src/components/LabsPanel.tsx +63 -0
- package/template/client/src/components/Sidebar.tsx +12 -1
- package/template/client/src/components/WorkspaceSwitcher.tsx +36 -0
- package/template/client/src/reactLab.ts +1206 -0
- package/template/client/src/store.ts +24 -1
- package/template/client/src/types.ts +3 -1
- package/template/client/vite.config.ts +15 -8
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +4 -1
- package/template/server/src/index.ts +63 -6
- package/template/server/src/storage.ts +1 -0
|
@@ -1551,6 +1551,1212 @@ export const ISOLATED_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
|
|
|
1551
1551
|
files: MODULE_FEDERATION_ISOLATED_FILES,
|
|
1552
1552
|
};
|
|
1553
1553
|
|
|
1554
|
+
// ─── Next.js MF Option A — @module-federation/nextjs-mf plugin ──────────────
|
|
1555
|
+
const NEXTJS_MF_PLUGIN_FILES: Record<string, string> = {
|
|
1556
|
+
"README.md": `# Next.js Module Federation — Option A: built-in webpack ModuleFederationPlugin
|
|
1557
|
+
|
|
1558
|
+
## What is this
|
|
1559
|
+
|
|
1560
|
+
A minimal Next.js shell that loads a remote Next.js app using webpack's built-in
|
|
1561
|
+
\`ModuleFederationPlugin\` — no extra npm packages needed.
|
|
1562
|
+
Both shell and remote are Next.js apps that configure the plugin inside their \`next.config.js\`.
|
|
1563
|
+
|
|
1564
|
+
## Structure
|
|
1565
|
+
|
|
1566
|
+
- \`apps/shell/\` — Next.js shell app
|
|
1567
|
+
- \`apps/remote/\` — Next.js remote app that exposes a widget
|
|
1568
|
+
|
|
1569
|
+
Ports are assigned automatically by the lab runner (HOST_PORT for shell, REMOTE_PORT for remote).
|
|
1570
|
+
|
|
1571
|
+
## Key idea
|
|
1572
|
+
|
|
1573
|
+
The shell's \`next.config.js\` registers \`webpack.container.ModuleFederationPlugin\` with no
|
|
1574
|
+
static remotes — the remote URL is resolved at runtime via the \`RemoteSlot\` component.
|
|
1575
|
+
No \`@module-federation/nextjs-mf\` package is required.
|
|
1576
|
+
|
|
1577
|
+
## Things to try
|
|
1578
|
+
|
|
1579
|
+
1. Stop the remote and reload the shell — observe the fallback UI.
|
|
1580
|
+
2. Add a second exposed component in the remote's \`next.config.js\` and load it from the shell.
|
|
1581
|
+
3. Change the entry URL to point to a CDN-hosted remoteEntry.js.
|
|
1582
|
+
`,
|
|
1583
|
+
"package.json": `{
|
|
1584
|
+
"name": "nextjs-mf-plugin-lab",
|
|
1585
|
+
"private": true,
|
|
1586
|
+
"workspaces": [
|
|
1587
|
+
"apps/shell",
|
|
1588
|
+
"apps/remote"
|
|
1589
|
+
],
|
|
1590
|
+
"scripts": {
|
|
1591
|
+
"dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=nextjs-mf-remote' 'npm run dev --workspace=nextjs-mf-shell'"
|
|
1592
|
+
},
|
|
1593
|
+
"devDependencies": {
|
|
1594
|
+
"concurrently": "^9.2.1"
|
|
1595
|
+
},
|
|
1596
|
+
"overrides": {
|
|
1597
|
+
"undici": "^7"
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
`,
|
|
1601
|
+
"apps/shell/package.json": `{
|
|
1602
|
+
"name": "nextjs-mf-shell",
|
|
1603
|
+
"private": true,
|
|
1604
|
+
"scripts": {
|
|
1605
|
+
"dev": "next dev -p $HOST_PORT",
|
|
1606
|
+
"build": "next build",
|
|
1607
|
+
"start": "next start"
|
|
1608
|
+
},
|
|
1609
|
+
"dependencies": {
|
|
1610
|
+
"next": "latest",
|
|
1611
|
+
"react": "^19.0.0",
|
|
1612
|
+
"react-dom": "^19.0.0"
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
`,
|
|
1616
|
+
"apps/shell/next.config.js": `/** @type {import('next').NextConfig} */
|
|
1617
|
+
const nextConfig = {
|
|
1618
|
+
reactStrictMode: true,
|
|
1619
|
+
// Use webpack's built-in ModuleFederationPlugin — no extra npm package needed.
|
|
1620
|
+
webpack(config, { webpack }) {
|
|
1621
|
+
config.plugins.push(
|
|
1622
|
+
new webpack.container.ModuleFederationPlugin({
|
|
1623
|
+
name: 'shell',
|
|
1624
|
+
// Shell exposes nothing, just consumes remotes at runtime.
|
|
1625
|
+
remotes: {},
|
|
1626
|
+
shared: {
|
|
1627
|
+
react: { singleton: true, requiredVersion: false },
|
|
1628
|
+
'react-dom': { singleton: true, requiredVersion: false },
|
|
1629
|
+
},
|
|
1630
|
+
})
|
|
1631
|
+
);
|
|
1632
|
+
return config;
|
|
1633
|
+
},
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
module.exports = nextConfig;
|
|
1637
|
+
`,
|
|
1638
|
+
"apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
|
|
1639
|
+
import React from 'react';
|
|
1640
|
+
|
|
1641
|
+
export const metadata: Metadata = { title: 'Shell — Next.js MF' };
|
|
1642
|
+
|
|
1643
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1644
|
+
return (
|
|
1645
|
+
<html lang="en">
|
|
1646
|
+
<body>{children}</body>
|
|
1647
|
+
</html>
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
`,
|
|
1651
|
+
"apps/shell/src/app/page.tsx": `'use client';
|
|
1652
|
+
|
|
1653
|
+
import React from 'react';
|
|
1654
|
+
import { RemoteSlot } from '@/components/RemoteSlot';
|
|
1655
|
+
|
|
1656
|
+
export default function Home() {
|
|
1657
|
+
return (
|
|
1658
|
+
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
|
1659
|
+
<h1>Next.js Shell — Option A</h1>
|
|
1660
|
+
<p style={{ color: '#64748b' }}>
|
|
1661
|
+
The widget below is loaded from the Next.js remote at runtime.
|
|
1662
|
+
Shell and remote both use Next.js with webpack's built-in ModuleFederationPlugin.
|
|
1663
|
+
</p>
|
|
1664
|
+
|
|
1665
|
+
{/* NEXT_PUBLIC_REMOTE_URL is injected by the lab runner */}
|
|
1666
|
+
<RemoteSlot
|
|
1667
|
+
scope="myRemote"
|
|
1668
|
+
module="./Widget"
|
|
1669
|
+
remoteUrl={
|
|
1670
|
+
process.env.NEXT_PUBLIC_REMOTE_URL ||
|
|
1671
|
+
'http://localhost:3001/_next/static/chunks/remoteEntry.js'
|
|
1672
|
+
}
|
|
1673
|
+
fallback={<p style={{ color: '#ef4444' }}>Remote unavailable.</p>}
|
|
1674
|
+
/>
|
|
1675
|
+
</main>
|
|
1676
|
+
);
|
|
1677
|
+
}
|
|
1678
|
+
`,
|
|
1679
|
+
"apps/shell/src/components/RemoteSlot.tsx": `'use client';
|
|
1680
|
+
|
|
1681
|
+
import React from 'react';
|
|
1682
|
+
|
|
1683
|
+
type Props = {
|
|
1684
|
+
scope: string;
|
|
1685
|
+
module: string;
|
|
1686
|
+
remoteUrl: string;
|
|
1687
|
+
fallback?: React.ReactNode;
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
// Webpack federation globals — available because shell's next.config.js
|
|
1691
|
+
// registers ModuleFederationPlugin which injects them at runtime.
|
|
1692
|
+
declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
|
|
1693
|
+
declare const __webpack_share_scopes__: { default: unknown };
|
|
1694
|
+
|
|
1695
|
+
async function loadRemoteModule(scope: string, module: string, remoteUrl: string) {
|
|
1696
|
+
// 1. Inject the remote's remoteEntry.js once.
|
|
1697
|
+
if (!(window as Record<string, unknown>)[scope]) {
|
|
1698
|
+
await new Promise<void>((resolve, reject) => {
|
|
1699
|
+
const script = document.createElement('script');
|
|
1700
|
+
script.src = remoteUrl;
|
|
1701
|
+
script.async = true;
|
|
1702
|
+
script.onload = () => resolve();
|
|
1703
|
+
script.onerror = () => reject(new Error(\`Failed to load remote: \${scope}\`));
|
|
1704
|
+
document.head.appendChild(script);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// 2. Init the shared scope so singleton packages (react) are negotiated.
|
|
1709
|
+
await __webpack_init_sharing__('default');
|
|
1710
|
+
|
|
1711
|
+
const container = (window as Record<string, unknown>)[scope] as {
|
|
1712
|
+
init(s: unknown): Promise<void>;
|
|
1713
|
+
get(m: string): Promise<() => unknown>;
|
|
1714
|
+
};
|
|
1715
|
+
|
|
1716
|
+
await container.init(__webpack_share_scopes__.default);
|
|
1717
|
+
|
|
1718
|
+
// 3. Get the exposed module factory and invoke it.
|
|
1719
|
+
const factory = await container.get(module);
|
|
1720
|
+
return factory() as { default: React.ComponentType };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
export function RemoteSlot({ scope, module, remoteUrl, fallback }: Props) {
|
|
1724
|
+
const LazyComponent = React.useMemo(
|
|
1725
|
+
() =>
|
|
1726
|
+
React.lazy(() =>
|
|
1727
|
+
loadRemoteModule(scope, module, remoteUrl).then((mod) => ({
|
|
1728
|
+
default: mod.default,
|
|
1729
|
+
}))
|
|
1730
|
+
),
|
|
1731
|
+
[scope, module, remoteUrl]
|
|
1732
|
+
);
|
|
1733
|
+
|
|
1734
|
+
return (
|
|
1735
|
+
<React.Suspense fallback={<p>Loading remote...</p>}>
|
|
1736
|
+
<LazyComponent />
|
|
1737
|
+
</React.Suspense>
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
`,
|
|
1741
|
+
"apps/remote/package.json": `{
|
|
1742
|
+
"name": "nextjs-mf-remote",
|
|
1743
|
+
"private": true,
|
|
1744
|
+
"scripts": {
|
|
1745
|
+
"dev": "next dev -p $REMOTE_PORT",
|
|
1746
|
+
"build": "next build",
|
|
1747
|
+
"start": "next start"
|
|
1748
|
+
},
|
|
1749
|
+
"dependencies": {
|
|
1750
|
+
"next": "latest",
|
|
1751
|
+
"react": "^19.0.0",
|
|
1752
|
+
"react-dom": "^19.0.0"
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
`,
|
|
1756
|
+
"apps/remote/next.config.js": `/** @type {import('next').NextConfig} */
|
|
1757
|
+
const nextConfig = {
|
|
1758
|
+
reactStrictMode: true,
|
|
1759
|
+
webpack(config, { webpack }) {
|
|
1760
|
+
config.plugins.push(
|
|
1761
|
+
new webpack.container.ModuleFederationPlugin({
|
|
1762
|
+
name: 'myRemote',
|
|
1763
|
+
filename: 'static/chunks/remoteEntry.js',
|
|
1764
|
+
exposes: {
|
|
1765
|
+
// The shell loads this module at runtime via RemoteSlot.
|
|
1766
|
+
'./Widget': './src/exposes/Widget',
|
|
1767
|
+
},
|
|
1768
|
+
shared: {
|
|
1769
|
+
react: { singleton: true, requiredVersion: false },
|
|
1770
|
+
'react-dom': { singleton: true, requiredVersion: false },
|
|
1771
|
+
},
|
|
1772
|
+
})
|
|
1773
|
+
);
|
|
1774
|
+
return config;
|
|
1775
|
+
},
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
module.exports = nextConfig;
|
|
1779
|
+
`,
|
|
1780
|
+
"apps/remote/src/app/layout.tsx": `import type { Metadata } from 'next';
|
|
1781
|
+
import React from 'react';
|
|
1782
|
+
|
|
1783
|
+
export const metadata: Metadata = { title: 'Remote — Next.js MF' };
|
|
1784
|
+
|
|
1785
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1786
|
+
return (
|
|
1787
|
+
<html lang="en">
|
|
1788
|
+
<body>{children}</body>
|
|
1789
|
+
</html>
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
`,
|
|
1793
|
+
"apps/remote/src/app/page.tsx": `export default function Home() {
|
|
1794
|
+
return (
|
|
1795
|
+
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
|
1796
|
+
<h1>Remote App</h1>
|
|
1797
|
+
<p>
|
|
1798
|
+
This app exposes <code>myRemote/Widget</code> via Module Federation.
|
|
1799
|
+
The shell loads it at runtime — open the shell URL to see it in action.
|
|
1800
|
+
</p>
|
|
1801
|
+
</main>
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
`,
|
|
1805
|
+
"apps/remote/src/exposes/Widget.tsx": `'use client';
|
|
1806
|
+
|
|
1807
|
+
// This component is exposed at ./Widget via ModuleFederationPlugin.
|
|
1808
|
+
// The shell dynamically loads it at runtime via RemoteSlot — zero build-time coupling.
|
|
1809
|
+
export default function Widget() {
|
|
1810
|
+
return (
|
|
1811
|
+
<section
|
|
1812
|
+
style={{
|
|
1813
|
+
padding: '1rem',
|
|
1814
|
+
border: '1px solid #e2e8f0',
|
|
1815
|
+
borderRadius: '8px',
|
|
1816
|
+
background: '#f8fafc',
|
|
1817
|
+
maxWidth: '400px',
|
|
1818
|
+
}}
|
|
1819
|
+
>
|
|
1820
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#0f172a' }}>
|
|
1821
|
+
Remote Widget
|
|
1822
|
+
</h2>
|
|
1823
|
+
<p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
|
|
1824
|
+
Loaded from{' '}
|
|
1825
|
+
<code style={{ background: '#e2e8f0', padding: '0 4px', borderRadius: '4px' }}>
|
|
1826
|
+
myRemote/Widget
|
|
1827
|
+
</code>{' '}
|
|
1828
|
+
at runtime. Both shell and remote are Next.js apps.
|
|
1829
|
+
</p>
|
|
1830
|
+
</section>
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
`,
|
|
1834
|
+
};
|
|
1835
|
+
|
|
1836
|
+
export const NEXTJS_MF_PLUGIN_LAB: FrontendLabWorkspace = {
|
|
1837
|
+
version: 1,
|
|
1838
|
+
label: "Next.js MF — Plugin (Option A)",
|
|
1839
|
+
type: "module-federation",
|
|
1840
|
+
activeFile: "apps/shell/src/components/RemoteSlot.tsx",
|
|
1841
|
+
files: NEXTJS_MF_PLUGIN_FILES,
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// ─── Next.js MF Option B — plain runtime script loading (no plugin) ──────────
|
|
1845
|
+
const NEXTJS_MF_RUNTIME_FILES: Record<string, string> = {
|
|
1846
|
+
"README.md": `# Next.js Module Federation — Option B: plain runtime script loading
|
|
1847
|
+
|
|
1848
|
+
## What is this
|
|
1849
|
+
|
|
1850
|
+
The shell is a plain Next.js app with NO federation webpack plugin.
|
|
1851
|
+
The remote is a standard webpack 5 app that produces a \`remoteEntry.js\` federation container.
|
|
1852
|
+
The shell loads that container at runtime using plain DOM script injection + webpack federation globals.
|
|
1853
|
+
|
|
1854
|
+
## Why this approach
|
|
1855
|
+
|
|
1856
|
+
- No Next.js-specific federation plugin required on the shell
|
|
1857
|
+
- Full runtime control — manifest URL can come from an API at page-load time
|
|
1858
|
+
- Easier to reason about for client-side-only composition
|
|
1859
|
+
|
|
1860
|
+
## Structure
|
|
1861
|
+
|
|
1862
|
+
- \`apps/shell/\` — plain Next.js app
|
|
1863
|
+
- \`apps/remote/\` — webpack 5 React app that exposes a widget
|
|
1864
|
+
|
|
1865
|
+
Ports are assigned automatically by the lab runner (HOST_PORT for shell, REMOTE_PORT for remote).
|
|
1866
|
+
|
|
1867
|
+
## Things to try
|
|
1868
|
+
|
|
1869
|
+
1. Change the entry URL to point to a CDN-hosted remoteEntry.js.
|
|
1870
|
+
2. Add a second exposed module in apps/remote/webpack.config.js and load it in the shell.
|
|
1871
|
+
3. Move the remote URL to an API route (\`/api/remote-config\`) to simulate a manifest service.
|
|
1872
|
+
`,
|
|
1873
|
+
"package.json": `{
|
|
1874
|
+
"name": "nextjs-runtime-mf-lab",
|
|
1875
|
+
"private": true,
|
|
1876
|
+
"workspaces": [
|
|
1877
|
+
"apps/shell",
|
|
1878
|
+
"apps/remote"
|
|
1879
|
+
],
|
|
1880
|
+
"scripts": {
|
|
1881
|
+
"dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=nextjs-runtime-remote' 'npm run dev --workspace=nextjs-runtime-shell'"
|
|
1882
|
+
},
|
|
1883
|
+
"devDependencies": {
|
|
1884
|
+
"concurrently": "^9.2.1"
|
|
1885
|
+
},
|
|
1886
|
+
"overrides": {
|
|
1887
|
+
"undici": "^7"
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
`,
|
|
1891
|
+
"apps/shell/package.json": `{
|
|
1892
|
+
"name": "nextjs-runtime-shell",
|
|
1893
|
+
"private": true,
|
|
1894
|
+
"scripts": {
|
|
1895
|
+
"dev": "next dev -p $HOST_PORT",
|
|
1896
|
+
"build": "next build",
|
|
1897
|
+
"start": "next start"
|
|
1898
|
+
},
|
|
1899
|
+
"dependencies": {
|
|
1900
|
+
"next": "latest",
|
|
1901
|
+
"react": "^19.0.0",
|
|
1902
|
+
"react-dom": "^19.0.0"
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
`,
|
|
1906
|
+
"apps/shell/next.config.js": `/** @type {import('next').NextConfig} */
|
|
1907
|
+
const nextConfig = {
|
|
1908
|
+
reactStrictMode: true,
|
|
1909
|
+
// No federation plugin — the shell loads remotes entirely at runtime.
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
module.exports = nextConfig;
|
|
1913
|
+
`,
|
|
1914
|
+
"apps/shell/src/lib/loadRemoteModule.ts": `// Utility: load a webpack 5 Module Federation container at runtime.
|
|
1915
|
+
// Works in any browser environment — no webpack plugin needed on the shell side.
|
|
1916
|
+
|
|
1917
|
+
declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
|
|
1918
|
+
declare const __webpack_share_scopes__: { default: unknown };
|
|
1919
|
+
|
|
1920
|
+
type FederatedContainer = {
|
|
1921
|
+
init(shareScope: unknown): Promise<void>;
|
|
1922
|
+
get(module: string): Promise<() => unknown>;
|
|
1923
|
+
};
|
|
1924
|
+
|
|
1925
|
+
// Avoid injecting the same script twice.
|
|
1926
|
+
const loaded = new Set<string>();
|
|
1927
|
+
|
|
1928
|
+
async function injectScript(url: string, scope: string): Promise<void> {
|
|
1929
|
+
if (loaded.has(scope)) return;
|
|
1930
|
+
loaded.add(scope);
|
|
1931
|
+
|
|
1932
|
+
return new Promise((resolve, reject) => {
|
|
1933
|
+
const script = document.createElement('script');
|
|
1934
|
+
script.src = url;
|
|
1935
|
+
script.async = true;
|
|
1936
|
+
script.crossOrigin = 'anonymous';
|
|
1937
|
+
script.onload = () => resolve();
|
|
1938
|
+
script.onerror = () => {
|
|
1939
|
+
loaded.delete(scope); // allow retry
|
|
1940
|
+
reject(new Error(\`Failed to load remote entry: \${url}\`));
|
|
1941
|
+
};
|
|
1942
|
+
document.head.appendChild(script);
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
export async function loadRemoteModule<T = { default: unknown }>(
|
|
1947
|
+
scope: string,
|
|
1948
|
+
module: string,
|
|
1949
|
+
remoteUrl: string
|
|
1950
|
+
): Promise<T> {
|
|
1951
|
+
await injectScript(remoteUrl, scope);
|
|
1952
|
+
|
|
1953
|
+
// Init the shared scope so singleton packages are negotiated.
|
|
1954
|
+
await __webpack_init_sharing__('default');
|
|
1955
|
+
|
|
1956
|
+
const container = (window as Record<string, unknown>)[scope] as
|
|
1957
|
+
| FederatedContainer
|
|
1958
|
+
| undefined;
|
|
1959
|
+
|
|
1960
|
+
if (!container) {
|
|
1961
|
+
throw new Error(\`Remote container "\${scope}" not found on window after script load.\`);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
await container.init(__webpack_share_scopes__.default);
|
|
1965
|
+
|
|
1966
|
+
const factory = await container.get(module);
|
|
1967
|
+
return factory() as T;
|
|
1968
|
+
}
|
|
1969
|
+
`,
|
|
1970
|
+
"apps/shell/src/app/page.tsx": `'use client';
|
|
1971
|
+
|
|
1972
|
+
import React from 'react';
|
|
1973
|
+
import { loadRemoteModule } from '@/lib/loadRemoteModule';
|
|
1974
|
+
|
|
1975
|
+
// The remote URL would normally come from an API call / manifest service.
|
|
1976
|
+
const REMOTE_URL =
|
|
1977
|
+
process.env.NEXT_PUBLIC_REMOTE_URL ||
|
|
1978
|
+
'http://localhost:3001/remoteEntry.js';
|
|
1979
|
+
|
|
1980
|
+
// React.lazy wraps our runtime loader — Suspense handles the loading state.
|
|
1981
|
+
const RemoteWidget = React.lazy(() =>
|
|
1982
|
+
loadRemoteModule<{ default: React.ComponentType }>(
|
|
1983
|
+
'myRemote',
|
|
1984
|
+
'./Widget',
|
|
1985
|
+
REMOTE_URL
|
|
1986
|
+
).then((mod) => ({ default: mod.default }))
|
|
1987
|
+
);
|
|
1988
|
+
|
|
1989
|
+
export default function Home() {
|
|
1990
|
+
return (
|
|
1991
|
+
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
|
1992
|
+
<h1>Next.js Shell (runtime loader)</h1>
|
|
1993
|
+
<p style={{ color: '#64748b' }}>
|
|
1994
|
+
No federation plugin on the shell — the widget is loaded via plain
|
|
1995
|
+
script injection and webpack federation globals.
|
|
1996
|
+
</p>
|
|
1997
|
+
|
|
1998
|
+
<React.Suspense fallback={<p>Loading remote widget...</p>}>
|
|
1999
|
+
<RemoteWidget />
|
|
2000
|
+
</React.Suspense>
|
|
2001
|
+
</main>
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
`,
|
|
2005
|
+
"apps/remote/package.json": `{
|
|
2006
|
+
"name": "nextjs-runtime-remote",
|
|
2007
|
+
"private": true,
|
|
2008
|
+
"scripts": {
|
|
2009
|
+
"dev": "webpack serve --config webpack.config.js",
|
|
2010
|
+
"build": "webpack --config webpack.config.js"
|
|
2011
|
+
},
|
|
2012
|
+
"dependencies": {
|
|
2013
|
+
"react": "18.2.0",
|
|
2014
|
+
"react-dom": "18.2.0"
|
|
2015
|
+
},
|
|
2016
|
+
"devDependencies": {
|
|
2017
|
+
"esbuild": "^0.28.0",
|
|
2018
|
+
"esbuild-loader": "^4.4.3",
|
|
2019
|
+
"html-webpack-plugin": "^5.6.7",
|
|
2020
|
+
"webpack": "^5.106.2",
|
|
2021
|
+
"webpack-cli": "^7.0.2",
|
|
2022
|
+
"webpack-dev-server": "^5.2.3"
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
`,
|
|
2026
|
+
"apps/remote/webpack.config.js": `const path = require('path');
|
|
2027
|
+
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
2028
|
+
const { ModuleFederationPlugin } = require('webpack').container;
|
|
2029
|
+
|
|
2030
|
+
const REMOTE_PORT = Number(process.env.REMOTE_PORT || 3001);
|
|
2031
|
+
|
|
2032
|
+
module.exports = {
|
|
2033
|
+
mode: 'development',
|
|
2034
|
+
entry: './src/index.js',
|
|
2035
|
+
output: {
|
|
2036
|
+
path: path.resolve(__dirname, 'dist'),
|
|
2037
|
+
publicPath: \`http://localhost:\${REMOTE_PORT}/\`,
|
|
2038
|
+
clean: true,
|
|
2039
|
+
},
|
|
2040
|
+
resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
|
|
2041
|
+
module: {
|
|
2042
|
+
rules: [
|
|
2043
|
+
{
|
|
2044
|
+
test: /\\.(js|jsx|ts|tsx)$/,
|
|
2045
|
+
exclude: /node_modules/,
|
|
2046
|
+
use: {
|
|
2047
|
+
loader: 'esbuild-loader',
|
|
2048
|
+
options: { loader: 'jsx', jsx: 'automatic', target: 'es2020' },
|
|
2049
|
+
},
|
|
2050
|
+
},
|
|
2051
|
+
],
|
|
2052
|
+
},
|
|
2053
|
+
plugins: [
|
|
2054
|
+
new ModuleFederationPlugin({
|
|
2055
|
+
name: 'myRemote',
|
|
2056
|
+
filename: 'remoteEntry.js',
|
|
2057
|
+
exposes: {
|
|
2058
|
+
'./Widget': './src/Widget.jsx',
|
|
2059
|
+
},
|
|
2060
|
+
shared: {
|
|
2061
|
+
react: { singleton: true, requiredVersion: false },
|
|
2062
|
+
'react-dom': { singleton: true, requiredVersion: false },
|
|
2063
|
+
},
|
|
2064
|
+
}),
|
|
2065
|
+
new HtmlWebpackPlugin({ template: './public/index.html' }),
|
|
2066
|
+
],
|
|
2067
|
+
devServer: {
|
|
2068
|
+
port: REMOTE_PORT,
|
|
2069
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
2070
|
+
},
|
|
2071
|
+
};
|
|
2072
|
+
`,
|
|
2073
|
+
"apps/remote/public/index.html": `<!doctype html>
|
|
2074
|
+
<html lang="en">
|
|
2075
|
+
<head><meta charset="UTF-8" /><title>Remote (standalone)</title></head>
|
|
2076
|
+
<body><div id="root"></div></body>
|
|
2077
|
+
</html>
|
|
2078
|
+
`,
|
|
2079
|
+
"apps/remote/src/index.js": `// Async boundary required for Module Federation
|
|
2080
|
+
import('./bootstrap');
|
|
2081
|
+
`,
|
|
2082
|
+
"apps/remote/src/bootstrap.jsx": `import React from 'react';
|
|
2083
|
+
import { createRoot } from 'react-dom/client';
|
|
2084
|
+
import Widget from './Widget';
|
|
2085
|
+
|
|
2086
|
+
createRoot(document.getElementById('root')).render(<Widget />);
|
|
2087
|
+
`,
|
|
2088
|
+
"apps/remote/src/Widget.jsx": `import React from 'react';
|
|
2089
|
+
|
|
2090
|
+
// This component is exposed at ./Widget via ModuleFederationPlugin.
|
|
2091
|
+
// The shell loads it with loadRemoteModule('myRemote', './Widget', url).
|
|
2092
|
+
export default function Widget() {
|
|
2093
|
+
return (
|
|
2094
|
+
<section
|
|
2095
|
+
style={{
|
|
2096
|
+
padding: '1rem',
|
|
2097
|
+
border: '1px solid #e2e8f0',
|
|
2098
|
+
borderRadius: '8px',
|
|
2099
|
+
background: '#f8fafc',
|
|
2100
|
+
maxWidth: '400px',
|
|
2101
|
+
}}
|
|
2102
|
+
>
|
|
2103
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#0f172a' }}>
|
|
2104
|
+
Remote Widget
|
|
2105
|
+
</h2>
|
|
2106
|
+
<p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
|
|
2107
|
+
Built by webpack 5. Loaded by the Next.js shell at runtime via plain
|
|
2108
|
+
script injection — no federation plugin on the shell required.
|
|
2109
|
+
</p>
|
|
2110
|
+
</section>
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
`,
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
export const NEXTJS_MF_RUNTIME_LAB: FrontendLabWorkspace = {
|
|
2117
|
+
version: 1,
|
|
2118
|
+
label: "Next.js MF — Runtime Loader (Option B)",
|
|
2119
|
+
type: "module-federation",
|
|
2120
|
+
activeFile: "apps/shell/src/lib/loadRemoteModule.ts",
|
|
2121
|
+
files: NEXTJS_MF_RUNTIME_FILES,
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
// ─── Next.js Multi-Zones ─────────────────────────────────────────────────────
|
|
2125
|
+
const NEXTJS_MULTI_ZONES_FILES: Record<string, string> = {
|
|
2126
|
+
"README.md": `# Next.js Multi-Zones
|
|
2127
|
+
|
|
2128
|
+
## What this shows
|
|
2129
|
+
Two separate Next.js apps served under one domain via URL-path splitting.
|
|
2130
|
+
The shell owns \`/\` and proxies \`/store\` to Zone B using Next.js rewrites.
|
|
2131
|
+
Each zone deploys independently — the shell never rebuilds when Zone B changes.
|
|
2132
|
+
|
|
2133
|
+
## Key files
|
|
2134
|
+
- \`apps/shell/next.config.js\` — rewrites \`/store/*\` → Zone B
|
|
2135
|
+
- \`apps/zone-b/next.config.js\` — \`basePath: '/store'\` aligns Zone B's routes with the rewrite
|
|
2136
|
+
|
|
2137
|
+
## Things to try
|
|
2138
|
+
1. Click the Store link — URL stays on the shell's origin but Zone B renders.
|
|
2139
|
+
2. Deploy Zone B to a CDN; update only the rewrite destination.
|
|
2140
|
+
3. Add a \`/checkout\` zone as a third independent Next.js app.
|
|
2141
|
+
`,
|
|
2142
|
+
"package.json": `{
|
|
2143
|
+
"name": "nextjs-multi-zones-lab",
|
|
2144
|
+
"private": true,
|
|
2145
|
+
"workspaces": ["apps/shell", "apps/zone-b"],
|
|
2146
|
+
"scripts": {
|
|
2147
|
+
"dev": "concurrently -k -n zone-b,shell -c magenta,cyan 'npm run dev --workspace=nextjs-zone-b' 'npm run dev --workspace=nextjs-shell'"
|
|
2148
|
+
},
|
|
2149
|
+
"devDependencies": { "concurrently": "^9.2.1" },
|
|
2150
|
+
"overrides": { "undici": "^7" }
|
|
2151
|
+
}
|
|
2152
|
+
`,
|
|
2153
|
+
"apps/shell/package.json": `{
|
|
2154
|
+
"name": "nextjs-shell",
|
|
2155
|
+
"private": true,
|
|
2156
|
+
"scripts": { "dev": "next dev -p $HOST_PORT" },
|
|
2157
|
+
"dependencies": {
|
|
2158
|
+
"next": "latest",
|
|
2159
|
+
"react": "^19.0.0",
|
|
2160
|
+
"react-dom": "^19.0.0"
|
|
2161
|
+
},
|
|
2162
|
+
"devDependencies": {
|
|
2163
|
+
"typescript": "latest",
|
|
2164
|
+
"@types/react": "latest",
|
|
2165
|
+
"@types/react-dom": "latest",
|
|
2166
|
+
"@types/node": "latest"
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
`,
|
|
2170
|
+
"apps/shell/next.config.js": `/** @type {import('next').NextConfig} */
|
|
2171
|
+
// Shell acts as the routing layer.
|
|
2172
|
+
// /store and /store/* are proxied to Zone B running at REMOTE_PORT.
|
|
2173
|
+
const nextConfig = {
|
|
2174
|
+
async rewrites() {
|
|
2175
|
+
const zoneB = 'http://localhost:' + process.env.REMOTE_PORT;
|
|
2176
|
+
return [
|
|
2177
|
+
{ source: '/store', destination: zoneB + '/store' },
|
|
2178
|
+
{ source: '/store/:path*', destination: zoneB + '/store/:path*' },
|
|
2179
|
+
];
|
|
2180
|
+
},
|
|
2181
|
+
};
|
|
2182
|
+
module.exports = nextConfig;
|
|
2183
|
+
`,
|
|
2184
|
+
"apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
|
|
2185
|
+
import React from 'react';
|
|
2186
|
+
|
|
2187
|
+
export const metadata: Metadata = { title: 'Shell \u2014 Multi-Zones' };
|
|
2188
|
+
|
|
2189
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
2190
|
+
return (
|
|
2191
|
+
<html lang="en">
|
|
2192
|
+
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
|
|
2193
|
+
</html>
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
`,
|
|
2197
|
+
"apps/shell/src/app/page.tsx": `// Cross-zone links must be plain <a> tags — Next.js <Link> routes within
|
|
2198
|
+
// the same app via client-side JS, so the server-side rewrite never fires.
|
|
2199
|
+
export default function Home() {
|
|
2200
|
+
return (
|
|
2201
|
+
<main style={{ padding: '2rem' }}>
|
|
2202
|
+
<h1>Shell App \u2014 Zone A</h1>
|
|
2203
|
+
<p style={{ color: '#64748b' }}>
|
|
2204
|
+
This zone owns <code>/</code>. Clicking Store proxies the request to a
|
|
2205
|
+
separate Next.js app (Zone B) \u2014 transparent to the browser.
|
|
2206
|
+
</p>
|
|
2207
|
+
<nav style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
|
|
2208
|
+
<a href="/" style={{ color: '#3b82f6' }}>Home (Zone A)</a>
|
|
2209
|
+
<a href="/store" style={{ color: '#3b82f6' }}>Store (Zone B \u2014 proxied)</a>
|
|
2210
|
+
</nav>
|
|
2211
|
+
</main>
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
`,
|
|
2215
|
+
"apps/zone-b/package.json": `{
|
|
2216
|
+
"name": "nextjs-zone-b",
|
|
2217
|
+
"private": true,
|
|
2218
|
+
"scripts": { "dev": "next dev -p $REMOTE_PORT" },
|
|
2219
|
+
"dependencies": {
|
|
2220
|
+
"next": "latest",
|
|
2221
|
+
"react": "^19.0.0",
|
|
2222
|
+
"react-dom": "^19.0.0"
|
|
2223
|
+
},
|
|
2224
|
+
"devDependencies": {
|
|
2225
|
+
"typescript": "latest",
|
|
2226
|
+
"@types/react": "latest",
|
|
2227
|
+
"@types/react-dom": "latest",
|
|
2228
|
+
"@types/node": "latest"
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
`,
|
|
2232
|
+
"apps/zone-b/next.config.js": `/** @type {import('next').NextConfig} */
|
|
2233
|
+
// basePath makes Zone B serve its pages under /store,
|
|
2234
|
+
// matching the path prefix the shell forwards here.
|
|
2235
|
+
const nextConfig = {
|
|
2236
|
+
basePath: '/store',
|
|
2237
|
+
};
|
|
2238
|
+
module.exports = nextConfig;
|
|
2239
|
+
`,
|
|
2240
|
+
"apps/zone-b/src/app/layout.tsx": `import type { Metadata } from 'next';
|
|
2241
|
+
import React from 'react';
|
|
2242
|
+
|
|
2243
|
+
export const metadata: Metadata = { title: 'Store \u2014 Zone B' };
|
|
2244
|
+
|
|
2245
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
2246
|
+
return (
|
|
2247
|
+
<html lang="en">
|
|
2248
|
+
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
|
|
2249
|
+
</html>
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
`,
|
|
2253
|
+
"apps/zone-b/src/app/page.tsx": `// Cross-zone back-link must be a plain <a> — client-side Next.js routing
|
|
2254
|
+
// stays within this app and would 404. A full navigation triggers the shell's rewrite.
|
|
2255
|
+
export default function StorePage() {
|
|
2256
|
+
return (
|
|
2257
|
+
<main style={{ padding: '2rem', background: '#f0fdf4', minHeight: '100vh' }}>
|
|
2258
|
+
<h1>Store \u2014 Zone B</h1>
|
|
2259
|
+
<p style={{ color: '#64748b' }}>
|
|
2260
|
+
Rendered by a <strong>separate</strong> Next.js app. The shell proxied
|
|
2261
|
+
your request via <code>next.config.js</code> rewrites. No code is shared
|
|
2262
|
+
\u2014 both zones are fully independent.
|
|
2263
|
+
</p>
|
|
2264
|
+
<a href="/" style={{ color: '#3b82f6' }}>\u2190 Back to Shell (Zone A)</a>
|
|
2265
|
+
</main>
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
`,
|
|
2269
|
+
};
|
|
2270
|
+
|
|
2271
|
+
export const NEXTJS_MULTI_ZONES_LAB: FrontendLabWorkspace = {
|
|
2272
|
+
version: 1,
|
|
2273
|
+
label: "Next.js \u2014 Multi-Zones",
|
|
2274
|
+
type: "module-federation",
|
|
2275
|
+
activeFile: "apps/shell/next.config.js",
|
|
2276
|
+
files: NEXTJS_MULTI_ZONES_FILES,
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
// ─── Next.js MF Runtime API ──────────────────────────────────────────────────
|
|
2280
|
+
const NEXTJS_MF_RUNTIME_API_FILES: Record<string, string> = {
|
|
2281
|
+
"README.md": `# Next.js \u2014 Module Federation Runtime API
|
|
2282
|
+
|
|
2283
|
+
## What this shows
|
|
2284
|
+
Consuming a federated remote from Next.js App Router without touching \`next.config.js\`.
|
|
2285
|
+
All federation work is isolated inside a \`'use client'\` component using
|
|
2286
|
+
\`@module-federation/enhanced/runtime\`'s \`init()\` + \`loadRemote()\` API.
|
|
2287
|
+
|
|
2288
|
+
## Key files
|
|
2289
|
+
- \`apps/shell/src/components/FederatedWidget.tsx\` \u2014 the only federation code in the shell
|
|
2290
|
+
- \`apps/mf-remote/webpack.config.js\` \u2014 webpack remote exposing \`./Widget\`
|
|
2291
|
+
|
|
2292
|
+
## Trade-off
|
|
2293
|
+
The component is strictly client-side (SPA). There is no SSR for the remote content.
|
|
2294
|
+
This is the recommended safe pattern for consuming federation in the App Router.
|
|
2295
|
+
`,
|
|
2296
|
+
"package.json": `{
|
|
2297
|
+
"name": "nextjs-mf-runtime-api-lab",
|
|
2298
|
+
"private": true,
|
|
2299
|
+
"workspaces": ["apps/shell", "apps/mf-remote"],
|
|
2300
|
+
"scripts": {
|
|
2301
|
+
"dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=mf-runtime-remote' 'npm run dev --workspace=mf-runtime-shell'"
|
|
2302
|
+
},
|
|
2303
|
+
"devDependencies": { "concurrently": "^9.2.1" },
|
|
2304
|
+
"overrides": { "undici": "^7" }
|
|
2305
|
+
}
|
|
2306
|
+
`,
|
|
2307
|
+
"apps/shell/package.json": `{
|
|
2308
|
+
"name": "mf-runtime-shell",
|
|
2309
|
+
"private": true,
|
|
2310
|
+
"scripts": { "dev": "next dev -p $HOST_PORT" },
|
|
2311
|
+
"dependencies": {
|
|
2312
|
+
"next": "latest",
|
|
2313
|
+
"react": "^19.0.0",
|
|
2314
|
+
"react-dom": "^19.0.0",
|
|
2315
|
+
"@module-federation/enhanced": "latest"
|
|
2316
|
+
},
|
|
2317
|
+
"devDependencies": {
|
|
2318
|
+
"typescript": "latest",
|
|
2319
|
+
"@types/react": "latest",
|
|
2320
|
+
"@types/react-dom": "latest",
|
|
2321
|
+
"@types/node": "latest"
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
`,
|
|
2325
|
+
"apps/shell/next.config.js": `/** @type {import('next').NextConfig} */
|
|
2326
|
+
// No webpack hooks needed — federation is handled entirely at runtime
|
|
2327
|
+
// inside FederatedWidget.tsx via @module-federation/enhanced/runtime.
|
|
2328
|
+
module.exports = {};
|
|
2329
|
+
`,
|
|
2330
|
+
"apps/shell/tsconfig.json": `{
|
|
2331
|
+
"compilerOptions": {
|
|
2332
|
+
"target": "ES2017",
|
|
2333
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
2334
|
+
"allowJs": true,
|
|
2335
|
+
"skipLibCheck": true,
|
|
2336
|
+
"strict": true,
|
|
2337
|
+
"noEmit": true,
|
|
2338
|
+
"esModuleInterop": true,
|
|
2339
|
+
"module": "esnext",
|
|
2340
|
+
"moduleResolution": "bundler",
|
|
2341
|
+
"resolveJsonModule": true,
|
|
2342
|
+
"isolatedModules": true,
|
|
2343
|
+
"jsx": "preserve",
|
|
2344
|
+
"incremental": true,
|
|
2345
|
+
"plugins": [{ "name": "next" }],
|
|
2346
|
+
"paths": {
|
|
2347
|
+
"@/*": ["./src/*"]
|
|
2348
|
+
}
|
|
2349
|
+
},
|
|
2350
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
2351
|
+
"exclude": ["node_modules"]
|
|
2352
|
+
}
|
|
2353
|
+
`,
|
|
2354
|
+
"apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
|
|
2355
|
+
import React from 'react';
|
|
2356
|
+
|
|
2357
|
+
export const metadata: Metadata = { title: 'Shell \u2014 MF Runtime API' };
|
|
2358
|
+
|
|
2359
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
2360
|
+
return (
|
|
2361
|
+
<html lang="en">
|
|
2362
|
+
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
|
|
2363
|
+
</html>
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
`,
|
|
2367
|
+
"apps/shell/src/app/page.tsx": `import FederatedWidget from '@/components/FederatedWidget';
|
|
2368
|
+
|
|
2369
|
+
export default function Home() {
|
|
2370
|
+
return (
|
|
2371
|
+
<main style={{ padding: '2rem' }}>
|
|
2372
|
+
<h1>Next.js Shell \u2014 MF Runtime API</h1>
|
|
2373
|
+
<p style={{ color: '#64748b' }}>
|
|
2374
|
+
The widget below is loaded at runtime using{' '}
|
|
2375
|
+
<code>@module-federation/enhanced/runtime</code>. No webpack config changes needed.
|
|
2376
|
+
</p>
|
|
2377
|
+
<FederatedWidget />
|
|
2378
|
+
</main>
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
`,
|
|
2382
|
+
"apps/shell/src/components/FederatedWidget.tsx": `'use client';
|
|
2383
|
+
|
|
2384
|
+
import { useEffect, useState } from 'react';
|
|
2385
|
+
import type { ComponentType } from 'react';
|
|
2386
|
+
|
|
2387
|
+
// All federation work is isolated here in a client component.
|
|
2388
|
+
// init() + loadRemote() run only in the browser, never on the server.
|
|
2389
|
+
export default function FederatedWidget() {
|
|
2390
|
+
const [Component, setComponent] = useState<ComponentType | null>(null);
|
|
2391
|
+
const [error, setError] = useState<string | null>(null);
|
|
2392
|
+
|
|
2393
|
+
useEffect(() => {
|
|
2394
|
+
async function load() {
|
|
2395
|
+
const { init, loadRemote } = await import('@module-federation/enhanced/runtime');
|
|
2396
|
+
|
|
2397
|
+
// NEXT_PUBLIC_REMOTE_URL is injected by the lab runner.
|
|
2398
|
+
const entry =
|
|
2399
|
+
process.env.NEXT_PUBLIC_REMOTE_URL || 'http://localhost:3001/remoteEntry.js';
|
|
2400
|
+
|
|
2401
|
+
// shared tells the runtime which packages to negotiate as singletons.
|
|
2402
|
+
// Without this, both shell and remote load their own React copy → version mismatch crash.
|
|
2403
|
+
init({
|
|
2404
|
+
name: 'shell',
|
|
2405
|
+
remotes: [{ name: 'mfRemote', entry }],
|
|
2406
|
+
shared: {
|
|
2407
|
+
react: { singleton: true, version: '19.0.0', lib: () => require('react') },
|
|
2408
|
+
'react-dom': { singleton: true, version: '19.0.0', lib: () => require('react-dom') },
|
|
2409
|
+
},
|
|
2410
|
+
});
|
|
2411
|
+
|
|
2412
|
+
// loadRemote resolves ./Widget from the remote container at runtime.
|
|
2413
|
+
const mod = await loadRemote<{ default: ComponentType }>('mfRemote/Widget');
|
|
2414
|
+
if (mod) setComponent(() => mod.default);
|
|
2415
|
+
}
|
|
2416
|
+
load().catch((e) => setError(String(e)));
|
|
2417
|
+
}, []);
|
|
2418
|
+
|
|
2419
|
+
if (error) return <p style={{ color: '#ef4444' }}>Remote failed: {error}</p>;
|
|
2420
|
+
if (!Component) return <p>Loading remote widget\u2026</p>;
|
|
2421
|
+
return <Component />;
|
|
2422
|
+
}
|
|
2423
|
+
`,
|
|
2424
|
+
"apps/mf-remote/package.json": `{
|
|
2425
|
+
"name": "mf-runtime-remote",
|
|
2426
|
+
"private": true,
|
|
2427
|
+
"scripts": { "dev": "webpack serve" },
|
|
2428
|
+
"dependencies": {
|
|
2429
|
+
"react": "^19.0.0",
|
|
2430
|
+
"react-dom": "^19.0.0"
|
|
2431
|
+
},
|
|
2432
|
+
"devDependencies": {
|
|
2433
|
+
"webpack": "^5",
|
|
2434
|
+
"webpack-cli": "^5",
|
|
2435
|
+
"webpack-dev-server": "^5",
|
|
2436
|
+
"html-webpack-plugin": "^5",
|
|
2437
|
+
"@babel/core": "^7",
|
|
2438
|
+
"@babel/preset-env": "^7",
|
|
2439
|
+
"@babel/preset-react": "^7",
|
|
2440
|
+
"babel-loader": "^9"
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
`,
|
|
2444
|
+
"apps/mf-remote/webpack.config.js": `const path = require('path');
|
|
2445
|
+
const webpack = require('webpack');
|
|
2446
|
+
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
2447
|
+
|
|
2448
|
+
module.exports = {
|
|
2449
|
+
entry: './src/index.js',
|
|
2450
|
+
mode: 'development',
|
|
2451
|
+
output: {
|
|
2452
|
+
path: path.resolve(__dirname, 'dist'),
|
|
2453
|
+
publicPath: 'auto',
|
|
2454
|
+
},
|
|
2455
|
+
devServer: {
|
|
2456
|
+
port: parseInt(process.env.REMOTE_PORT, 10) || 3001,
|
|
2457
|
+
hot: true,
|
|
2458
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
2459
|
+
},
|
|
2460
|
+
module: {
|
|
2461
|
+
rules: [
|
|
2462
|
+
{
|
|
2463
|
+
test: /\\.(js|jsx)$/,
|
|
2464
|
+
exclude: /node_modules/,
|
|
2465
|
+
use: {
|
|
2466
|
+
loader: 'babel-loader',
|
|
2467
|
+
options: { presets: ['@babel/preset-env', '@babel/preset-react'] },
|
|
2468
|
+
},
|
|
2469
|
+
},
|
|
2470
|
+
],
|
|
2471
|
+
},
|
|
2472
|
+
resolve: { extensions: ['.js', '.jsx'] },
|
|
2473
|
+
plugins: [
|
|
2474
|
+
new webpack.container.ModuleFederationPlugin({
|
|
2475
|
+
name: 'mfRemote',
|
|
2476
|
+
filename: 'remoteEntry.js',
|
|
2477
|
+
exposes: { './Widget': './src/exposes/Widget' },
|
|
2478
|
+
shared: {
|
|
2479
|
+
react: { singleton: true, eager: true, requiredVersion: '^19.0.0' },
|
|
2480
|
+
'react-dom': { singleton: true, eager: true, requiredVersion: '^19.0.0' },
|
|
2481
|
+
},
|
|
2482
|
+
}),
|
|
2483
|
+
new HtmlWebpackPlugin({ template: './public/index.html' }),
|
|
2484
|
+
],
|
|
2485
|
+
};
|
|
2486
|
+
`,
|
|
2487
|
+
"apps/mf-remote/src/index.js": `// Remote bootstrap page. MF consumers load ./Widget via remoteEntry.js.
|
|
2488
|
+
document.getElementById('root').innerHTML =
|
|
2489
|
+
'<div style="padding:2rem;font-family:system-ui">' +
|
|
2490
|
+
'<h2>mfRemote \u2014 running</h2>' +
|
|
2491
|
+
'<p>Exposes <code>mfRemote/Widget</code> via Module Federation.</p>' +
|
|
2492
|
+
'</div>';
|
|
2493
|
+
`,
|
|
2494
|
+
"apps/mf-remote/src/exposes/Widget.jsx": `import React from 'react';
|
|
2495
|
+
|
|
2496
|
+
// Exposed as mfRemote/Widget via ModuleFederationPlugin.
|
|
2497
|
+
// The shell loads this at runtime via @module-federation/enhanced/runtime.
|
|
2498
|
+
export default function Widget() {
|
|
2499
|
+
return (
|
|
2500
|
+
<section style={{
|
|
2501
|
+
padding: '1rem', border: '1px solid #e2e8f0',
|
|
2502
|
+
borderRadius: '8px', background: '#f8fafc', maxWidth: '400px',
|
|
2503
|
+
}}>
|
|
2504
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem' }}>Remote Widget</h2>
|
|
2505
|
+
<p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
|
|
2506
|
+
Loaded via <code>@module-federation/enhanced/runtime</code> \u2014 client-side only.
|
|
2507
|
+
</p>
|
|
2508
|
+
</section>
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
`,
|
|
2512
|
+
"apps/mf-remote/public/index.html": `<!DOCTYPE html>
|
|
2513
|
+
<html lang="en">
|
|
2514
|
+
<head><meta charset="UTF-8" /><title>MF Remote</title></head>
|
|
2515
|
+
<body><div id="root"></div></body>
|
|
2516
|
+
</html>
|
|
2517
|
+
`,
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2520
|
+
export const NEXTJS_MF_RUNTIME_API_LAB: FrontendLabWorkspace = {
|
|
2521
|
+
version: 1,
|
|
2522
|
+
label: "Next.js \u2014 MF Runtime API",
|
|
2523
|
+
type: "module-federation",
|
|
2524
|
+
activeFile: "apps/shell/src/components/FederatedWidget.tsx",
|
|
2525
|
+
files: NEXTJS_MF_RUNTIME_API_FILES,
|
|
2526
|
+
};
|
|
2527
|
+
|
|
2528
|
+
// ─── Rspack Shell + Webpack Remote ───────────────────────────────────────────
|
|
2529
|
+
const RSPACK_SHELL_FILES: Record<string, string> = {
|
|
2530
|
+
"README.md": `# Rspack Shell \u2014 Native Module Federation 2.0
|
|
2531
|
+
|
|
2532
|
+
## What this shows
|
|
2533
|
+
An Rspack-bundled React shell consuming a webpack remote using native Module
|
|
2534
|
+
Federation support. Rspack mirrors webpack's API and ships MF support out of the
|
|
2535
|
+
box \u2014 no extra plugins or workarounds needed.
|
|
2536
|
+
|
|
2537
|
+
## Key files
|
|
2538
|
+
- \`apps/rspack-shell/rspack.config.js\` \u2014 shell config with MF plugin + built-in SWC loader
|
|
2539
|
+
- \`apps/webpack-remote/webpack.config.js\` \u2014 remote exposing \`./Widget\`
|
|
2540
|
+
|
|
2541
|
+
## Why Rspack for the shell
|
|
2542
|
+
If Module Federation is a hard requirement, the community recommends running the
|
|
2543
|
+
host/orchestrator on Rspack (or Modern.js) rather than Next.js. Next.js remotes
|
|
2544
|
+
still work \u2014 here a plain webpack remote is used for simplicity.
|
|
2545
|
+
`,
|
|
2546
|
+
"package.json": `{
|
|
2547
|
+
"name": "rspack-shell-mf-lab",
|
|
2548
|
+
"private": true,
|
|
2549
|
+
"workspaces": ["apps/rspack-shell", "apps/webpack-remote"],
|
|
2550
|
+
"scripts": {
|
|
2551
|
+
"dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=rspack-mf-remote' 'npm run dev --workspace=rspack-mf-shell'"
|
|
2552
|
+
},
|
|
2553
|
+
"devDependencies": { "concurrently": "^9.2.1" }
|
|
2554
|
+
}
|
|
2555
|
+
`,
|
|
2556
|
+
"apps/rspack-shell/package.json": `{
|
|
2557
|
+
"name": "rspack-mf-shell",
|
|
2558
|
+
"private": true,
|
|
2559
|
+
"scripts": { "dev": "rspack serve" },
|
|
2560
|
+
"dependencies": {
|
|
2561
|
+
"react": "^18.0.0",
|
|
2562
|
+
"react-dom": "^18.0.0"
|
|
2563
|
+
},
|
|
2564
|
+
"devDependencies": {
|
|
2565
|
+
"@rspack/core": "latest",
|
|
2566
|
+
"@rspack/cli": "latest"
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
`,
|
|
2570
|
+
"apps/rspack-shell/rspack.config.js": `const rspack = require('@rspack/core');
|
|
2571
|
+
|
|
2572
|
+
module.exports = {
|
|
2573
|
+
entry: './src/index.jsx',
|
|
2574
|
+
mode: 'development',
|
|
2575
|
+
output: { publicPath: 'auto' },
|
|
2576
|
+
devServer: {
|
|
2577
|
+
// PORT is read from env — injected by the lab runner.
|
|
2578
|
+
port: parseInt(process.env.HOST_PORT, 10) || 3000,
|
|
2579
|
+
hot: true,
|
|
2580
|
+
},
|
|
2581
|
+
resolve: { extensions: ['.js', '.jsx'] },
|
|
2582
|
+
module: {
|
|
2583
|
+
rules: [
|
|
2584
|
+
{
|
|
2585
|
+
test: /\\.(js|jsx)$/,
|
|
2586
|
+
// Rspack ships a built-in SWC loader — no Babel or extra packages needed.
|
|
2587
|
+
loader: 'builtin:swc-loader',
|
|
2588
|
+
options: {
|
|
2589
|
+
jsc: {
|
|
2590
|
+
parser: { syntax: 'ecmascript', jsx: true },
|
|
2591
|
+
transform: { react: { runtime: 'automatic' } },
|
|
2592
|
+
},
|
|
2593
|
+
},
|
|
2594
|
+
type: 'javascript/auto',
|
|
2595
|
+
},
|
|
2596
|
+
],
|
|
2597
|
+
},
|
|
2598
|
+
plugins: [
|
|
2599
|
+
// Rspack has first-class Module Federation support via rspack.container.
|
|
2600
|
+
new rspack.container.ModuleFederationPlugin({
|
|
2601
|
+
name: 'rspackShell',
|
|
2602
|
+
remotes: {
|
|
2603
|
+
// mfRemote is the webpack remote at REMOTE_PORT.
|
|
2604
|
+
mfRemote:
|
|
2605
|
+
'mfRemote@http://localhost:' +
|
|
2606
|
+
(process.env.REMOTE_PORT || '3001') +
|
|
2607
|
+
'/remoteEntry.js',
|
|
2608
|
+
},
|
|
2609
|
+
shared: {
|
|
2610
|
+
react: { singleton: true },
|
|
2611
|
+
'react-dom': { singleton: true },
|
|
2612
|
+
},
|
|
2613
|
+
}),
|
|
2614
|
+
new rspack.HtmlRspackPlugin({ template: './index.html' }),
|
|
2615
|
+
],
|
|
2616
|
+
};
|
|
2617
|
+
`,
|
|
2618
|
+
"apps/rspack-shell/index.html": `<!DOCTYPE html>
|
|
2619
|
+
<html lang="en">
|
|
2620
|
+
<head>
|
|
2621
|
+
<meta charset="UTF-8" />
|
|
2622
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2623
|
+
<title>Rspack Shell</title>
|
|
2624
|
+
</head>
|
|
2625
|
+
<body>
|
|
2626
|
+
<div id="root"></div>
|
|
2627
|
+
</body>
|
|
2628
|
+
</html>
|
|
2629
|
+
`,
|
|
2630
|
+
"apps/rspack-shell/src/index.jsx": `import { createRoot } from 'react-dom/client';
|
|
2631
|
+
import App from './App';
|
|
2632
|
+
|
|
2633
|
+
// Rspack's SWC loader handles JSX via builtin:swc-loader (no Babel needed).
|
|
2634
|
+
createRoot(document.getElementById('root')).render(<App />);
|
|
2635
|
+
`,
|
|
2636
|
+
"apps/rspack-shell/src/App.jsx": `import React, { Suspense, lazy } from 'react';
|
|
2637
|
+
|
|
2638
|
+
// mfRemote is declared in rspack.config.js under remotes.
|
|
2639
|
+
// Rspack resolves this lazy import to the federated container at runtime.
|
|
2640
|
+
const RemoteWidget = lazy(() => import('mfRemote/Widget'));
|
|
2641
|
+
|
|
2642
|
+
export default function App() {
|
|
2643
|
+
return (
|
|
2644
|
+
<main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
|
|
2645
|
+
<h1>Rspack Shell</h1>
|
|
2646
|
+
<p style={{ color: '#64748b' }}>
|
|
2647
|
+
Bundled with Rspack \u2014 native Module Federation, no plugins or workarounds.
|
|
2648
|
+
The widget below is loaded from a separate webpack remote.
|
|
2649
|
+
</p>
|
|
2650
|
+
<Suspense fallback={<p>Loading remote widget\u2026</p>}>
|
|
2651
|
+
<RemoteWidget />
|
|
2652
|
+
</Suspense>
|
|
2653
|
+
</main>
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
`,
|
|
2657
|
+
"apps/webpack-remote/package.json": `{
|
|
2658
|
+
"name": "rspack-mf-remote",
|
|
2659
|
+
"private": true,
|
|
2660
|
+
"scripts": { "dev": "webpack serve" },
|
|
2661
|
+
"dependencies": {
|
|
2662
|
+
"react": "^18.0.0",
|
|
2663
|
+
"react-dom": "^18.0.0"
|
|
2664
|
+
},
|
|
2665
|
+
"devDependencies": {
|
|
2666
|
+
"webpack": "^5",
|
|
2667
|
+
"webpack-cli": "^5",
|
|
2668
|
+
"webpack-dev-server": "^5",
|
|
2669
|
+
"html-webpack-plugin": "^5",
|
|
2670
|
+
"@babel/core": "^7",
|
|
2671
|
+
"@babel/preset-env": "^7",
|
|
2672
|
+
"@babel/preset-react": "^7",
|
|
2673
|
+
"babel-loader": "^9"
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
`,
|
|
2677
|
+
"apps/webpack-remote/webpack.config.js": `const path = require('path');
|
|
2678
|
+
const webpack = require('webpack');
|
|
2679
|
+
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
2680
|
+
|
|
2681
|
+
module.exports = {
|
|
2682
|
+
entry: './src/index.js',
|
|
2683
|
+
mode: 'development',
|
|
2684
|
+
output: {
|
|
2685
|
+
path: path.resolve(__dirname, 'dist'),
|
|
2686
|
+
publicPath: 'auto',
|
|
2687
|
+
},
|
|
2688
|
+
devServer: {
|
|
2689
|
+
port: parseInt(process.env.REMOTE_PORT, 10) || 3001,
|
|
2690
|
+
hot: true,
|
|
2691
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
2692
|
+
},
|
|
2693
|
+
module: {
|
|
2694
|
+
rules: [
|
|
2695
|
+
{
|
|
2696
|
+
test: /\\.(js|jsx)$/,
|
|
2697
|
+
exclude: /node_modules/,
|
|
2698
|
+
use: {
|
|
2699
|
+
loader: 'babel-loader',
|
|
2700
|
+
options: { presets: ['@babel/preset-env', '@babel/preset-react'] },
|
|
2701
|
+
},
|
|
2702
|
+
},
|
|
2703
|
+
],
|
|
2704
|
+
},
|
|
2705
|
+
resolve: { extensions: ['.js', '.jsx'] },
|
|
2706
|
+
plugins: [
|
|
2707
|
+
new webpack.container.ModuleFederationPlugin({
|
|
2708
|
+
name: 'mfRemote',
|
|
2709
|
+
filename: 'remoteEntry.js',
|
|
2710
|
+
exposes: { './Widget': './src/exposes/Widget' },
|
|
2711
|
+
shared: {
|
|
2712
|
+
react: { singleton: true, eager: true, requiredVersion: '^18.0.0' },
|
|
2713
|
+
'react-dom': { singleton: true, eager: true, requiredVersion: '^18.0.0' },
|
|
2714
|
+
},
|
|
2715
|
+
}),
|
|
2716
|
+
new HtmlWebpackPlugin({ template: './public/index.html' }),
|
|
2717
|
+
],
|
|
2718
|
+
};
|
|
2719
|
+
`,
|
|
2720
|
+
"apps/webpack-remote/src/index.js": `// Remote bootstrap page. MF consumers load ./Widget via remoteEntry.js.
|
|
2721
|
+
document.getElementById('root').innerHTML =
|
|
2722
|
+
'<div style="padding:2rem;font-family:system-ui">' +
|
|
2723
|
+
'<h2>webpack-remote \u2014 running</h2>' +
|
|
2724
|
+
'<p>Exposes <code>mfRemote/Widget</code> via Module Federation.</p>' +
|
|
2725
|
+
'</div>';
|
|
2726
|
+
`,
|
|
2727
|
+
"apps/webpack-remote/src/exposes/Widget.jsx": `import React from 'react';
|
|
2728
|
+
|
|
2729
|
+
// Exposed as mfRemote/Widget. The Rspack shell loads this via lazy import.
|
|
2730
|
+
export default function Widget() {
|
|
2731
|
+
return (
|
|
2732
|
+
<section style={{
|
|
2733
|
+
padding: '1rem', border: '1px solid #e2e8f0',
|
|
2734
|
+
borderRadius: '8px', background: '#f8fafc', maxWidth: '400px',
|
|
2735
|
+
}}>
|
|
2736
|
+
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem' }}>Remote Widget</h2>
|
|
2737
|
+
<p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
|
|
2738
|
+
Served by a webpack remote, consumed by the Rspack shell via native MF.
|
|
2739
|
+
</p>
|
|
2740
|
+
</section>
|
|
2741
|
+
);
|
|
2742
|
+
}
|
|
2743
|
+
`,
|
|
2744
|
+
"apps/webpack-remote/public/index.html": `<!DOCTYPE html>
|
|
2745
|
+
<html lang="en">
|
|
2746
|
+
<head><meta charset="UTF-8" /><title>Webpack Remote</title></head>
|
|
2747
|
+
<body><div id="root"></div></body>
|
|
2748
|
+
</html>
|
|
2749
|
+
`,
|
|
2750
|
+
};
|
|
2751
|
+
|
|
2752
|
+
export const RSPACK_SHELL_LAB: FrontendLabWorkspace = {
|
|
2753
|
+
version: 1,
|
|
2754
|
+
label: "Rspack Shell \u2014 Native MF 2.0",
|
|
2755
|
+
type: "module-federation",
|
|
2756
|
+
activeFile: "apps/rspack-shell/rspack.config.js",
|
|
2757
|
+
files: RSPACK_SHELL_FILES,
|
|
2758
|
+
};
|
|
2759
|
+
|
|
1554
2760
|
export function defaultForType(type: FrontendLabType): FrontendLabWorkspace {
|
|
1555
2761
|
if (type === "nextjs") return DEFAULT_NEXTJS_LAB;
|
|
1556
2762
|
if (type === "module-federation") return DEFAULT_MODULE_FEDERATION_LAB;
|