create-interview-cockpit 0.12.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 +1 -1
- package/template/client/src/App.tsx +30 -1
- package/template/client/src/api.ts +22 -0
- package/template/client/src/components/CodeContextPanel.tsx +0 -622
- package/template/client/src/components/DeploymentLabModal.tsx +1941 -0
- package/template/client/src/components/LabsPanel.tsx +626 -0
- package/template/client/src/components/Sidebar.tsx +97 -55
- package/template/client/src/reactLab.ts +408 -0
- package/template/client/src/store.ts +52 -1
- package/template/client/src/types.ts +2 -0
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +77 -24
- package/template/server/src/storage.ts +31 -0
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
Globe,
|
|
20
20
|
SlidersHorizontal,
|
|
21
21
|
ArrowRightLeft,
|
|
22
|
+
MoreHorizontal,
|
|
22
23
|
} from "lucide-react";
|
|
23
24
|
|
|
24
25
|
const ROOT_PARENT_VALUE = "__root__";
|
|
@@ -75,6 +76,9 @@ export default function Sidebar() {
|
|
|
75
76
|
const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
|
|
76
77
|
new Set(),
|
|
77
78
|
);
|
|
79
|
+
const [openMenuQuestionId, setOpenMenuQuestionId] = useState<string | null>(
|
|
80
|
+
null,
|
|
81
|
+
);
|
|
78
82
|
const [openTopicPrompts, setOpenTopicPrompts] = useState<Set<string>>(
|
|
79
83
|
new Set(),
|
|
80
84
|
);
|
|
@@ -320,6 +324,7 @@ export default function Sidebar() {
|
|
|
320
324
|
) => {
|
|
321
325
|
// 12px base left padding + 16px per depth level
|
|
322
326
|
const paddingLeft = 12 + depth * 16;
|
|
327
|
+
const isMenuOpen = openMenuQuestionId === q.id;
|
|
323
328
|
return (
|
|
324
329
|
<div
|
|
325
330
|
key={q.id}
|
|
@@ -371,7 +376,8 @@ export default function Sidebar() {
|
|
|
371
376
|
/>
|
|
372
377
|
) : (
|
|
373
378
|
<span
|
|
374
|
-
className="text-xs text-slate-400 truncate flex-1"
|
|
379
|
+
className="text-xs text-slate-400 truncate flex-1 min-w-0"
|
|
380
|
+
title={q.title}
|
|
375
381
|
onDoubleClick={(e) => {
|
|
376
382
|
e.stopPropagation();
|
|
377
383
|
setEditingQuestionId(q.id);
|
|
@@ -381,63 +387,99 @@ export default function Sidebar() {
|
|
|
381
387
|
{q.title}
|
|
382
388
|
</span>
|
|
383
389
|
)}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
</span>
|
|
387
|
-
{editingQuestionId !== q.id && (
|
|
388
|
-
<button
|
|
389
|
-
onClick={(e) => {
|
|
390
|
-
e.stopPropagation();
|
|
391
|
-
setAddingChildTo(q.id);
|
|
392
|
-
setNewChildTitle("");
|
|
393
|
-
}}
|
|
394
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
395
|
-
title="Add child question"
|
|
396
|
-
>
|
|
397
|
-
<CornerDownRight className="w-2.5 h-2.5" />
|
|
398
|
-
</button>
|
|
399
|
-
)}
|
|
400
|
-
{editingQuestionId !== q.id && (
|
|
401
|
-
<button
|
|
402
|
-
onClick={(e) => {
|
|
403
|
-
e.stopPropagation();
|
|
404
|
-
setEditingQuestionId(q.id);
|
|
405
|
-
setEditingQuestionTitle(q.title);
|
|
406
|
-
}}
|
|
407
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
408
|
-
title="Rename"
|
|
409
|
-
>
|
|
410
|
-
<Pencil className="w-2.5 h-2.5" />
|
|
411
|
-
</button>
|
|
412
|
-
)}
|
|
390
|
+
|
|
391
|
+
{/* Right side: count fades on hover, replaced by "..." menu */}
|
|
413
392
|
{editingQuestionId !== q.id && (
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
setMovingQuestionId((prev) => (prev === q.id ? null : q.id));
|
|
418
|
-
setMoveTargetParentId(q.parentQuestionId ?? ROOT_PARENT_VALUE);
|
|
419
|
-
}}
|
|
420
|
-
className={`p-0.5 rounded opacity-0 group-hover:opacity-100 transition-all ${
|
|
421
|
-
movingQuestionId === q.id
|
|
422
|
-
? "opacity-100 text-cyan-400"
|
|
423
|
-
: "text-slate-600 hover:text-cyan-400"
|
|
424
|
-
}`}
|
|
425
|
-
title="Move to a different parent"
|
|
393
|
+
<div
|
|
394
|
+
className="relative shrink-0 flex items-center"
|
|
395
|
+
onClick={(e) => e.stopPropagation()}
|
|
426
396
|
>
|
|
427
|
-
|
|
428
|
-
|
|
397
|
+
{/* Count — hidden while hovering or when menu is open */}
|
|
398
|
+
<span
|
|
399
|
+
className={`text-[10px] text-slate-700 ${
|
|
400
|
+
isMenuOpen ? "hidden" : "group-hover:hidden"
|
|
401
|
+
}`}
|
|
402
|
+
>
|
|
403
|
+
{q.messages.length > 0 ? `${q.messages.length}` : ""}
|
|
404
|
+
</span>
|
|
405
|
+
|
|
406
|
+
{/* "..." button — shown on hover or while menu is open */}
|
|
407
|
+
<button
|
|
408
|
+
onClick={() => setOpenMenuQuestionId(isMenuOpen ? null : q.id)}
|
|
409
|
+
className={`p-0.5 rounded transition-all ${
|
|
410
|
+
isMenuOpen
|
|
411
|
+
? "text-cyan-400"
|
|
412
|
+
: "opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300"
|
|
413
|
+
}`}
|
|
414
|
+
title="More options"
|
|
415
|
+
>
|
|
416
|
+
<MoreHorizontal className="w-3.5 h-3.5" />
|
|
417
|
+
</button>
|
|
418
|
+
|
|
419
|
+
{/* Dropdown */}
|
|
420
|
+
{isMenuOpen && (
|
|
421
|
+
<>
|
|
422
|
+
{/* Backdrop — closes menu when clicking outside */}
|
|
423
|
+
<div
|
|
424
|
+
className="fixed inset-0 z-40"
|
|
425
|
+
onClick={() => setOpenMenuQuestionId(null)}
|
|
426
|
+
/>
|
|
427
|
+
<div className="absolute right-0 top-full mt-0.5 z-50 bg-slate-800 border border-slate-700 rounded-md shadow-xl min-w-[140px] py-0.5">
|
|
428
|
+
<button
|
|
429
|
+
onClick={() => {
|
|
430
|
+
setOpenMenuQuestionId(null);
|
|
431
|
+
setEditingQuestionId(q.id);
|
|
432
|
+
setEditingQuestionTitle(q.title);
|
|
433
|
+
}}
|
|
434
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
435
|
+
>
|
|
436
|
+
<Pencil className="w-3 h-3" /> Rename
|
|
437
|
+
</button>
|
|
438
|
+
<button
|
|
439
|
+
onClick={() => {
|
|
440
|
+
setOpenMenuQuestionId(null);
|
|
441
|
+
setAddingChildTo(q.id);
|
|
442
|
+
setNewChildTitle("");
|
|
443
|
+
}}
|
|
444
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
445
|
+
>
|
|
446
|
+
<CornerDownRight className="w-3 h-3" /> Add child
|
|
447
|
+
</button>
|
|
448
|
+
<button
|
|
449
|
+
onClick={() => {
|
|
450
|
+
setOpenMenuQuestionId(null);
|
|
451
|
+
setMovingQuestionId((prev) =>
|
|
452
|
+
prev === q.id ? null : q.id,
|
|
453
|
+
);
|
|
454
|
+
setMoveTargetParentId(
|
|
455
|
+
q.parentQuestionId ?? ROOT_PARENT_VALUE,
|
|
456
|
+
);
|
|
457
|
+
}}
|
|
458
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
|
459
|
+
>
|
|
460
|
+
<ArrowRightLeft className="w-3 h-3" /> Move
|
|
461
|
+
</button>
|
|
462
|
+
<div className="border-t border-slate-700 my-0.5" />
|
|
463
|
+
<button
|
|
464
|
+
onClick={() => {
|
|
465
|
+
setOpenMenuQuestionId(null);
|
|
466
|
+
if (
|
|
467
|
+
window.confirm(
|
|
468
|
+
`Delete "${q.title}"? This cannot be undone.`,
|
|
469
|
+
)
|
|
470
|
+
) {
|
|
471
|
+
removeQuestion(q.id, topicId);
|
|
472
|
+
}
|
|
473
|
+
}}
|
|
474
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-slate-700 hover:text-red-300 transition-colors"
|
|
475
|
+
>
|
|
476
|
+
<Trash2 className="w-3 h-3" /> Delete
|
|
477
|
+
</button>
|
|
478
|
+
</div>
|
|
479
|
+
</>
|
|
480
|
+
)}
|
|
481
|
+
</div>
|
|
429
482
|
)}
|
|
430
|
-
<button
|
|
431
|
-
onClick={(e) => {
|
|
432
|
-
e.stopPropagation();
|
|
433
|
-
if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
|
|
434
|
-
removeQuestion(q.id, topicId);
|
|
435
|
-
}
|
|
436
|
-
}}
|
|
437
|
-
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
438
|
-
>
|
|
439
|
-
<Trash2 className="w-2.5 h-2.5" />
|
|
440
|
-
</button>
|
|
441
483
|
</div>
|
|
442
484
|
);
|
|
443
485
|
};
|
|
@@ -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;
|