create-interview-cockpit 0.13.0 → 0.14.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
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useStore } from "../store";
|
|
3
3
|
import { parseInfraLabWorkspace } from "../infraLab";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseFrontendLabWorkspace,
|
|
6
|
+
ISOLATED_MODULE_FEDERATION_LAB,
|
|
7
|
+
} from "../reactLab";
|
|
5
8
|
import type { ContextFile } from "../types";
|
|
6
9
|
import {
|
|
7
10
|
Plus,
|
|
@@ -379,6 +382,7 @@ export default function LabsPanel() {
|
|
|
379
382
|
emptyText,
|
|
380
383
|
onNewLab,
|
|
381
384
|
newLabTitle,
|
|
385
|
+
newLabMenu,
|
|
382
386
|
onOpen,
|
|
383
387
|
openTitle,
|
|
384
388
|
accentClass,
|
|
@@ -391,11 +395,17 @@ export default function LabsPanel() {
|
|
|
391
395
|
emptyText: string;
|
|
392
396
|
onNewLab?: () => void;
|
|
393
397
|
newLabTitle?: string;
|
|
398
|
+
newLabMenu?: Array<{
|
|
399
|
+
label: string;
|
|
400
|
+
description: string;
|
|
401
|
+
onClick: () => void;
|
|
402
|
+
}>;
|
|
394
403
|
onOpen: (cf: ContextFile) => void;
|
|
395
404
|
openTitle: string;
|
|
396
405
|
accentClass: string;
|
|
397
406
|
bgClass: string;
|
|
398
407
|
}) {
|
|
408
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
399
409
|
const items = byOrigin(origin);
|
|
400
410
|
if (!currentQuestion) return null;
|
|
401
411
|
|
|
@@ -408,7 +418,45 @@ export default function LabsPanel() {
|
|
|
408
418
|
{title} ({items.length})
|
|
409
419
|
</span>
|
|
410
420
|
</div>
|
|
411
|
-
{
|
|
421
|
+
{newLabMenu ? (
|
|
422
|
+
<div className="relative">
|
|
423
|
+
<button
|
|
424
|
+
onClick={() => setMenuOpen((o) => !o)}
|
|
425
|
+
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
426
|
+
title="New lab"
|
|
427
|
+
>
|
|
428
|
+
<Plus className="w-3.5 h-3.5" />
|
|
429
|
+
</button>
|
|
430
|
+
{menuOpen && (
|
|
431
|
+
<>
|
|
432
|
+
{/* backdrop to close on outside click */}
|
|
433
|
+
<div
|
|
434
|
+
className="fixed inset-0 z-40"
|
|
435
|
+
onClick={() => setMenuOpen(false)}
|
|
436
|
+
/>
|
|
437
|
+
<div className="absolute right-0 top-full mt-1 z-50 bg-slate-800 border border-slate-600 rounded-lg shadow-xl w-52 overflow-hidden">
|
|
438
|
+
{newLabMenu.map((item) => (
|
|
439
|
+
<button
|
|
440
|
+
key={item.label}
|
|
441
|
+
onClick={() => {
|
|
442
|
+
item.onClick();
|
|
443
|
+
setMenuOpen(false);
|
|
444
|
+
}}
|
|
445
|
+
className="w-full text-left px-3 py-2 hover:bg-slate-700 transition-colors group"
|
|
446
|
+
>
|
|
447
|
+
<div className="text-[11px] font-medium text-slate-200 group-hover:text-cyan-300">
|
|
448
|
+
{item.label}
|
|
449
|
+
</div>
|
|
450
|
+
<div className="text-[10px] text-slate-500 mt-0.5 leading-snug">
|
|
451
|
+
{item.description}
|
|
452
|
+
</div>
|
|
453
|
+
</button>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
</>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
) : onNewLab ? (
|
|
412
460
|
<button
|
|
413
461
|
onClick={onNewLab}
|
|
414
462
|
className="p-0.5 rounded text-slate-600 hover:text-cyan-400 hover:bg-slate-700 transition-colors"
|
|
@@ -416,7 +464,7 @@ export default function LabsPanel() {
|
|
|
416
464
|
>
|
|
417
465
|
<Plus className="w-3.5 h-3.5" />
|
|
418
466
|
</button>
|
|
419
|
-
)}
|
|
467
|
+
) : null}
|
|
420
468
|
</div>
|
|
421
469
|
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
|
422
470
|
{items.map((cf) => (
|
|
@@ -528,8 +576,21 @@ export default function LabsPanel() {
|
|
|
528
576
|
iconColor="text-emerald-400/70"
|
|
529
577
|
origin="module-federation"
|
|
530
578
|
emptyText="Save a webpack module federation lab to reopen it here"
|
|
531
|
-
|
|
532
|
-
|
|
579
|
+
newLabMenu={[
|
|
580
|
+
{
|
|
581
|
+
label: "Shared React Tree",
|
|
582
|
+
description:
|
|
583
|
+
"Shell & remote share one React runtime — classic federation",
|
|
584
|
+
onClick: () => openModuleFederationLab(),
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
label: "Isolated Mount / Unmount",
|
|
588
|
+
description:
|
|
589
|
+
"Remote owns its own React root via mount(el) / unmount(el)",
|
|
590
|
+
onClick: () =>
|
|
591
|
+
openModuleFederationLab(ISOLATED_MODULE_FEDERATION_LAB),
|
|
592
|
+
},
|
|
593
|
+
]}
|
|
533
594
|
onOpen={openMFFile}
|
|
534
595
|
openTitle="Open in Webpack Module Federation Lab"
|
|
535
596
|
accentClass="text-emerald-200"
|
|
@@ -1143,6 +1143,414 @@ export const DEFAULT_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
|
|
|
1143
1143
|
files: MODULE_FEDERATION_DEFAULT_FILES,
|
|
1144
1144
|
};
|
|
1145
1145
|
|
|
1146
|
+
// ─── Isolated Mount/Unmount MFE workspace ───────────────────────────────────
|
|
1147
|
+
// Each remote manages its own React root — no shared React tree with the host.
|
|
1148
|
+
// The remote exposes mount(el, props) / unmount(el) instead of a React component.
|
|
1149
|
+
const MODULE_FEDERATION_ISOLATED_FILES: Record<string, string> = {
|
|
1150
|
+
"README.md": `# Isolated Mount/Unmount Module Federation Lab
|
|
1151
|
+
|
|
1152
|
+
## Pattern overview
|
|
1153
|
+
|
|
1154
|
+
In the classic "shared React tree" pattern the host imports a remote React component
|
|
1155
|
+
and renders it inside its own tree - both share one React runtime.
|
|
1156
|
+
|
|
1157
|
+
In this **isolated** pattern:
|
|
1158
|
+
- The remote exposes \`mount(el, props)\` and \`unmount(el)\` functions
|
|
1159
|
+
- The host calls those functions with a DOM element
|
|
1160
|
+
- Each remote creates its own \`ReactDOM.createRoot\` — it is NOT in the host's tree
|
|
1161
|
+
- The remote can use a different React major version from the host
|
|
1162
|
+
|
|
1163
|
+
## Structure
|
|
1164
|
+
|
|
1165
|
+
- \`apps/host\` — the shell app (React 18)
|
|
1166
|
+
- \`apps/mfe-auth\` — the micro-frontend (could be React 18 or 19)
|
|
1167
|
+
|
|
1168
|
+
## Key files
|
|
1169
|
+
|
|
1170
|
+
- \`apps/mfe-auth/src/mount.jsx\` — exposes \`mount\` / \`unmount\`
|
|
1171
|
+
- \`apps/mfe-auth/webpack.config.js\` — exposes \`./mount\` (not a React component)
|
|
1172
|
+
- \`apps/host/src/MfeContainer.jsx\` — host side: DOM ref + useEffect calling mount/unmount
|
|
1173
|
+
- \`apps/host/src/App.jsx\` — renders \`<MfeContainer />\` like any normal component
|
|
1174
|
+
|
|
1175
|
+
## What to experiment with
|
|
1176
|
+
|
|
1177
|
+
1. Try passing different versions of React to host vs mfe-auth and see they don't conflict
|
|
1178
|
+
2. Add a second MFE (\`apps/mfe-dashboard\`) following the same mount/unmount contract
|
|
1179
|
+
3. Pass props through \`mount(el, { user, theme })\` and handle updates in the remote
|
|
1180
|
+
4. Observe that React context from the host does NOT flow into the remote
|
|
1181
|
+
`,
|
|
1182
|
+
"package.json": `{
|
|
1183
|
+
"name": "mf-isolated-lab",
|
|
1184
|
+
"private": true,
|
|
1185
|
+
"workspaces": [
|
|
1186
|
+
"apps/host",
|
|
1187
|
+
"apps/mfe-auth"
|
|
1188
|
+
],
|
|
1189
|
+
"scripts": {
|
|
1190
|
+
"dev": "concurrently -k -n host,mfe-auth -c cyan,magenta 'npm run dev --workspace=@mf-isolated/host' 'npm run dev --workspace=@mf-isolated/mfe-auth'",
|
|
1191
|
+
"build": "npm run build --workspace=@mf-isolated/host && npm run build --workspace=@mf-isolated/mfe-auth"
|
|
1192
|
+
},
|
|
1193
|
+
"devDependencies": {
|
|
1194
|
+
"concurrently": "^9.2.1"
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
`,
|
|
1198
|
+
"apps/host/package.json": `{
|
|
1199
|
+
"name": "@mf-isolated/host",
|
|
1200
|
+
"version": "1.0.0",
|
|
1201
|
+
"private": true,
|
|
1202
|
+
"scripts": {
|
|
1203
|
+
"dev": "webpack serve --config webpack.config.js",
|
|
1204
|
+
"build": "webpack --config webpack.config.js"
|
|
1205
|
+
},
|
|
1206
|
+
"dependencies": {
|
|
1207
|
+
"react": "^19.0.0",
|
|
1208
|
+
"react-dom": "^19.0.0"
|
|
1209
|
+
},
|
|
1210
|
+
"devDependencies": {
|
|
1211
|
+
"esbuild": "^0.28.0",
|
|
1212
|
+
"esbuild-loader": "^4.4.3",
|
|
1213
|
+
"html-webpack-plugin": "^5.6.7",
|
|
1214
|
+
"webpack": "^5.106.2",
|
|
1215
|
+
"webpack-cli": "^7.0.2",
|
|
1216
|
+
"webpack-dev-server": "^5.2.3"
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
`,
|
|
1220
|
+
"apps/host/webpack.config.js": `const HtmlWebpackPlugin = require("html-webpack-plugin");
|
|
1221
|
+
const { ModuleFederationPlugin } = require("webpack").container;
|
|
1222
|
+
const deps = require("./package.json").dependencies;
|
|
1223
|
+
|
|
1224
|
+
const HOST_PORT = parseInt(process.env.HOST_PORT || "3001");
|
|
1225
|
+
const MFE_AUTH_PORT = parseInt(process.env.MFE_AUTH_PORT || "3002");
|
|
1226
|
+
|
|
1227
|
+
module.exports = {
|
|
1228
|
+
mode: "development",
|
|
1229
|
+
entry: "./src/index.js",
|
|
1230
|
+
output: {
|
|
1231
|
+
publicPath: "auto",
|
|
1232
|
+
},
|
|
1233
|
+
resolve: { extensions: [".js", ".jsx"] },
|
|
1234
|
+
module: {
|
|
1235
|
+
rules: [
|
|
1236
|
+
{
|
|
1237
|
+
test: /\\.(js|jsx)$/,
|
|
1238
|
+
exclude: /node_modules/,
|
|
1239
|
+
use: {
|
|
1240
|
+
loader: "esbuild-loader",
|
|
1241
|
+
options: { loader: "jsx", jsx: "automatic", target: "es2020" },
|
|
1242
|
+
},
|
|
1243
|
+
},
|
|
1244
|
+
],
|
|
1245
|
+
},
|
|
1246
|
+
plugins: [
|
|
1247
|
+
new ModuleFederationPlugin({
|
|
1248
|
+
name: "host",
|
|
1249
|
+
remotes: {
|
|
1250
|
+
// The remote exposes mount/unmount — NOT a React component
|
|
1251
|
+
mfeAuth: \`mfeAuth@http://localhost:\${MFE_AUTH_PORT}/remoteEntry.js\`,
|
|
1252
|
+
},
|
|
1253
|
+
// Host does NOT share React with the remote.
|
|
1254
|
+
// Each app brings its own copy.
|
|
1255
|
+
shared: {
|
|
1256
|
+
react: { requiredVersion: deps.react },
|
|
1257
|
+
"react-dom": { requiredVersion: deps["react-dom"] },
|
|
1258
|
+
},
|
|
1259
|
+
}),
|
|
1260
|
+
new HtmlWebpackPlugin({ template: "./public/index.html" }),
|
|
1261
|
+
],
|
|
1262
|
+
devServer: {
|
|
1263
|
+
port: HOST_PORT,
|
|
1264
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
`,
|
|
1268
|
+
"apps/host/public/index.html": `<!doctype html>
|
|
1269
|
+
<html lang="en">
|
|
1270
|
+
<head>
|
|
1271
|
+
<meta charset="UTF-8" />
|
|
1272
|
+
<title>Host (Isolated MFE)</title>
|
|
1273
|
+
<style>
|
|
1274
|
+
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
|
|
1275
|
+
h1 { padding: 1rem 1.5rem 0; font-size: 1.1rem; color: #94a3b8; }
|
|
1276
|
+
.mfe-slot {
|
|
1277
|
+
margin: 1rem 1.5rem;
|
|
1278
|
+
border: 1px solid #334155;
|
|
1279
|
+
border-radius: 8px;
|
|
1280
|
+
padding: 1rem;
|
|
1281
|
+
min-height: 80px;
|
|
1282
|
+
background: #1e293b;
|
|
1283
|
+
}
|
|
1284
|
+
</style>
|
|
1285
|
+
</head>
|
|
1286
|
+
<body>
|
|
1287
|
+
<div id="root"></div>
|
|
1288
|
+
</body>
|
|
1289
|
+
</html>
|
|
1290
|
+
`,
|
|
1291
|
+
"apps/host/src/index.js": `// Async boundary: required for Module Federation dynamic imports
|
|
1292
|
+
import("./bootstrap");
|
|
1293
|
+
`,
|
|
1294
|
+
"apps/host/src/bootstrap.jsx": `import React from "react";
|
|
1295
|
+
import { createRoot } from "react-dom/client";
|
|
1296
|
+
import App from "./App";
|
|
1297
|
+
|
|
1298
|
+
createRoot(document.getElementById("root")).render(<App />);
|
|
1299
|
+
`,
|
|
1300
|
+
"apps/host/src/App.jsx": `import React from "react";
|
|
1301
|
+
import MfeContainer from "./MfeContainer";
|
|
1302
|
+
|
|
1303
|
+
export default function App() {
|
|
1304
|
+
return (
|
|
1305
|
+
<div>
|
|
1306
|
+
<h1>Host App — Isolated MFE demo</h1>
|
|
1307
|
+
<p style={{ padding: "0 1.5rem", color: "#64748b", fontSize: "0.8rem" }}>
|
|
1308
|
+
The auth widget below is rendered by mfe-auth into its own React root.
|
|
1309
|
+
The host and the remote do NOT share a React tree.
|
|
1310
|
+
</p>
|
|
1311
|
+
|
|
1312
|
+
{/* Host renders a plain DOM container.
|
|
1313
|
+
MfeContainer calls mount/unmount on that element. */}
|
|
1314
|
+
<MfeContainer user={{ name: "Alice", role: "admin" }} />
|
|
1315
|
+
</div>
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
`,
|
|
1319
|
+
"apps/host/src/MfeContainer.jsx": `import React, { useRef, useEffect } from "react";
|
|
1320
|
+
|
|
1321
|
+
// Lazy-load the remote's mount/unmount contract — not a React component.
|
|
1322
|
+
const mfeAuthMountPromise = import("mfeAuth/mount");
|
|
1323
|
+
|
|
1324
|
+
export default function MfeContainer({ user }) {
|
|
1325
|
+
const elRef = useRef(null);
|
|
1326
|
+
const mountedRef = useRef(null); // holds { unmount } returned by remote
|
|
1327
|
+
|
|
1328
|
+
useEffect(() => {
|
|
1329
|
+
let cancelled = false;
|
|
1330
|
+
let cleanup = null;
|
|
1331
|
+
|
|
1332
|
+
mfeAuthMountPromise.then(({ mount }) => {
|
|
1333
|
+
if (cancelled || !elRef.current) return;
|
|
1334
|
+
// mount() creates a new ReactDOM.createRoot inside mfe-auth.
|
|
1335
|
+
// It returns an object with an unmount() method.
|
|
1336
|
+
cleanup = mount(elRef.current, { user });
|
|
1337
|
+
mountedRef.current = cleanup;
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
return () => {
|
|
1341
|
+
cancelled = true;
|
|
1342
|
+
// On cleanup, call the remote's own unmount so it can tear down its root.
|
|
1343
|
+
cleanup?.unmount();
|
|
1344
|
+
};
|
|
1345
|
+
}, []); // mount once — treat like a portal
|
|
1346
|
+
|
|
1347
|
+
// If props change, tell the remote to update.
|
|
1348
|
+
// The remote decides how to handle prop updates.
|
|
1349
|
+
useEffect(() => {
|
|
1350
|
+
mountedRef.current?.update?.({ user });
|
|
1351
|
+
}, [user]);
|
|
1352
|
+
|
|
1353
|
+
return (
|
|
1354
|
+
<div
|
|
1355
|
+
ref={elRef}
|
|
1356
|
+
className="mfe-slot"
|
|
1357
|
+
style={{
|
|
1358
|
+
margin: "1rem 1.5rem",
|
|
1359
|
+
border: "1px solid #334155",
|
|
1360
|
+
borderRadius: "8px",
|
|
1361
|
+
padding: "1rem",
|
|
1362
|
+
minHeight: "80px",
|
|
1363
|
+
background: "#1e293b",
|
|
1364
|
+
}}
|
|
1365
|
+
/>
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
`,
|
|
1369
|
+
"apps/mfe-auth/package.json": `{
|
|
1370
|
+
"name": "@mf-isolated/mfe-auth",
|
|
1371
|
+
"version": "1.0.0",
|
|
1372
|
+
"private": true,
|
|
1373
|
+
"scripts": {
|
|
1374
|
+
"dev": "webpack serve --config webpack.config.js",
|
|
1375
|
+
"build": "webpack --config webpack.config.js"
|
|
1376
|
+
},
|
|
1377
|
+
"dependencies": {
|
|
1378
|
+
"react": "^19.0.0",
|
|
1379
|
+
"react-dom": "^19.0.0"
|
|
1380
|
+
},
|
|
1381
|
+
"devDependencies": {
|
|
1382
|
+
"esbuild": "^0.28.0",
|
|
1383
|
+
"esbuild-loader": "^4.4.3",
|
|
1384
|
+
"html-webpack-plugin": "^5.6.7",
|
|
1385
|
+
"webpack": "^5.106.2",
|
|
1386
|
+
"webpack-cli": "^7.0.2",
|
|
1387
|
+
"webpack-dev-server": "^5.2.3"
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
`,
|
|
1391
|
+
"apps/mfe-auth/webpack.config.js": `const HtmlWebpackPlugin = require("html-webpack-plugin");
|
|
1392
|
+
const { ModuleFederationPlugin } = require("webpack").container;
|
|
1393
|
+
const deps = require("./package.json").dependencies;
|
|
1394
|
+
|
|
1395
|
+
const MFE_AUTH_PORT = parseInt(process.env.MFE_AUTH_PORT || "3002");
|
|
1396
|
+
|
|
1397
|
+
module.exports = {
|
|
1398
|
+
mode: "development",
|
|
1399
|
+
entry: "./src/index.js",
|
|
1400
|
+
output: {
|
|
1401
|
+
publicPath: "auto",
|
|
1402
|
+
},
|
|
1403
|
+
resolve: { extensions: [".js", ".jsx"] },
|
|
1404
|
+
module: {
|
|
1405
|
+
rules: [
|
|
1406
|
+
{
|
|
1407
|
+
test: /\\.(js|jsx)$/,
|
|
1408
|
+
exclude: /node_modules/,
|
|
1409
|
+
use: {
|
|
1410
|
+
loader: "esbuild-loader",
|
|
1411
|
+
options: { loader: "jsx", jsx: "automatic", target: "es2020" },
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
],
|
|
1415
|
+
},
|
|
1416
|
+
plugins: [
|
|
1417
|
+
new ModuleFederationPlugin({
|
|
1418
|
+
name: "mfeAuth",
|
|
1419
|
+
filename: "remoteEntry.js",
|
|
1420
|
+
exposes: {
|
|
1421
|
+
// Key difference: we expose the MOUNT MODULE, not a React component.
|
|
1422
|
+
// The host never imports a JSX element from us directly.
|
|
1423
|
+
"./mount": "./src/mount.jsx",
|
|
1424
|
+
},
|
|
1425
|
+
// This remote brings its OWN React copy.
|
|
1426
|
+
// No singleton sharing with the host here.
|
|
1427
|
+
shared: {
|
|
1428
|
+
react: { requiredVersion: deps.react },
|
|
1429
|
+
"react-dom": { requiredVersion: deps["react-dom"] },
|
|
1430
|
+
},
|
|
1431
|
+
}),
|
|
1432
|
+
new HtmlWebpackPlugin({ template: "./public/index.html" }),
|
|
1433
|
+
],
|
|
1434
|
+
devServer: {
|
|
1435
|
+
port: MFE_AUTH_PORT,
|
|
1436
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
`,
|
|
1440
|
+
"apps/mfe-auth/public/index.html": `<!doctype html>
|
|
1441
|
+
<html lang="en">
|
|
1442
|
+
<head><meta charset="UTF-8" /><title>MFE Auth (standalone)</title></head>
|
|
1443
|
+
<body><div id="root"></div></body>
|
|
1444
|
+
</html>
|
|
1445
|
+
`,
|
|
1446
|
+
"apps/mfe-auth/src/index.js": `import("./bootstrap");
|
|
1447
|
+
`,
|
|
1448
|
+
"apps/mfe-auth/src/bootstrap.jsx": `// Standalone entry — only used when running mfe-auth on its own for development.
|
|
1449
|
+
import React from "react";
|
|
1450
|
+
import { createRoot } from "react-dom/client";
|
|
1451
|
+
import App from "./App";
|
|
1452
|
+
|
|
1453
|
+
createRoot(document.getElementById("root")).render(
|
|
1454
|
+
<App user={{ name: "Dev User", role: "developer" }} />
|
|
1455
|
+
);
|
|
1456
|
+
`,
|
|
1457
|
+
"apps/mfe-auth/src/App.jsx": `import React from "react";
|
|
1458
|
+
|
|
1459
|
+
// Standalone view — used for local development of the MFE in isolation.
|
|
1460
|
+
export default function App({ user = {} }) {
|
|
1461
|
+
return (
|
|
1462
|
+
<div style={{ padding: "1rem", fontFamily: "system-ui, sans-serif", background: "#0f172a", color: "#e2e8f0", minHeight: "100vh" }}>
|
|
1463
|
+
<h2 style={{ color: "#a78bfa", fontSize: "0.9rem", margin: "0 0 0.5rem" }}>
|
|
1464
|
+
mfe-auth — standalone dev view
|
|
1465
|
+
</h2>
|
|
1466
|
+
<AuthWidget user={user} />
|
|
1467
|
+
</div>
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// The actual React component that this MFE renders.
|
|
1472
|
+
export function AuthWidget({ user = {} }) {
|
|
1473
|
+
return (
|
|
1474
|
+
<div style={{ padding: "0.75rem 1rem", background: "#1e293b", borderRadius: "6px", border: "1px solid #334155" }}>
|
|
1475
|
+
<p style={{ margin: 0, fontSize: "0.8rem", color: "#94a3b8" }}>Auth MFE — isolated React root</p>
|
|
1476
|
+
<p style={{ margin: "0.5rem 0 0", fontSize: "0.75rem", color: "#64748b" }}>
|
|
1477
|
+
Logged in as: <strong style={{ color: "#e2e8f0" }}>{user.name ?? "Guest"}</strong>
|
|
1478
|
+
{user.role && <span style={{ marginLeft: "0.5rem", color: "#6366f1" }}>({user.role})</span>}
|
|
1479
|
+
</p>
|
|
1480
|
+
<p style={{ margin: "0.5rem 0 0", fontSize: "0.7rem", color: "#334155", fontStyle: "italic" }}>
|
|
1481
|
+
This component lives in its own React root — not in the host tree.
|
|
1482
|
+
React context from the host does not reach here.
|
|
1483
|
+
</p>
|
|
1484
|
+
</div>
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
`,
|
|
1488
|
+
"apps/mfe-auth/src/mount.jsx": `// ─────────────────────────────────────────────────────────────
|
|
1489
|
+
// mount.jsx — the public contract exposed via Module Federation
|
|
1490
|
+
//
|
|
1491
|
+
// The host imports THIS file, not a React component.
|
|
1492
|
+
// It calls mount(domElement, props) to render, unmount(domElement) to tear down.
|
|
1493
|
+
//
|
|
1494
|
+
// This is the core of the isolated MFE pattern:
|
|
1495
|
+
// 1. We create our OWN React root (not the host's)
|
|
1496
|
+
// 2. We own our own lifecycle
|
|
1497
|
+
// 3. The host never sees our React instance
|
|
1498
|
+
// ─────────────────────────────────────────────────────────────
|
|
1499
|
+
import React from "react";
|
|
1500
|
+
import { createRoot } from "react-dom/client";
|
|
1501
|
+
import { AuthWidget } from "./App";
|
|
1502
|
+
|
|
1503
|
+
// Keep track of roots by element so we can update or unmount them.
|
|
1504
|
+
const roots = new WeakMap();
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Mount the MFE into the given DOM element.
|
|
1508
|
+
*
|
|
1509
|
+
* @param {HTMLElement} el - the host-provided DOM node
|
|
1510
|
+
* @param {object} props - initial props from the host
|
|
1511
|
+
* @returns {{ unmount: () => void, update: (props: object) => void }}
|
|
1512
|
+
*/
|
|
1513
|
+
export function mount(el, props = {}) {
|
|
1514
|
+
const root = createRoot(el);
|
|
1515
|
+
roots.set(el, root);
|
|
1516
|
+
|
|
1517
|
+
root.render(<AuthWidget {...props} />);
|
|
1518
|
+
|
|
1519
|
+
return {
|
|
1520
|
+
/** Call this to pass updated props from the host without remounting. */
|
|
1521
|
+
update(newProps) {
|
|
1522
|
+
root.render(<AuthWidget {...newProps} />);
|
|
1523
|
+
},
|
|
1524
|
+
/** Tear down the React root when the host removes this MFE. */
|
|
1525
|
+
unmount() {
|
|
1526
|
+
root.unmount();
|
|
1527
|
+
roots.delete(el);
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Convenience function: unmount using only the element reference.
|
|
1534
|
+
* Useful if the host didn't store the return value of mount().
|
|
1535
|
+
*/
|
|
1536
|
+
export function unmount(el) {
|
|
1537
|
+
const root = roots.get(el);
|
|
1538
|
+
if (root) {
|
|
1539
|
+
root.unmount();
|
|
1540
|
+
roots.delete(el);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
`,
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
export const ISOLATED_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
|
|
1547
|
+
version: 1,
|
|
1548
|
+
label: "Webpack MF — Isolated Mount/Unmount",
|
|
1549
|
+
type: "module-federation",
|
|
1550
|
+
activeFile: "apps/mfe-auth/src/mount.jsx",
|
|
1551
|
+
files: MODULE_FEDERATION_ISOLATED_FILES,
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1146
1554
|
export function defaultForType(type: FrontendLabType): FrontendLabWorkspace {
|
|
1147
1555
|
if (type === "nextjs") return DEFAULT_NEXTJS_LAB;
|
|
1148
1556
|
if (type === "module-federation") return DEFAULT_MODULE_FEDERATION_LAB;
|
package/template/cockpit.json
CHANGED
|
@@ -2589,17 +2589,19 @@ function getModuleFederationCommandEnv(
|
|
|
2589
2589
|
sandbox: ModuleFederationSandboxEntry,
|
|
2590
2590
|
): NodeJS.ProcessEnv {
|
|
2591
2591
|
const hostPort = new URL(sandbox.appUrls.host).port;
|
|
2592
|
-
const
|
|
2593
|
-
const checkoutPort = new URL(sandbox.appUrls.checkout).port;
|
|
2594
|
-
|
|
2595
|
-
return {
|
|
2592
|
+
const env: NodeJS.ProcessEnv = {
|
|
2596
2593
|
...process.env,
|
|
2597
2594
|
HOST_PORT: hostPort,
|
|
2598
|
-
PROFILE_PORT: profilePort,
|
|
2599
|
-
CHECKOUT_PORT: checkoutPort,
|
|
2600
2595
|
MF_SANDBOX_ID: sandbox.id,
|
|
2601
2596
|
npm_config_update_notifier: "false",
|
|
2602
2597
|
};
|
|
2598
|
+
if (sandbox.appUrls.profile)
|
|
2599
|
+
env.PROFILE_PORT = new URL(sandbox.appUrls.profile).port;
|
|
2600
|
+
if (sandbox.appUrls.checkout)
|
|
2601
|
+
env.CHECKOUT_PORT = new URL(sandbox.appUrls.checkout).port;
|
|
2602
|
+
if (sandbox.appUrls.mfeAuth)
|
|
2603
|
+
env.MFE_AUTH_PORT = new URL(sandbox.appUrls.mfeAuth).port;
|
|
2604
|
+
return env;
|
|
2603
2605
|
}
|
|
2604
2606
|
|
|
2605
2607
|
async function runStreamedCommand(
|
|
@@ -2934,24 +2936,42 @@ app.post("/api/module-federation/start", async (req, res) => {
|
|
|
2934
2936
|
logs,
|
|
2935
2937
|
);
|
|
2936
2938
|
|
|
2937
|
-
|
|
2938
|
-
const
|
|
2939
|
+
// Detect isolated 2-app pattern (host + mfe-auth, no profile/checkout).
|
|
2940
|
+
const isIsolated =
|
|
2941
|
+
typeof files["apps/mfe-auth/package.json"] === "string" &&
|
|
2942
|
+
typeof files["apps/checkout/package.json"] !== "string";
|
|
2943
|
+
|
|
2944
|
+
const ports = await getDistinctPorts(isIsolated ? 2 : 3);
|
|
2945
|
+
const [hostPort] = ports;
|
|
2946
|
+
|
|
2947
|
+
const appUrls: Record<string, string> = {
|
|
2939
2948
|
host: `http://localhost:${hostPort}`,
|
|
2940
|
-
profile: `http://localhost:${profilePort}`,
|
|
2941
|
-
checkout: `http://localhost:${checkoutPort}`,
|
|
2942
2949
|
};
|
|
2950
|
+
const spawnEnv: NodeJS.ProcessEnv = {
|
|
2951
|
+
...process.env,
|
|
2952
|
+
HOST_PORT: String(hostPort),
|
|
2953
|
+
MF_SANDBOX_ID: id,
|
|
2954
|
+
npm_config_update_notifier: "false",
|
|
2955
|
+
};
|
|
2956
|
+
|
|
2957
|
+
if (isIsolated) {
|
|
2958
|
+
const [, mfeAuthPort] = ports;
|
|
2959
|
+
appUrls.mfeAuth = `http://localhost:${mfeAuthPort}`;
|
|
2960
|
+
spawnEnv.MFE_AUTH_PORT = String(mfeAuthPort);
|
|
2961
|
+
} else {
|
|
2962
|
+
const [, profilePort, checkoutPort] = ports;
|
|
2963
|
+
appUrls.profile = `http://localhost:${profilePort}`;
|
|
2964
|
+
appUrls.checkout = `http://localhost:${checkoutPort}`;
|
|
2965
|
+
spawnEnv.PROFILE_PORT = String(profilePort);
|
|
2966
|
+
spawnEnv.CHECKOUT_PORT = String(checkoutPort);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2943
2969
|
const readyPorts = new Set<string>();
|
|
2970
|
+
const requiredPorts = isIsolated ? 2 : 3;
|
|
2944
2971
|
|
|
2945
2972
|
const child = spawn(npmCommand(), ["run", "dev"], {
|
|
2946
2973
|
cwd: dir,
|
|
2947
|
-
env:
|
|
2948
|
-
...process.env,
|
|
2949
|
-
HOST_PORT: String(hostPort),
|
|
2950
|
-
PROFILE_PORT: String(profilePort),
|
|
2951
|
-
CHECKOUT_PORT: String(checkoutPort),
|
|
2952
|
-
MF_SANDBOX_ID: id,
|
|
2953
|
-
npm_config_update_notifier: "false",
|
|
2954
|
-
},
|
|
2974
|
+
env: spawnEnv,
|
|
2955
2975
|
});
|
|
2956
2976
|
|
|
2957
2977
|
const entry: ModuleFederationSandboxEntry = {
|
|
@@ -2966,11 +2986,10 @@ app.post("/api/module-federation/start", async (req, res) => {
|
|
|
2966
2986
|
};
|
|
2967
2987
|
|
|
2968
2988
|
const markReady = (text: string) => {
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
if (readyPorts.size === 3) {
|
|
2989
|
+
for (const [key, url] of Object.entries(appUrls)) {
|
|
2990
|
+
if (text.includes(new URL(url).host)) readyPorts.add(key);
|
|
2991
|
+
}
|
|
2992
|
+
if (readyPorts.size >= requiredPorts) {
|
|
2974
2993
|
entry.ready = true;
|
|
2975
2994
|
}
|
|
2976
2995
|
};
|