create-interview-cockpit 0.15.0 → 0.17.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.
@@ -342,21 +342,47 @@ This lab uses real webpack 5 + webpack-dev-server + Module Federation.
342
342
  "react-router-dom": "^7.6.1"
343
343
  },
344
344
  "devDependencies": {
345
+ "@types/react": "^19.0.0",
346
+ "@types/react-dom": "^19.0.0",
345
347
  "esbuild": "^0.28.0",
346
348
  "esbuild-loader": "^4.4.3",
347
349
  "html-webpack-plugin": "^5.6.7",
350
+ "typescript": "^5.6.0",
348
351
  "webpack": "^5.106.2",
349
352
  "webpack-cli": "^7.0.2",
350
353
  "webpack-dev-server": "^5.2.3"
351
354
  }
352
355
  }
353
356
  `,
354
- "apps/shared/mfInspector.js": `import React from "react";
357
+ "apps/host/tsconfig.json": `{
358
+ "compilerOptions": {
359
+ "target": "ES2020",
360
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
361
+ "module": "ESNext",
362
+ "moduleResolution": "bundler",
363
+ "jsx": "react-jsx",
364
+ "strict": true,
365
+ "esModuleInterop": true,
366
+ "allowSyntheticDefaultImports": true,
367
+ "skipLibCheck": true,
368
+ "noEmit": true
369
+ },
370
+ "include": ["src"]
371
+ }
372
+ `,
373
+ "apps/shared/mfInspector.ts": `import React from "react";
355
374
  import ReactDOM from "react-dom";
356
375
  // NOTE: default import (not namespace import) is intentional — namespace objects
357
376
  // are never reference-equal across MF consumers even when the singleton is shared,
358
377
  // so comparing the default export is the only way to prove same-instance.
359
378
 
379
+ // Webpack DefinePlugin / MF globals injected at build time
380
+ declare const __MF_INSPECTOR_APP__: unknown;
381
+ declare const __MF_INSPECTOR_SANDBOX_ID__: unknown;
382
+ declare const __MF_INSPECTOR_DECLARED_CONFIG__: unknown;
383
+ declare const __webpack_init_sharing__: ((scope: string) => Promise<void>) | undefined;
384
+ declare const __webpack_share_scopes__: Record<string, unknown> | undefined;
385
+
360
386
  const app =
361
387
  typeof __MF_INSPECTOR_APP__ === "string" ? __MF_INSPECTOR_APP__ : "unknown";
362
388
  const sandboxId =
@@ -374,7 +400,7 @@ const runtimeId =
374
400
 
375
401
  let attachedRouteListeners = false;
376
402
 
377
- function getRoute() {
403
+ function getRoute(): string {
378
404
  if (typeof window === "undefined" || !window.location) return "/";
379
405
  const route =
380
406
  window.location.pathname +
@@ -383,7 +409,7 @@ function getRoute() {
383
409
  return route || "/";
384
410
  }
385
411
 
386
- function cloneJsonSafe(value) {
412
+ function cloneJsonSafe(value: unknown): unknown {
387
413
  if (value == null) return value;
388
414
  try {
389
415
  return JSON.parse(JSON.stringify(value));
@@ -392,7 +418,7 @@ function cloneJsonSafe(value) {
392
418
  }
393
419
  }
394
420
 
395
- export async function ensureShareScopeReady() {
421
+ export async function ensureShareScopeReady(): Promise<void> {
396
422
  try {
397
423
  if (typeof __webpack_init_sharing__ === "function") {
398
424
  await __webpack_init_sharing__("default");
@@ -431,7 +457,7 @@ export function snapshotShareScopes() {
431
457
  }
432
458
  }
433
459
 
434
- export function emitInspectorEvent(kind, payload) {
460
+ export function emitInspectorEvent(kind: string, payload: unknown): void {
435
461
  if (!sandboxId || typeof window === "undefined" || !window.parent) return;
436
462
  window.parent.postMessage(
437
463
  {
@@ -465,7 +491,7 @@ export function emitDeclaredConfig() {
465
491
  });
466
492
  }
467
493
 
468
- export async function emitRuntimeBoot(label) {
494
+ export async function emitRuntimeBoot(label?: string): Promise<void> {
469
495
  attachRouteListeners();
470
496
  await ensureShareScopeReady();
471
497
  emitDeclaredConfig();
@@ -481,7 +507,7 @@ export async function emitRuntimeBoot(label) {
481
507
  });
482
508
  }
483
509
 
484
- export async function emitRouteSnapshot(label) {
510
+ export async function emitRouteSnapshot(label?: string): Promise<void> {
485
511
  await ensureShareScopeReady();
486
512
  emitInspectorEvent("route-change", {
487
513
  label: label || "route",
@@ -501,7 +527,11 @@ export function getRemoteInspectorBridge() {
501
527
  };
502
528
  }
503
529
 
504
- export function makeInspectableLazy(remoteKey, componentLoad, debugLoad) {
530
+ export function makeInspectableLazy<T>(
531
+ remoteKey: string,
532
+ componentLoad: () => Promise<T>,
533
+ debugLoad?: (() => Promise<Record<string, unknown>>) | null,
534
+ ): () => Promise<T> {
505
535
  return async () => {
506
536
  await ensureShareScopeReady();
507
537
  emitInspectorEvent("remote-load-start", {
@@ -573,7 +603,9 @@ export function makeInspectableLazy(remoteKey, componentLoad, debugLoad) {
573
603
  };
574
604
  }
575
605
  `,
576
- "apps/shared/buildSharedConfig.js": `function createSharedConfig(packageJson) {
606
+ "apps/shared/buildSharedConfig.js": `
607
+ /** @param {{ dependencies?: Record<string, string> }} packageJson */
608
+ function createSharedConfig(packageJson) {
577
609
  const dependencyVersions =
578
610
  packageJson && typeof packageJson === "object"
579
611
  ? packageJson.dependencies || {}
@@ -611,14 +643,14 @@ export function makeInspectableLazy(remoteKey, componentLoad, debugLoad) {
611
643
 
612
644
  module.exports = { createSharedConfig };
613
645
  `,
614
- "apps/host/src/index.jsx": `import("./bootstrap");
646
+ "apps/host/src/index.tsx": `import("./bootstrap");
615
647
  `,
616
- "apps/host/src/bootstrap.jsx": `import React from "react";
648
+ "apps/host/src/bootstrap.tsx": `import React from "react";
617
649
  import { createRoot } from "react-dom/client";
618
650
  import App from "./App";
619
651
  import { emitRuntimeBoot } from "../../shared/mfInspector";
620
652
 
621
- const root = createRoot(document.getElementById("root"));
653
+ const root = createRoot(document.getElementById("root")!);
622
654
 
623
655
  root.render(
624
656
  <React.StrictMode>
@@ -628,7 +660,7 @@ root.render(
628
660
 
629
661
  void emitRuntimeBoot("host-bootstrap");
630
662
  `,
631
- "apps/host/src/App.jsx": `import React, { Suspense } from "react";
663
+ "apps/host/src/App.tsx": `import React, { Suspense } from "react";
632
664
  import { makeInspectableLazy } from "../../shared/mfInspector";
633
665
 
634
666
  const ProfileCard = React.lazy(
@@ -646,7 +678,7 @@ const CheckoutPanel = React.lazy(
646
678
  ),
647
679
  );
648
680
 
649
- function RemoteBoundary({ title, children }) {
681
+ function RemoteBoundary({ title, children }: { title: string; children: React.ReactNode }) {
650
682
  return (
651
683
  <div
652
684
  style={{
@@ -720,6 +752,7 @@ const { createSharedConfig } = require("../shared/buildSharedConfig");
720
752
  const hostPort = Number(process.env.HOST_PORT || 3100);
721
753
  const profilePort = Number(process.env.PROFILE_PORT || 3101);
722
754
  const checkoutPort = Number(process.env.CHECKOUT_PORT || 3102);
755
+ // TypeScript support via esbuild-loader
723
756
  const sharedConfig = createSharedConfig(packageJson);
724
757
  const remoteConfig = {
725
758
  profile: "profile@http://localhost:" + profilePort + "/remoteEntry.js",
@@ -733,24 +766,24 @@ const inspectorConfig = {
733
766
 
734
767
  module.exports = {
735
768
  mode: "development",
736
- entry: path.resolve(__dirname, "./src/index.jsx"),
769
+ entry: path.resolve(__dirname, "./src/index.tsx"),
737
770
  output: {
738
771
  path: path.resolve(__dirname, "./dist"),
739
772
  publicPath: "http://localhost:" + hostPort + "/",
740
773
  clean: true,
741
774
  },
742
775
  resolve: {
743
- extensions: [".js", ".jsx"],
776
+ extensions: [".js", ".jsx", ".ts", ".tsx"],
744
777
  },
745
778
  module: {
746
779
  rules: [
747
780
  {
748
- test: /\\.(js|jsx)$/,
781
+ test: /\\.(js|jsx|ts|tsx)$/,
749
782
  exclude: /node_modules/,
750
783
  use: {
751
784
  loader: "esbuild-loader",
752
785
  options: {
753
- loader: "jsx",
786
+ loader: "tsx",
754
787
  jsx: "automatic",
755
788
  target: "es2020",
756
789
  },
@@ -808,28 +841,47 @@ module.exports = {
808
841
  "react-router-dom": "^7.6.1"
809
842
  },
810
843
  "devDependencies": {
844
+ "@types/react": "^19.0.0",
845
+ "@types/react-dom": "^19.0.0",
811
846
  "esbuild": "^0.28.0",
812
847
  "esbuild-loader": "^4.4.3",
813
848
  "html-webpack-plugin": "^5.6.7",
849
+ "typescript": "^5.6.0",
814
850
  "webpack": "^5.106.2",
815
851
  "webpack-cli": "^7.0.2",
816
852
  "webpack-dev-server": "^5.2.3"
817
853
  }
818
854
  }
819
855
  `,
820
- "apps/profile/src/inspectorBridge.js": `import { getRemoteInspectorBridge } from "../../shared/mfInspector";
856
+ "apps/profile/tsconfig.json": `{
857
+ "compilerOptions": {
858
+ "target": "ES2020",
859
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
860
+ "module": "ESNext",
861
+ "moduleResolution": "bundler",
862
+ "jsx": "react-jsx",
863
+ "strict": true,
864
+ "esModuleInterop": true,
865
+ "allowSyntheticDefaultImports": true,
866
+ "skipLibCheck": true,
867
+ "noEmit": true
868
+ },
869
+ "include": ["src"]
870
+ }
871
+ `,
872
+ "apps/profile/src/inspectorBridge.ts": `import { getRemoteInspectorBridge } from "../../shared/mfInspector";
821
873
 
822
874
  export { getRemoteInspectorBridge };
823
875
  export default getRemoteInspectorBridge;
824
876
  `,
825
- "apps/profile/src/index.jsx": `import("./bootstrap");
877
+ "apps/profile/src/index.tsx": `import("./bootstrap");
826
878
  `,
827
- "apps/profile/src/bootstrap.jsx": `import React from "react";
879
+ "apps/profile/src/bootstrap.tsx": `import React from "react";
828
880
  import { createRoot } from "react-dom/client";
829
881
  import App from "./App";
830
882
  import { emitRuntimeBoot } from "../../shared/mfInspector";
831
883
 
832
- const root = createRoot(document.getElementById("root"));
884
+ const root = createRoot(document.getElementById("root")!);
833
885
 
834
886
  root.render(
835
887
  <React.StrictMode>
@@ -839,7 +891,7 @@ root.render(
839
891
 
840
892
  void emitRuntimeBoot("profile-bootstrap");
841
893
  `,
842
- "apps/profile/src/App.jsx": `import React from "react";
894
+ "apps/profile/src/App.tsx": `import React from "react";
843
895
  import ProfileCard from "./ProfileCard";
844
896
 
845
897
  export default function App() {
@@ -854,7 +906,7 @@ export default function App() {
854
906
  );
855
907
  }
856
908
  `,
857
- "apps/profile/src/ProfileCard.jsx": `import React from "react";
909
+ "apps/profile/src/ProfileCard.tsx": `import React from "react";
858
910
 
859
911
  export default function ProfileCard() {
860
912
  return (
@@ -883,8 +935,8 @@ const { createSharedConfig } = require("../shared/buildSharedConfig");
883
935
  const profilePort = Number(process.env.PROFILE_PORT || 3101);
884
936
  const sharedConfig = createSharedConfig(packageJson);
885
937
  const exposeConfig = {
886
- "./ProfileCard": path.resolve(__dirname, "./src/ProfileCard.jsx"),
887
- "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.js"),
938
+ "./ProfileCard": path.resolve(__dirname, "./src/ProfileCard.tsx"),
939
+ "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.ts"),
888
940
  };
889
941
  const inspectorConfig = {
890
942
  app: "profile",
@@ -894,24 +946,24 @@ const inspectorConfig = {
894
946
 
895
947
  module.exports = {
896
948
  mode: "development",
897
- entry: path.resolve(__dirname, "./src/index.jsx"),
949
+ entry: path.resolve(__dirname, "./src/index.tsx"),
898
950
  output: {
899
951
  path: path.resolve(__dirname, "./dist"),
900
952
  publicPath: "http://localhost:" + profilePort + "/",
901
953
  clean: true,
902
954
  },
903
955
  resolve: {
904
- extensions: [".js", ".jsx"],
956
+ extensions: [".js", ".jsx", ".ts", ".tsx"],
905
957
  },
906
958
  module: {
907
959
  rules: [
908
960
  {
909
- test: /\\.(js|jsx)$/,
961
+ test: /\\.(js|jsx|ts|tsx)$/,
910
962
  exclude: /node_modules/,
911
963
  use: {
912
964
  loader: "esbuild-loader",
913
965
  options: {
914
- loader: "jsx",
966
+ loader: "tsx",
915
967
  jsx: "automatic",
916
968
  target: "es2020",
917
969
  },
@@ -970,28 +1022,47 @@ module.exports = {
970
1022
  "react-router-dom": "^7.6.1"
971
1023
  },
972
1024
  "devDependencies": {
1025
+ "@types/react": "^19.0.0",
1026
+ "@types/react-dom": "^19.0.0",
973
1027
  "esbuild": "^0.28.0",
974
1028
  "esbuild-loader": "^4.4.3",
975
1029
  "html-webpack-plugin": "^5.6.7",
1030
+ "typescript": "^5.6.0",
976
1031
  "webpack": "^5.106.2",
977
1032
  "webpack-cli": "^7.0.2",
978
1033
  "webpack-dev-server": "^5.2.3"
979
1034
  }
980
1035
  }
981
1036
  `,
982
- "apps/checkout/src/inspectorBridge.js": `import { getRemoteInspectorBridge } from "../../shared/mfInspector";
1037
+ "apps/checkout/tsconfig.json": `{
1038
+ "compilerOptions": {
1039
+ "target": "ES2020",
1040
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
1041
+ "module": "ESNext",
1042
+ "moduleResolution": "bundler",
1043
+ "jsx": "react-jsx",
1044
+ "strict": true,
1045
+ "esModuleInterop": true,
1046
+ "allowSyntheticDefaultImports": true,
1047
+ "skipLibCheck": true,
1048
+ "noEmit": true
1049
+ },
1050
+ "include": ["src"]
1051
+ }
1052
+ `,
1053
+ "apps/checkout/src/inspectorBridge.ts": `import { getRemoteInspectorBridge } from "../../shared/mfInspector";
983
1054
 
984
1055
  export { getRemoteInspectorBridge };
985
1056
  export default getRemoteInspectorBridge;
986
1057
  `,
987
- "apps/checkout/src/index.jsx": `import("./bootstrap");
1058
+ "apps/checkout/src/index.tsx": `import("./bootstrap");
988
1059
  `,
989
- "apps/checkout/src/bootstrap.jsx": `import React from "react";
1060
+ "apps/checkout/src/bootstrap.tsx": `import React from "react";
990
1061
  import { createRoot } from "react-dom/client";
991
1062
  import App from "./App";
992
1063
  import { emitRuntimeBoot } from "../../shared/mfInspector";
993
1064
 
994
- const root = createRoot(document.getElementById("root"));
1065
+ const root = createRoot(document.getElementById("root")!);
995
1066
 
996
1067
  root.render(
997
1068
  <React.StrictMode>
@@ -1001,7 +1072,7 @@ root.render(
1001
1072
 
1002
1073
  void emitRuntimeBoot("checkout-bootstrap");
1003
1074
  `,
1004
- "apps/checkout/src/App.jsx": `import React from "react";
1075
+ "apps/checkout/src/App.tsx": `import React from "react";
1005
1076
  import CheckoutPanel from "./CheckoutPanel";
1006
1077
 
1007
1078
  export default function App() {
@@ -1016,7 +1087,7 @@ export default function App() {
1016
1087
  );
1017
1088
  }
1018
1089
  `,
1019
- "apps/checkout/src/CheckoutPanel.jsx": `import React from "react";
1090
+ "apps/checkout/src/CheckoutPanel.tsx": `import React from "react";
1020
1091
 
1021
1092
  export default function CheckoutPanel() {
1022
1093
  return (
@@ -1053,8 +1124,8 @@ const { createSharedConfig } = require("../shared/buildSharedConfig");
1053
1124
  const checkoutPort = Number(process.env.CHECKOUT_PORT || 3102);
1054
1125
  const sharedConfig = createSharedConfig(packageJson);
1055
1126
  const exposeConfig = {
1056
- "./CheckoutPanel": path.resolve(__dirname, "./src/CheckoutPanel.jsx"),
1057
- "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.js"),
1127
+ "./CheckoutPanel": path.resolve(__dirname, "./src/CheckoutPanel.tsx"),
1128
+ "./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.ts"),
1058
1129
  };
1059
1130
  const inspectorConfig = {
1060
1131
  app: "checkout",
@@ -1064,24 +1135,24 @@ const inspectorConfig = {
1064
1135
 
1065
1136
  module.exports = {
1066
1137
  mode: "development",
1067
- entry: path.resolve(__dirname, "./src/index.jsx"),
1138
+ entry: path.resolve(__dirname, "./src/index.tsx"),
1068
1139
  output: {
1069
1140
  path: path.resolve(__dirname, "./dist"),
1070
1141
  publicPath: "http://localhost:" + checkoutPort + "/",
1071
1142
  clean: true,
1072
1143
  },
1073
1144
  resolve: {
1074
- extensions: [".js", ".jsx"],
1145
+ extensions: [".js", ".jsx", ".ts", ".tsx"],
1075
1146
  },
1076
1147
  module: {
1077
1148
  rules: [
1078
1149
  {
1079
- test: /\\.(js|jsx)$/,
1150
+ test: /\\.(js|jsx|ts|tsx)$/,
1080
1151
  exclude: /node_modules/,
1081
1152
  use: {
1082
1153
  loader: "esbuild-loader",
1083
1154
  options: {
1084
- loader: "jsx",
1155
+ loader: "tsx",
1085
1156
  jsx: "automatic",
1086
1157
  target: "es2020",
1087
1158
  },
@@ -1139,7 +1210,7 @@ export const DEFAULT_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
1139
1210
  version: 1,
1140
1211
  label: "Webpack Module Federation Lab",
1141
1212
  type: "module-federation",
1142
- activeFile: "apps/host/src/App.jsx",
1213
+ activeFile: "apps/host/src/App.tsx",
1143
1214
  files: MODULE_FEDERATION_DEFAULT_FILES,
1144
1215
  };
1145
1216
 
@@ -1208,14 +1279,33 @@ In this **isolated** pattern:
1208
1279
  "react-dom": "^19.0.0"
1209
1280
  },
1210
1281
  "devDependencies": {
1282
+ "@types/react": "^19.0.0",
1283
+ "@types/react-dom": "^19.0.0",
1211
1284
  "esbuild": "^0.28.0",
1212
1285
  "esbuild-loader": "^4.4.3",
1213
1286
  "html-webpack-plugin": "^5.6.7",
1287
+ "typescript": "^5.6.0",
1214
1288
  "webpack": "^5.106.2",
1215
1289
  "webpack-cli": "^7.0.2",
1216
1290
  "webpack-dev-server": "^5.2.3"
1217
1291
  }
1218
1292
  }
1293
+ `,
1294
+ "apps/host/tsconfig.json": `{
1295
+ "compilerOptions": {
1296
+ "target": "ES2020",
1297
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
1298
+ "module": "ESNext",
1299
+ "moduleResolution": "bundler",
1300
+ "jsx": "react-jsx",
1301
+ "strict": true,
1302
+ "esModuleInterop": true,
1303
+ "allowSyntheticDefaultImports": true,
1304
+ "skipLibCheck": true,
1305
+ "noEmit": true
1306
+ },
1307
+ "include": ["src"]
1308
+ }
1219
1309
  `,
1220
1310
  "apps/host/webpack.config.js": `const HtmlWebpackPlugin = require("html-webpack-plugin");
1221
1311
  const { ModuleFederationPlugin } = require("webpack").container;
@@ -1226,19 +1316,19 @@ const MFE_AUTH_PORT = parseInt(process.env.MFE_AUTH_PORT || "3002");
1226
1316
 
1227
1317
  module.exports = {
1228
1318
  mode: "development",
1229
- entry: "./src/index.js",
1319
+ entry: "./src/index.ts",
1230
1320
  output: {
1231
1321
  publicPath: "auto",
1232
1322
  },
1233
- resolve: { extensions: [".js", ".jsx"] },
1323
+ resolve: { extensions: [".js", ".jsx", ".ts", ".tsx"] },
1234
1324
  module: {
1235
1325
  rules: [
1236
1326
  {
1237
- test: /\\.(js|jsx)$/,
1327
+ test: /\\.(js|jsx|ts|tsx)$/,
1238
1328
  exclude: /node_modules/,
1239
1329
  use: {
1240
1330
  loader: "esbuild-loader",
1241
- options: { loader: "jsx", jsx: "automatic", target: "es2020" },
1331
+ options: { loader: "tsx", jsx: "automatic", target: "es2020" },
1242
1332
  },
1243
1333
  },
1244
1334
  ],
@@ -1288,16 +1378,16 @@ module.exports = {
1288
1378
  </body>
1289
1379
  </html>
1290
1380
  `,
1291
- "apps/host/src/index.js": `// Async boundary: required for Module Federation dynamic imports
1381
+ "apps/host/src/index.ts": `// Async boundary: required for Module Federation dynamic imports
1292
1382
  import("./bootstrap");
1293
1383
  `,
1294
- "apps/host/src/bootstrap.jsx": `import React from "react";
1384
+ "apps/host/src/bootstrap.tsx": `import React from "react";
1295
1385
  import { createRoot } from "react-dom/client";
1296
1386
  import App from "./App";
1297
1387
 
1298
- createRoot(document.getElementById("root")).render(<App />);
1388
+ createRoot(document.getElementById("root")!).render(<App />);
1299
1389
  `,
1300
- "apps/host/src/App.jsx": `import React from "react";
1390
+ "apps/host/src/App.tsx": `import React from "react";
1301
1391
  import MfeContainer from "./MfeContainer";
1302
1392
 
1303
1393
  export default function App() {
@@ -1316,18 +1406,30 @@ export default function App() {
1316
1406
  );
1317
1407
  }
1318
1408
  `,
1319
- "apps/host/src/MfeContainer.jsx": `import React, { useRef, useEffect } from "react";
1409
+ "apps/host/src/MfeContainer.tsx": `import React, { useRef, useEffect } from "react";
1410
+
1411
+ interface User {
1412
+ name?: string;
1413
+ role?: string;
1414
+ }
1415
+
1416
+ interface MfeMount {
1417
+ update?: (props: { user: User }) => void;
1418
+ unmount?: () => void;
1419
+ }
1320
1420
 
1321
1421
  // Lazy-load the remote's mount/unmount contract — not a React component.
1322
- const mfeAuthMountPromise = import("mfeAuth/mount");
1422
+ const mfeAuthMountPromise = import("mfeAuth/mount") as Promise<{
1423
+ mount: (el: HTMLElement, props: { user: User }) => MfeMount;
1424
+ }>;
1323
1425
 
1324
- export default function MfeContainer({ user }) {
1325
- const elRef = useRef(null);
1326
- const mountedRef = useRef(null); // holds { unmount } returned by remote
1426
+ export default function MfeContainer({ user }: { user: User }) {
1427
+ const elRef = useRef<HTMLDivElement>(null);
1428
+ const mountedRef = useRef<MfeMount | null>(null);
1327
1429
 
1328
1430
  useEffect(() => {
1329
1431
  let cancelled = false;
1330
- let cleanup = null;
1432
+ let cleanup: MfeMount | null = null;
1331
1433
 
1332
1434
  mfeAuthMountPromise.then(({ mount }) => {
1333
1435
  if (cancelled || !elRef.current) return;
@@ -1340,7 +1442,7 @@ export default function MfeContainer({ user }) {
1340
1442
  return () => {
1341
1443
  cancelled = true;
1342
1444
  // On cleanup, call the remote's own unmount so it can tear down its root.
1343
- cleanup?.unmount();
1445
+ cleanup?.unmount?.();
1344
1446
  };
1345
1447
  }, []); // mount once — treat like a portal
1346
1448
 
@@ -1379,14 +1481,33 @@ export default function MfeContainer({ user }) {
1379
1481
  "react-dom": "^19.0.0"
1380
1482
  },
1381
1483
  "devDependencies": {
1484
+ "@types/react": "^19.0.0",
1485
+ "@types/react-dom": "^19.0.0",
1382
1486
  "esbuild": "^0.28.0",
1383
1487
  "esbuild-loader": "^4.4.3",
1384
1488
  "html-webpack-plugin": "^5.6.7",
1489
+ "typescript": "^5.6.0",
1385
1490
  "webpack": "^5.106.2",
1386
1491
  "webpack-cli": "^7.0.2",
1387
1492
  "webpack-dev-server": "^5.2.3"
1388
1493
  }
1389
1494
  }
1495
+ `,
1496
+ "apps/mfe-auth/tsconfig.json": `{
1497
+ "compilerOptions": {
1498
+ "target": "ES2020",
1499
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
1500
+ "module": "ESNext",
1501
+ "moduleResolution": "bundler",
1502
+ "jsx": "react-jsx",
1503
+ "strict": true,
1504
+ "esModuleInterop": true,
1505
+ "allowSyntheticDefaultImports": true,
1506
+ "skipLibCheck": true,
1507
+ "noEmit": true
1508
+ },
1509
+ "include": ["src"]
1510
+ }
1390
1511
  `,
1391
1512
  "apps/mfe-auth/webpack.config.js": `const HtmlWebpackPlugin = require("html-webpack-plugin");
1392
1513
  const { ModuleFederationPlugin } = require("webpack").container;
@@ -1396,19 +1517,19 @@ const MFE_AUTH_PORT = parseInt(process.env.MFE_AUTH_PORT || "3002");
1396
1517
 
1397
1518
  module.exports = {
1398
1519
  mode: "development",
1399
- entry: "./src/index.js",
1520
+ entry: "./src/index.ts",
1400
1521
  output: {
1401
1522
  publicPath: "auto",
1402
1523
  },
1403
- resolve: { extensions: [".js", ".jsx"] },
1524
+ resolve: { extensions: [".js", ".jsx", ".ts", ".tsx"] },
1404
1525
  module: {
1405
1526
  rules: [
1406
1527
  {
1407
- test: /\\.(js|jsx)$/,
1528
+ test: /\\.(js|jsx|ts|tsx)$/,
1408
1529
  exclude: /node_modules/,
1409
1530
  use: {
1410
1531
  loader: "esbuild-loader",
1411
- options: { loader: "jsx", jsx: "automatic", target: "es2020" },
1532
+ options: { loader: "tsx", jsx: "automatic", target: "es2020" },
1412
1533
  },
1413
1534
  },
1414
1535
  ],
@@ -1420,7 +1541,7 @@ module.exports = {
1420
1541
  exposes: {
1421
1542
  // Key difference: we expose the MOUNT MODULE, not a React component.
1422
1543
  // The host never imports a JSX element from us directly.
1423
- "./mount": "./src/mount.jsx",
1544
+ "./mount": "./src/mount.tsx",
1424
1545
  },
1425
1546
  // This remote brings its OWN React copy.
1426
1547
  // No singleton sharing with the host here.
@@ -1443,21 +1564,26 @@ module.exports = {
1443
1564
  <body><div id="root"></div></body>
1444
1565
  </html>
1445
1566
  `,
1446
- "apps/mfe-auth/src/index.js": `import("./bootstrap");
1567
+ "apps/mfe-auth/src/index.ts": `import("./bootstrap");
1447
1568
  `,
1448
- "apps/mfe-auth/src/bootstrap.jsx": `// Standalone entry — only used when running mfe-auth on its own for development.
1569
+ "apps/mfe-auth/src/bootstrap.tsx": `// Standalone entry — only used when running mfe-auth on its own for development.
1449
1570
  import React from "react";
1450
1571
  import { createRoot } from "react-dom/client";
1451
1572
  import App from "./App";
1452
1573
 
1453
- createRoot(document.getElementById("root")).render(
1574
+ createRoot(document.getElementById("root")!).render(
1454
1575
  <App user={{ name: "Dev User", role: "developer" }} />
1455
1576
  );
1456
1577
  `,
1457
- "apps/mfe-auth/src/App.jsx": `import React from "react";
1578
+ "apps/mfe-auth/src/App.tsx": `import React from "react";
1579
+
1580
+ interface User {
1581
+ name?: string;
1582
+ role?: string;
1583
+ }
1458
1584
 
1459
1585
  // Standalone view — used for local development of the MFE in isolation.
1460
- export default function App({ user = {} }) {
1586
+ export default function App({ user = {} }: { user?: User }) {
1461
1587
  return (
1462
1588
  <div style={{ padding: "1rem", fontFamily: "system-ui, sans-serif", background: "#0f172a", color: "#e2e8f0", minHeight: "100vh" }}>
1463
1589
  <h2 style={{ color: "#a78bfa", fontSize: "0.9rem", margin: "0 0 0.5rem" }}>
@@ -1469,7 +1595,7 @@ export default function App({ user = {} }) {
1469
1595
  }
1470
1596
 
1471
1597
  // The actual React component that this MFE renders.
1472
- export function AuthWidget({ user = {} }) {
1598
+ export function AuthWidget({ user = {} }: { user?: User }) {
1473
1599
  return (
1474
1600
  <div style={{ padding: "0.75rem 1rem", background: "#1e293b", borderRadius: "6px", border: "1px solid #334155" }}>
1475
1601
  <p style={{ margin: 0, fontSize: "0.8rem", color: "#94a3b8" }}>Auth MFE — isolated React root</p>
@@ -1485,8 +1611,8 @@ export function AuthWidget({ user = {} }) {
1485
1611
  );
1486
1612
  }
1487
1613
  `,
1488
- "apps/mfe-auth/src/mount.jsx": `// ─────────────────────────────────────────────────────────────
1489
- // mount.jsx — the public contract exposed via Module Federation
1614
+ "apps/mfe-auth/src/mount.tsx": `// ─────────────────────────────────────────────────────────────
1615
+ // mount.tsx — the public contract exposed via Module Federation
1490
1616
  //
1491
1617
  // The host imports THIS file, not a React component.
1492
1618
  // It calls mount(domElement, props) to render, unmount(domElement) to tear down.
@@ -1500,17 +1626,23 @@ import React from "react";
1500
1626
  import { createRoot } from "react-dom/client";
1501
1627
  import { AuthWidget } from "./App";
1502
1628
 
1629
+ interface User {
1630
+ name?: string;
1631
+ role?: string;
1632
+ }
1633
+
1634
+ interface MfeHandle {
1635
+ update: (props: { user?: User }) => void;
1636
+ unmount: () => void;
1637
+ }
1638
+
1503
1639
  // Keep track of roots by element so we can update or unmount them.
1504
- const roots = new WeakMap();
1640
+ const roots = new WeakMap<HTMLElement, ReturnType<typeof createRoot>>();
1505
1641
 
1506
1642
  /**
1507
1643
  * 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
1644
  */
1513
- export function mount(el, props = {}) {
1645
+ export function mount(el: HTMLElement, props: { user?: User } = {}): MfeHandle {
1514
1646
  const root = createRoot(el);
1515
1647
  roots.set(el, root);
1516
1648
 
@@ -1518,7 +1650,7 @@ export function mount(el, props = {}) {
1518
1650
 
1519
1651
  return {
1520
1652
  /** Call this to pass updated props from the host without remounting. */
1521
- update(newProps) {
1653
+ update(newProps: { user?: User }) {
1522
1654
  root.render(<AuthWidget {...newProps} />);
1523
1655
  },
1524
1656
  /** Tear down the React root when the host removes this MFE. */
@@ -1531,9 +1663,8 @@ export function mount(el, props = {}) {
1531
1663
 
1532
1664
  /**
1533
1665
  * Convenience function: unmount using only the element reference.
1534
- * Useful if the host didn't store the return value of mount().
1535
1666
  */
1536
- export function unmount(el) {
1667
+ export function unmount(el: HTMLElement): void {
1537
1668
  const root = roots.get(el);
1538
1669
  if (root) {
1539
1670
  root.unmount();
@@ -1547,99 +1678,1396 @@ export const ISOLATED_MODULE_FEDERATION_LAB: FrontendLabWorkspace = {
1547
1678
  version: 1,
1548
1679
  label: "Webpack MF — Isolated Mount/Unmount",
1549
1680
  type: "module-federation",
1550
- activeFile: "apps/mfe-auth/src/mount.jsx",
1681
+ activeFile: "apps/mfe-auth/src/mount.tsx",
1551
1682
  files: MODULE_FEDERATION_ISOLATED_FILES,
1552
1683
  };
1553
1684
 
1554
- export function defaultForType(type: FrontendLabType): FrontendLabWorkspace {
1555
- if (type === "nextjs") return DEFAULT_NEXTJS_LAB;
1556
- if (type === "module-federation") return DEFAULT_MODULE_FEDERATION_LAB;
1557
- return DEFAULT_REACT_LAB;
1685
+ // ─── Next.js MF Option A — @module-federation/nextjs-mf plugin ──────────────
1686
+ const NEXTJS_MF_PLUGIN_FILES: Record<string, string> = {
1687
+ "README.md": `# Next.js Module Federation — Option A: built-in webpack ModuleFederationPlugin
1688
+
1689
+ ## What is this
1690
+
1691
+ A minimal Next.js shell that loads a remote Next.js app using webpack's built-in
1692
+ \`ModuleFederationPlugin\` — no extra npm packages needed.
1693
+ Both shell and remote are Next.js apps that configure the plugin inside their \`next.config.js\`.
1694
+
1695
+ ## Structure
1696
+
1697
+ - \`apps/shell/\` — Next.js shell app
1698
+ - \`apps/remote/\` — Next.js remote app that exposes a widget
1699
+
1700
+ Ports are assigned automatically by the lab runner (HOST_PORT for shell, REMOTE_PORT for remote).
1701
+
1702
+ ## Key idea
1703
+
1704
+ The shell's \`next.config.js\` registers \`webpack.container.ModuleFederationPlugin\` with no
1705
+ static remotes — the remote URL is resolved at runtime via the \`RemoteSlot\` component.
1706
+ No \`@module-federation/nextjs-mf\` package is required.
1707
+
1708
+ ## Things to try
1709
+
1710
+ 1. Stop the remote and reload the shell — observe the fallback UI.
1711
+ 2. Add a second exposed component in the remote's \`next.config.js\` and load it from the shell.
1712
+ 3. Change the entry URL to point to a CDN-hosted remoteEntry.js.
1713
+ `,
1714
+ "package.json": `{
1715
+ "name": "nextjs-mf-plugin-lab",
1716
+ "private": true,
1717
+ "workspaces": [
1718
+ "apps/shell",
1719
+ "apps/remote"
1720
+ ],
1721
+ "scripts": {
1722
+ "dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=nextjs-mf-remote' 'npm run dev --workspace=nextjs-mf-shell'"
1723
+ },
1724
+ "devDependencies": {
1725
+ "concurrently": "^9.2.1"
1726
+ },
1727
+ "overrides": {
1728
+ "undici": "^7"
1729
+ }
1730
+ }
1731
+ `,
1732
+ "apps/shell/package.json": `{
1733
+ "name": "nextjs-mf-shell",
1734
+ "private": true,
1735
+ "scripts": {
1736
+ "dev": "next dev -p $HOST_PORT",
1737
+ "build": "next build",
1738
+ "start": "next start"
1739
+ },
1740
+ "dependencies": {
1741
+ "next": "latest",
1742
+ "react": "^19.0.0",
1743
+ "react-dom": "^19.0.0"
1744
+ }
1558
1745
  }
1746
+ `,
1747
+ "apps/shell/next.config.ts": `import type { NextConfig } from 'next';
1748
+ // Use webpack's built-in ModuleFederationPlugin — no extra npm package.
1749
+ import { container } from 'webpack';
1750
+
1751
+ const nextConfig: NextConfig = {
1752
+ reactStrictMode: true,
1753
+ webpack(config, { webpack }) {
1754
+ config.plugins.push(
1755
+ new webpack.container.ModuleFederationPlugin({
1756
+ name: 'shell',
1757
+ // Shell exposes nothing, just consumes remotes at runtime.
1758
+ remotes: {},
1759
+ shared: {
1760
+ react: { singleton: true, requiredVersion: false },
1761
+ 'react-dom': { singleton: true, requiredVersion: false },
1762
+ },
1763
+ })
1764
+ );
1765
+ return config;
1766
+ },
1767
+ };
1559
1768
 
1560
- export function cloneFrontendLabWorkspace(
1561
- workspace?: FrontendLabWorkspace | null,
1562
- type?: FrontendLabType,
1563
- ): FrontendLabWorkspace {
1564
- const resolvedType = workspace?.type ?? type ?? "react";
1565
- const defaults = defaultForType(resolvedType);
1566
- const source = workspace ?? defaults;
1567
- const files =
1568
- source.files && Object.keys(source.files).length > 0
1569
- ? { ...source.files }
1570
- : { ...defaults.files };
1571
- const activeFile = files[source.activeFile]
1572
- ? source.activeFile
1573
- : (Object.keys(files)[0] ?? defaults.activeFile);
1769
+ export default nextConfig;
1770
+ `,
1771
+ "apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
1772
+ import React from 'react';
1574
1773
 
1575
- return {
1576
- version: 1,
1577
- label: source.label?.trim() || defaults.label,
1578
- type: resolvedType,
1579
- activeFile,
1580
- files,
1581
- };
1774
+ export const metadata: Metadata = { title: 'Shell — Next.js MF' };
1775
+
1776
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1777
+ return (
1778
+ <html lang="en">
1779
+ <body>{children}</body>
1780
+ </html>
1781
+ );
1582
1782
  }
1783
+ `,
1784
+ "apps/shell/src/app/page.tsx": `'use client';
1583
1785
 
1584
- export function serializeFrontendLabWorkspace(
1585
- workspace: FrontendLabWorkspace,
1586
- ): string {
1587
- return JSON.stringify(cloneFrontendLabWorkspace(workspace), null, 2);
1786
+ import React from 'react';
1787
+ import { RemoteSlot } from '@/components/RemoteSlot';
1788
+
1789
+ export default function Home() {
1790
+ return (
1791
+ <main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
1792
+ <h1>Next.js Shell — Option A</h1>
1793
+ <p style={{ color: '#64748b' }}>
1794
+ The widget below is loaded from the Next.js remote at runtime.
1795
+ Shell and remote both use Next.js with webpack's built-in ModuleFederationPlugin.
1796
+ </p>
1797
+
1798
+ {/* NEXT_PUBLIC_REMOTE_URL is injected by the lab runner */}
1799
+ <RemoteSlot
1800
+ scope="myRemote"
1801
+ module="./Widget"
1802
+ remoteUrl={
1803
+ process.env.NEXT_PUBLIC_REMOTE_URL ||
1804
+ 'http://localhost:3001/_next/static/chunks/remoteEntry.js'
1805
+ }
1806
+ fallback={<p style={{ color: '#ef4444' }}>Remote unavailable.</p>}
1807
+ />
1808
+ </main>
1809
+ );
1588
1810
  }
1811
+ `,
1812
+ "apps/shell/src/components/RemoteSlot.tsx": `'use client';
1589
1813
 
1590
- export function parseFrontendLabWorkspace(
1591
- raw: string,
1592
- ): FrontendLabWorkspace | null {
1593
- try {
1594
- const parsed = JSON.parse(raw) as Partial<FrontendLabWorkspace> & {
1595
- files?: Record<string, unknown>;
1596
- };
1597
- if (!parsed || typeof parsed !== "object") return null;
1598
- if (!parsed.files || typeof parsed.files !== "object") return null;
1814
+ import React from 'react';
1599
1815
 
1600
- const files = Object.fromEntries(
1601
- Object.entries(parsed.files).filter(
1602
- (e): e is [string, string] => typeof e[1] === "string",
1816
+ type Props = {
1817
+ scope: string;
1818
+ module: string;
1819
+ remoteUrl: string;
1820
+ fallback?: React.ReactNode;
1821
+ };
1822
+
1823
+ // Webpack federation globals — available because shell's next.config.js
1824
+ // registers ModuleFederationPlugin which injects them at runtime.
1825
+ declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
1826
+ declare const __webpack_share_scopes__: { default: unknown };
1827
+
1828
+ async function loadRemoteModule(scope: string, module: string, remoteUrl: string) {
1829
+ // 1. Inject the remote's remoteEntry.js once.
1830
+ if (!(window as Record<string, unknown>)[scope]) {
1831
+ await new Promise<void>((resolve, reject) => {
1832
+ const script = document.createElement('script');
1833
+ script.src = remoteUrl;
1834
+ script.async = true;
1835
+ script.onload = () => resolve();
1836
+ script.onerror = () => reject(new Error(\`Failed to load remote: \${scope}\`));
1837
+ document.head.appendChild(script);
1838
+ });
1839
+ }
1840
+
1841
+ // 2. Init the shared scope so singleton packages (react) are negotiated.
1842
+ await __webpack_init_sharing__('default');
1843
+
1844
+ const container = (window as Record<string, unknown>)[scope] as {
1845
+ init(s: unknown): Promise<void>;
1846
+ get(m: string): Promise<() => unknown>;
1847
+ };
1848
+
1849
+ await container.init(__webpack_share_scopes__.default);
1850
+
1851
+ // 3. Get the exposed module factory and invoke it.
1852
+ const factory = await container.get(module);
1853
+ return factory() as { default: React.ComponentType };
1854
+ }
1855
+
1856
+ export function RemoteSlot({ scope, module, remoteUrl, fallback }: Props) {
1857
+ const LazyComponent = React.useMemo(
1858
+ () =>
1859
+ React.lazy(() =>
1860
+ loadRemoteModule(scope, module, remoteUrl).then((mod) => ({
1861
+ default: mod.default,
1862
+ }))
1603
1863
  ),
1864
+ [scope, module, remoteUrl]
1865
+ );
1866
+
1867
+ return (
1868
+ <React.Suspense fallback={<p>Loading remote...</p>}>
1869
+ <LazyComponent />
1870
+ </React.Suspense>
1871
+ );
1872
+ }
1873
+ `,
1874
+ "apps/remote/package.json": `{
1875
+ "name": "nextjs-mf-remote",
1876
+ "private": true,
1877
+ "scripts": {
1878
+ "dev": "next dev -p $REMOTE_PORT",
1879
+ "build": "next build",
1880
+ "start": "next start"
1881
+ },
1882
+ "dependencies": {
1883
+ "next": "latest",
1884
+ "react": "^19.0.0",
1885
+ "react-dom": "^19.0.0"
1886
+ }
1887
+ }
1888
+ `,
1889
+ "apps/remote/next.config.ts": `import type { NextConfig } from 'next';
1890
+
1891
+ const nextConfig: NextConfig = {
1892
+ reactStrictMode: true,
1893
+ webpack(config, { webpack }) {
1894
+ config.plugins.push(
1895
+ new webpack.container.ModuleFederationPlugin({
1896
+ name: 'myRemote',
1897
+ filename: 'static/chunks/remoteEntry.js',
1898
+ exposes: {
1899
+ // The shell loads this module at runtime via RemoteSlot.
1900
+ './Widget': './src/exposes/Widget',
1901
+ },
1902
+ shared: {
1903
+ react: { singleton: true, requiredVersion: false },
1904
+ 'react-dom': { singleton: true, requiredVersion: false },
1905
+ },
1906
+ })
1604
1907
  );
1605
- if (Object.keys(files).length === 0) return null;
1908
+ return config;
1909
+ },
1910
+ };
1606
1911
 
1607
- const type: FrontendLabType =
1608
- parsed.type === "nextjs"
1609
- ? "nextjs"
1610
- : parsed.type === "module-federation"
1611
- ? "module-federation"
1612
- : "react";
1912
+ export default nextConfig;
1913
+ `,
1914
+ "apps/remote/src/app/layout.tsx": `import type { Metadata } from 'next';
1915
+ import React from 'react';
1613
1916
 
1614
- return cloneFrontendLabWorkspace({
1615
- version: 1,
1616
- type,
1617
- label:
1618
- typeof parsed.label === "string" && parsed.label.trim()
1619
- ? parsed.label.trim()
1620
- : defaultForType(type).label,
1621
- activeFile:
1622
- typeof parsed.activeFile === "string"
1623
- ? parsed.activeFile
1624
- : defaultForType(type).activeFile,
1625
- files,
1626
- });
1627
- } catch {
1628
- return null;
1917
+ export const metadata: Metadata = { title: 'Remote — Next.js MF' };
1918
+
1919
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1920
+ return (
1921
+ <html lang="en">
1922
+ <body>{children}</body>
1923
+ </html>
1924
+ );
1925
+ }
1926
+ `,
1927
+ "apps/remote/src/app/page.tsx": `export default function Home() {
1928
+ return (
1929
+ <main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
1930
+ <h1>Remote App</h1>
1931
+ <p>
1932
+ This app exposes <code>myRemote/Widget</code> via Module Federation.
1933
+ The shell loads it at runtime — open the shell URL to see it in action.
1934
+ </p>
1935
+ </main>
1936
+ );
1937
+ }
1938
+ `,
1939
+ "apps/remote/src/exposes/Widget.tsx": `'use client';
1940
+
1941
+ // This component is exposed at ./Widget via ModuleFederationPlugin.
1942
+ // The shell dynamically loads it at runtime via RemoteSlot — zero build-time coupling.
1943
+ export default function Widget() {
1944
+ return (
1945
+ <section
1946
+ style={{
1947
+ padding: '1rem',
1948
+ border: '1px solid #e2e8f0',
1949
+ borderRadius: '8px',
1950
+ background: '#f8fafc',
1951
+ maxWidth: '400px',
1952
+ }}
1953
+ >
1954
+ <h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#0f172a' }}>
1955
+ Remote Widget
1956
+ </h2>
1957
+ <p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
1958
+ Loaded from{' '}
1959
+ <code style={{ background: '#e2e8f0', padding: '0 4px', borderRadius: '4px' }}>
1960
+ myRemote/Widget
1961
+ </code>{' '}
1962
+ at runtime. Both shell and remote are Next.js apps.
1963
+ </p>
1964
+ </section>
1965
+ );
1966
+ }
1967
+ `,
1968
+ };
1969
+
1970
+ export const NEXTJS_MF_PLUGIN_LAB: FrontendLabWorkspace = {
1971
+ version: 1,
1972
+ label: "Next.js MF — Plugin (Option A)",
1973
+ type: "module-federation",
1974
+ activeFile: "apps/shell/src/components/RemoteSlot.tsx",
1975
+ files: NEXTJS_MF_PLUGIN_FILES,
1976
+ };
1977
+
1978
+ // ─── Next.js MF Option B — plain runtime script loading (no plugin) ──────────
1979
+ const NEXTJS_MF_RUNTIME_FILES: Record<string, string> = {
1980
+ "README.md": `# Next.js Module Federation — Option B: plain runtime script loading
1981
+
1982
+ ## What is this
1983
+
1984
+ The shell is a plain Next.js app with NO federation webpack plugin.
1985
+ The remote is a standard webpack 5 app that produces a \`remoteEntry.js\` federation container.
1986
+ The shell loads that container at runtime using plain DOM script injection + webpack federation globals.
1987
+
1988
+ ## Why this approach
1989
+
1990
+ - No Next.js-specific federation plugin required on the shell
1991
+ - Full runtime control — manifest URL can come from an API at page-load time
1992
+ - Easier to reason about for client-side-only composition
1993
+
1994
+ ## Structure
1995
+
1996
+ - \`apps/shell/\` — plain Next.js app
1997
+ - \`apps/remote/\` — webpack 5 React app that exposes a widget
1998
+
1999
+ Ports are assigned automatically by the lab runner (HOST_PORT for shell, REMOTE_PORT for remote).
2000
+
2001
+ ## Things to try
2002
+
2003
+ 1. Change the entry URL to point to a CDN-hosted remoteEntry.js.
2004
+ 2. Add a second exposed module in apps/remote/webpack.config.js and load it in the shell.
2005
+ 3. Move the remote URL to an API route (\`/api/remote-config\`) to simulate a manifest service.
2006
+ `,
2007
+ "package.json": `{
2008
+ "name": "nextjs-runtime-mf-lab",
2009
+ "private": true,
2010
+ "workspaces": [
2011
+ "apps/shell",
2012
+ "apps/remote"
2013
+ ],
2014
+ "scripts": {
2015
+ "dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=nextjs-runtime-remote' 'npm run dev --workspace=nextjs-runtime-shell'"
2016
+ },
2017
+ "devDependencies": {
2018
+ "concurrently": "^9.2.1"
2019
+ },
2020
+ "overrides": {
2021
+ "undici": "^7"
2022
+ }
2023
+ }
2024
+ `,
2025
+ "apps/shell/package.json": `{
2026
+ "name": "nextjs-runtime-shell",
2027
+ "private": true,
2028
+ "scripts": {
2029
+ "dev": "next dev -p $HOST_PORT",
2030
+ "build": "next build",
2031
+ "start": "next start"
2032
+ },
2033
+ "dependencies": {
2034
+ "next": "latest",
2035
+ "react": "^19.0.0",
2036
+ "react-dom": "^19.0.0"
1629
2037
  }
1630
2038
  }
2039
+ `,
2040
+ "apps/shell/next.config.ts": `import type { NextConfig } from 'next';
2041
+ // No federation plugin — the shell loads remotes entirely at runtime.
1631
2042
 
1632
- /** Returns the canonical entry file for "Run" → preview. */
1633
- export function getEntryFile(workspace: FrontendLabWorkspace): string {
1634
- if (workspace.type === "nextjs") {
1635
- return workspace.files["app/page.tsx"]
1636
- ? "app/page.tsx"
1637
- : Object.keys(workspace.files)[0];
2043
+ const nextConfig: NextConfig = {
2044
+ reactStrictMode: true,
2045
+ };
2046
+
2047
+ export default nextConfig;
2048
+ `,
2049
+ "apps/shell/src/lib/loadRemoteModule.ts": `// Utility: load a webpack 5 Module Federation container at runtime.
2050
+ // Works in any browser environment — no webpack plugin needed on the shell side.
2051
+
2052
+ declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
2053
+ declare const __webpack_share_scopes__: { default: unknown };
2054
+
2055
+ type FederatedContainer = {
2056
+ init(shareScope: unknown): Promise<void>;
2057
+ get(module: string): Promise<() => unknown>;
2058
+ };
2059
+
2060
+ // Avoid injecting the same script twice.
2061
+ const loaded = new Set<string>();
2062
+
2063
+ async function injectScript(url: string, scope: string): Promise<void> {
2064
+ if (loaded.has(scope)) return;
2065
+ loaded.add(scope);
2066
+
2067
+ return new Promise((resolve, reject) => {
2068
+ const script = document.createElement('script');
2069
+ script.src = url;
2070
+ script.async = true;
2071
+ script.crossOrigin = 'anonymous';
2072
+ script.onload = () => resolve();
2073
+ script.onerror = () => {
2074
+ loaded.delete(scope); // allow retry
2075
+ reject(new Error(\`Failed to load remote entry: \${url}\`));
2076
+ };
2077
+ document.head.appendChild(script);
2078
+ });
2079
+ }
2080
+
2081
+ export async function loadRemoteModule<T = { default: unknown }>(
2082
+ scope: string,
2083
+ module: string,
2084
+ remoteUrl: string
2085
+ ): Promise<T> {
2086
+ await injectScript(remoteUrl, scope);
2087
+
2088
+ // Init the shared scope so singleton packages are negotiated.
2089
+ await __webpack_init_sharing__('default');
2090
+
2091
+ const container = (window as Record<string, unknown>)[scope] as
2092
+ | FederatedContainer
2093
+ | undefined;
2094
+
2095
+ if (!container) {
2096
+ throw new Error(\`Remote container "\${scope}" not found on window after script load.\`);
1638
2097
  }
1639
- if (workspace.type === "module-federation") {
1640
- return workspace.files["apps/host/src/App.jsx"]
1641
- ? "apps/host/src/App.jsx"
1642
- : Object.keys(workspace.files)[0];
2098
+
2099
+ await container.init(__webpack_share_scopes__.default);
2100
+
2101
+ const factory = await container.get(module);
2102
+ return factory() as T;
2103
+ }
2104
+ `,
2105
+ "apps/shell/src/app/page.tsx": `'use client';
2106
+
2107
+ import React from 'react';
2108
+ import { loadRemoteModule } from '@/lib/loadRemoteModule';
2109
+
2110
+ // The remote URL would normally come from an API call / manifest service.
2111
+ const REMOTE_URL =
2112
+ process.env.NEXT_PUBLIC_REMOTE_URL ||
2113
+ 'http://localhost:3001/remoteEntry.js';
2114
+
2115
+ // React.lazy wraps our runtime loader — Suspense handles the loading state.
2116
+ const RemoteWidget = React.lazy(() =>
2117
+ loadRemoteModule<{ default: React.ComponentType }>(
2118
+ 'myRemote',
2119
+ './Widget',
2120
+ REMOTE_URL
2121
+ ).then((mod) => ({ default: mod.default }))
2122
+ );
2123
+
2124
+ export default function Home() {
2125
+ return (
2126
+ <main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
2127
+ <h1>Next.js Shell (runtime loader)</h1>
2128
+ <p style={{ color: '#64748b' }}>
2129
+ No federation plugin on the shell — the widget is loaded via plain
2130
+ script injection and webpack federation globals.
2131
+ </p>
2132
+
2133
+ <React.Suspense fallback={<p>Loading remote widget...</p>}>
2134
+ <RemoteWidget />
2135
+ </React.Suspense>
2136
+ </main>
2137
+ );
2138
+ }
2139
+ `,
2140
+ "apps/remote/package.json": `{
2141
+ "name": "nextjs-runtime-remote",
2142
+ "private": true,
2143
+ "scripts": {
2144
+ "dev": "webpack serve --config webpack.config.js",
2145
+ "build": "webpack --config webpack.config.js"
2146
+ },
2147
+ "dependencies": {
2148
+ "react": "18.2.0",
2149
+ "react-dom": "18.2.0"
2150
+ },
2151
+ "devDependencies": {
2152
+ "@types/react": "^18.0.0",
2153
+ "@types/react-dom": "^18.0.0",
2154
+ "esbuild": "^0.28.0",
2155
+ "esbuild-loader": "^4.4.3",
2156
+ "html-webpack-plugin": "^5.6.7",
2157
+ "typescript": "^5.6.0",
2158
+ "webpack": "^5.106.2",
2159
+ "webpack-cli": "^7.0.2",
2160
+ "webpack-dev-server": "^5.2.3"
2161
+ }
2162
+ }
2163
+ `,
2164
+ "apps/remote/tsconfig.json": `{
2165
+ "compilerOptions": {
2166
+ "target": "ES2020",
2167
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
2168
+ "module": "ESNext",
2169
+ "moduleResolution": "bundler",
2170
+ "jsx": "react-jsx",
2171
+ "strict": true,
2172
+ "esModuleInterop": true,
2173
+ "allowSyntheticDefaultImports": true,
2174
+ "skipLibCheck": true,
2175
+ "noEmit": true
2176
+ },
2177
+ "include": ["src"]
2178
+ }
2179
+ `,
2180
+ "apps/remote/webpack.config.js": `const path = require('path');
2181
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2182
+ const { ModuleFederationPlugin } = require('webpack').container;
2183
+
2184
+ const REMOTE_PORT = Number(process.env.REMOTE_PORT || 3001);
2185
+
2186
+ module.exports = {
2187
+ mode: 'development',
2188
+ entry: './src/index.ts',
2189
+ output: {
2190
+ path: path.resolve(__dirname, 'dist'),
2191
+ publicPath: \`http://localhost:\${REMOTE_PORT}/\`,
2192
+ clean: true,
2193
+ },
2194
+ resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
2195
+ module: {
2196
+ rules: [
2197
+ {
2198
+ test: /\\.(js|jsx|ts|tsx)$/,
2199
+ exclude: /node_modules/,
2200
+ use: {
2201
+ loader: 'esbuild-loader',
2202
+ options: { loader: 'tsx', jsx: 'automatic', target: 'es2020' },
2203
+ },
2204
+ },
2205
+ ],
2206
+ },
2207
+ plugins: [
2208
+ new ModuleFederationPlugin({
2209
+ name: 'myRemote',
2210
+ filename: 'remoteEntry.js',
2211
+ exposes: {
2212
+ './Widget': './src/Widget.tsx',
2213
+ },
2214
+ shared: {
2215
+ react: { singleton: true, requiredVersion: false },
2216
+ 'react-dom': { singleton: true, requiredVersion: false },
2217
+ },
2218
+ }),
2219
+ new HtmlWebpackPlugin({ template: './public/index.html' }),
2220
+ ],
2221
+ devServer: {
2222
+ port: REMOTE_PORT,
2223
+ headers: { 'Access-Control-Allow-Origin': '*' },
2224
+ },
2225
+ };
2226
+ `,
2227
+ "apps/remote/public/index.html": `<!doctype html>
2228
+ <html lang="en">
2229
+ <head><meta charset="UTF-8" /><title>Remote (standalone)</title></head>
2230
+ <body><div id="root"></div></body>
2231
+ </html>
2232
+ `,
2233
+ "apps/remote/src/index.ts": `// Async boundary required for Module Federation
2234
+ import('./bootstrap');
2235
+ `,
2236
+ "apps/remote/src/bootstrap.tsx": `import React from 'react';
2237
+ import { createRoot } from 'react-dom/client';
2238
+ import Widget from './Widget';
2239
+
2240
+ createRoot(document.getElementById('root')!).render(<Widget />);
2241
+ `,
2242
+ "apps/remote/src/Widget.tsx": `import React from 'react';
2243
+
2244
+ // This component is exposed at ./Widget via ModuleFederationPlugin.
2245
+ // The shell loads it with loadRemoteModule('myRemote', './Widget', url).
2246
+ export default function Widget(): React.ReactElement {
2247
+ return (
2248
+ <section
2249
+ style={{
2250
+ padding: '1rem',
2251
+ border: '1px solid #e2e8f0',
2252
+ borderRadius: '8px',
2253
+ background: '#f8fafc',
2254
+ maxWidth: '400px',
2255
+ }}
2256
+ >
2257
+ <h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#0f172a' }}>
2258
+ Remote Widget
2259
+ </h2>
2260
+ <p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
2261
+ Built by webpack 5. Loaded by the Next.js shell at runtime via plain
2262
+ script injection — no federation plugin on the shell required.
2263
+ </p>
2264
+ </section>
2265
+ );
2266
+ }
2267
+ `,
2268
+ };
2269
+
2270
+ export const NEXTJS_MF_RUNTIME_LAB: FrontendLabWorkspace = {
2271
+ version: 1,
2272
+ label: "Next.js MF — Runtime Loader (Option B)",
2273
+ type: "module-federation",
2274
+ activeFile: "apps/shell/src/lib/loadRemoteModule.ts",
2275
+ files: NEXTJS_MF_RUNTIME_FILES,
2276
+ };
2277
+
2278
+ // ─── Next.js Multi-Zones ─────────────────────────────────────────────────────
2279
+ const NEXTJS_MULTI_ZONES_FILES: Record<string, string> = {
2280
+ "README.md": `# Next.js Multi-Zones
2281
+
2282
+ ## What this shows
2283
+ Two separate Next.js apps served under one domain via URL-path splitting.
2284
+ The shell owns \`/\` and proxies \`/store\` to Zone B using Next.js rewrites.
2285
+ Each zone deploys independently — the shell never rebuilds when Zone B changes.
2286
+
2287
+ ## Key files
2288
+ - \`apps/shell/next.config.js\` — rewrites \`/store/*\` → Zone B
2289
+ - \`apps/zone-b/next.config.js\` — \`basePath: '/store'\` aligns Zone B's routes with the rewrite
2290
+
2291
+ ## Things to try
2292
+ 1. Click the Store link — URL stays on the shell's origin but Zone B renders.
2293
+ 2. Deploy Zone B to a CDN; update only the rewrite destination.
2294
+ 3. Add a \`/checkout\` zone as a third independent Next.js app.
2295
+ `,
2296
+ "package.json": `{
2297
+ "name": "nextjs-multi-zones-lab",
2298
+ "private": true,
2299
+ "workspaces": ["apps/shell", "apps/zone-b"],
2300
+ "scripts": {
2301
+ "dev": "concurrently -k -n zone-b,shell -c magenta,cyan 'npm run dev --workspace=nextjs-zone-b' 'npm run dev --workspace=nextjs-shell'"
2302
+ },
2303
+ "devDependencies": { "concurrently": "^9.2.1" },
2304
+ "overrides": { "undici": "^7" }
2305
+ }
2306
+ `,
2307
+ "apps/shell/package.json": `{
2308
+ "name": "nextjs-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
+ },
2316
+ "devDependencies": {
2317
+ "typescript": "latest",
2318
+ "@types/react": "latest",
2319
+ "@types/react-dom": "latest",
2320
+ "@types/node": "latest"
2321
+ }
2322
+ }
2323
+ `,
2324
+ "apps/shell/next.config.ts": `import type { NextConfig } from 'next';
2325
+ // Shell acts as the routing layer.
2326
+ // /store and /store/* are proxied to Zone B running at REMOTE_PORT.
2327
+ const nextConfig: NextConfig = {
2328
+ async rewrites() {
2329
+ const zoneB = 'http://localhost:' + process.env.REMOTE_PORT;
2330
+ return [
2331
+ { source: '/store', destination: zoneB + '/store' },
2332
+ { source: '/store/:path*', destination: zoneB + '/store/:path*' },
2333
+ ];
2334
+ },
2335
+ };
2336
+ export default nextConfig;
2337
+ `,
2338
+ "apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
2339
+ import React from 'react';
2340
+
2341
+ export const metadata: Metadata = { title: 'Shell \u2014 Multi-Zones' };
2342
+
2343
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
2344
+ return (
2345
+ <html lang="en">
2346
+ <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
2347
+ </html>
2348
+ );
2349
+ }
2350
+ `,
2351
+ "apps/shell/src/app/page.tsx": `// Cross-zone links must be plain <a> tags — Next.js <Link> routes within
2352
+ // the same app via client-side JS, so the server-side rewrite never fires.
2353
+ export default function Home() {
2354
+ return (
2355
+ <main style={{ padding: '2rem' }}>
2356
+ <h1>Shell App \u2014 Zone A</h1>
2357
+ <p style={{ color: '#64748b' }}>
2358
+ This zone owns <code>/</code>. Clicking Store proxies the request to a
2359
+ separate Next.js app (Zone B) \u2014 transparent to the browser.
2360
+ </p>
2361
+ <nav style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
2362
+ <a href="/" style={{ color: '#3b82f6' }}>Home (Zone A)</a>
2363
+ <a href="/store" style={{ color: '#3b82f6' }}>Store (Zone B \u2014 proxied)</a>
2364
+ </nav>
2365
+ </main>
2366
+ );
2367
+ }
2368
+ `,
2369
+ "apps/zone-b/package.json": `{
2370
+ "name": "nextjs-zone-b",
2371
+ "private": true,
2372
+ "scripts": { "dev": "next dev -p $REMOTE_PORT" },
2373
+ "dependencies": {
2374
+ "next": "latest",
2375
+ "react": "^19.0.0",
2376
+ "react-dom": "^19.0.0"
2377
+ },
2378
+ "devDependencies": {
2379
+ "typescript": "latest",
2380
+ "@types/react": "latest",
2381
+ "@types/react-dom": "latest",
2382
+ "@types/node": "latest"
2383
+ }
2384
+ }
2385
+ `,
2386
+ "apps/zone-b/next.config.ts": `import type { NextConfig } from 'next';
2387
+ // basePath makes Zone B serve its pages under /store,
2388
+ // matching the path prefix the shell forwards here.
2389
+ const nextConfig: NextConfig = {
2390
+ basePath: '/store',
2391
+ };
2392
+ export default nextConfig;
2393
+ `,
2394
+ "apps/zone-b/src/app/layout.tsx": `import type { Metadata } from 'next';
2395
+ import React from 'react';
2396
+
2397
+ export const metadata: Metadata = { title: 'Store \u2014 Zone B' };
2398
+
2399
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
2400
+ return (
2401
+ <html lang="en">
2402
+ <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
2403
+ </html>
2404
+ );
2405
+ }
2406
+ `,
2407
+ "apps/zone-b/src/app/page.tsx": `// Cross-zone back-link must be a plain <a> — client-side Next.js routing
2408
+ // stays within this app and would 404. A full navigation triggers the shell's rewrite.
2409
+ export default function StorePage() {
2410
+ return (
2411
+ <main style={{ padding: '2rem', background: '#f0fdf4', minHeight: '100vh' }}>
2412
+ <h1>Store \u2014 Zone B</h1>
2413
+ <p style={{ color: '#64748b' }}>
2414
+ Rendered by a <strong>separate</strong> Next.js app. The shell proxied
2415
+ your request via <code>next.config.js</code> rewrites. No code is shared
2416
+ \u2014 both zones are fully independent.
2417
+ </p>
2418
+ <a href="/" style={{ color: '#3b82f6' }}>\u2190 Back to Shell (Zone A)</a>
2419
+ </main>
2420
+ );
2421
+ }
2422
+ `,
2423
+ };
2424
+
2425
+ export const NEXTJS_MULTI_ZONES_LAB: FrontendLabWorkspace = {
2426
+ version: 1,
2427
+ label: "Next.js \u2014 Multi-Zones",
2428
+ type: "module-federation",
2429
+ activeFile: "apps/shell/next.config.ts",
2430
+ files: NEXTJS_MULTI_ZONES_FILES,
2431
+ };
2432
+
2433
+ // ─── Next.js MF Runtime API ──────────────────────────────────────────────────
2434
+ const NEXTJS_MF_RUNTIME_API_FILES: Record<string, string> = {
2435
+ "README.md": `# Next.js \u2014 Module Federation Runtime API
2436
+
2437
+ ## What this shows
2438
+ Consuming a federated remote from Next.js App Router without touching \`next.config.js\`.
2439
+ All federation work is isolated inside a \`'use client'\` component using
2440
+ \`@module-federation/enhanced/runtime\`'s \`init()\` + \`loadRemote()\` API.
2441
+
2442
+ ## Key files
2443
+ - \`apps/shell/src/components/FederatedWidget.tsx\` \u2014 the only federation code in the shell
2444
+ - \`apps/mf-remote/webpack.config.js\` \u2014 webpack remote exposing \`./Widget\`
2445
+
2446
+ ## Trade-off
2447
+ The component is strictly client-side (SPA). There is no SSR for the remote content.
2448
+ This is the recommended safe pattern for consuming federation in the App Router.
2449
+ `,
2450
+ "package.json": `{
2451
+ "name": "nextjs-mf-runtime-api-lab",
2452
+ "private": true,
2453
+ "workspaces": ["apps/shell", "apps/mf-remote"],
2454
+ "scripts": {
2455
+ "dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=mf-runtime-remote' 'npm run dev --workspace=mf-runtime-shell'"
2456
+ },
2457
+ "devDependencies": { "concurrently": "^9.2.1" },
2458
+ "overrides": { "undici": "^7" }
2459
+ }
2460
+ `,
2461
+ "apps/shell/package.json": `{
2462
+ "name": "mf-runtime-shell",
2463
+ "private": true,
2464
+ "scripts": { "dev": "next dev -p $HOST_PORT" },
2465
+ "dependencies": {
2466
+ "next": "latest",
2467
+ "react": "^19.0.0",
2468
+ "react-dom": "^19.0.0",
2469
+ "@module-federation/enhanced": "latest"
2470
+ },
2471
+ "devDependencies": {
2472
+ "typescript": "latest",
2473
+ "@types/react": "latest",
2474
+ "@types/react-dom": "latest",
2475
+ "@types/node": "latest"
2476
+ }
2477
+ }
2478
+ `,
2479
+ "apps/shell/next.config.ts": `import type { NextConfig } from 'next';
2480
+ // No webpack hooks needed — federation is handled entirely at runtime
2481
+ // inside FederatedWidget.tsx via @module-federation/enhanced/runtime.
2482
+ const nextConfig: NextConfig = {};
2483
+ export default nextConfig;
2484
+ `,
2485
+ "apps/shell/tsconfig.json": `{
2486
+ "compilerOptions": {
2487
+ "target": "ES2017",
2488
+ "lib": ["dom", "dom.iterable", "esnext"],
2489
+ "allowJs": true,
2490
+ "skipLibCheck": true,
2491
+ "strict": true,
2492
+ "noEmit": true,
2493
+ "esModuleInterop": true,
2494
+ "module": "esnext",
2495
+ "moduleResolution": "bundler",
2496
+ "resolveJsonModule": true,
2497
+ "isolatedModules": true,
2498
+ "jsx": "preserve",
2499
+ "incremental": true,
2500
+ "plugins": [{ "name": "next" }],
2501
+ "paths": {
2502
+ "@/*": ["./src/*"]
2503
+ }
2504
+ },
2505
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
2506
+ "exclude": ["node_modules"]
2507
+ }
2508
+ `,
2509
+ "apps/shell/src/app/layout.tsx": `import type { Metadata } from 'next';
2510
+ import React from 'react';
2511
+
2512
+ export const metadata: Metadata = { title: 'Shell \u2014 MF Runtime API' };
2513
+
2514
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
2515
+ return (
2516
+ <html lang="en">
2517
+ <body style={{ margin: 0, fontFamily: 'system-ui, sans-serif' }}>{children}</body>
2518
+ </html>
2519
+ );
2520
+ }
2521
+ `,
2522
+ "apps/shell/src/app/page.tsx": `import FederatedWidget from '@/components/FederatedWidget';
2523
+
2524
+ export default function Home() {
2525
+ return (
2526
+ <main style={{ padding: '2rem' }}>
2527
+ <h1>Next.js Shell \u2014 MF Runtime API</h1>
2528
+ <p style={{ color: '#64748b' }}>
2529
+ The widget below is loaded at runtime using{' '}
2530
+ <code>@module-federation/enhanced/runtime</code>. No webpack config changes needed.
2531
+ </p>
2532
+ <FederatedWidget />
2533
+ </main>
2534
+ );
2535
+ }
2536
+ `,
2537
+ "apps/shell/src/components/FederatedWidget.tsx": `'use client';
2538
+
2539
+ import { useEffect, useState } from 'react';
2540
+ import type { ComponentType } from 'react';
2541
+
2542
+ // All federation work is isolated here in a client component.
2543
+ // init() + loadRemote() run only in the browser, never on the server.
2544
+ export default function FederatedWidget() {
2545
+ const [Component, setComponent] = useState<ComponentType | null>(null);
2546
+ const [error, setError] = useState<string | null>(null);
2547
+
2548
+ useEffect(() => {
2549
+ async function load() {
2550
+ const { init, loadRemote } = await import('@module-federation/enhanced/runtime');
2551
+
2552
+ // NEXT_PUBLIC_REMOTE_URL is injected by the lab runner.
2553
+ const entry =
2554
+ process.env.NEXT_PUBLIC_REMOTE_URL || 'http://localhost:3001/remoteEntry.js';
2555
+
2556
+ // shared tells the runtime which packages to negotiate as singletons.
2557
+ // Without this, both shell and remote load their own React copy → version mismatch crash.
2558
+ init({
2559
+ name: 'shell',
2560
+ remotes: [{ name: 'mfRemote', entry }],
2561
+ shared: {
2562
+ react: { singleton: true, version: '19.0.0', lib: () => require('react') },
2563
+ 'react-dom': { singleton: true, version: '19.0.0', lib: () => require('react-dom') },
2564
+ },
2565
+ });
2566
+
2567
+ // loadRemote resolves ./Widget from the remote container at runtime.
2568
+ const mod = await loadRemote<{ default: ComponentType }>('mfRemote/Widget');
2569
+ if (mod) setComponent(() => mod.default);
2570
+ }
2571
+ load().catch((e) => setError(String(e)));
2572
+ }, []);
2573
+
2574
+ if (error) return <p style={{ color: '#ef4444' }}>Remote failed: {error}</p>;
2575
+ if (!Component) return <p>Loading remote widget\u2026</p>;
2576
+ return <Component />;
2577
+ }
2578
+ `,
2579
+ "apps/mf-remote/package.json": `{
2580
+ "name": "mf-runtime-remote",
2581
+ "private": true,
2582
+ "scripts": { "dev": "webpack serve" },
2583
+ "dependencies": {
2584
+ "react": "^19.0.0",
2585
+ "react-dom": "^19.0.0"
2586
+ },
2587
+ "devDependencies": {
2588
+ "@types/react": "^19.0.0",
2589
+ "@types/react-dom": "^19.0.0",
2590
+ "esbuild": "^0.28.0",
2591
+ "esbuild-loader": "^4.4.3",
2592
+ "html-webpack-plugin": "^5",
2593
+ "typescript": "^5.6.0",
2594
+ "webpack": "^5",
2595
+ "webpack-cli": "^5",
2596
+ "webpack-dev-server": "^5"
2597
+ }
2598
+ }
2599
+ `,
2600
+ "apps/mf-remote/tsconfig.json": `{
2601
+ "compilerOptions": {
2602
+ "target": "ES2020",
2603
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
2604
+ "module": "ESNext",
2605
+ "moduleResolution": "bundler",
2606
+ "jsx": "react-jsx",
2607
+ "strict": true,
2608
+ "esModuleInterop": true,
2609
+ "allowSyntheticDefaultImports": true,
2610
+ "skipLibCheck": true,
2611
+ "noEmit": true
2612
+ },
2613
+ "include": ["src"]
2614
+ }
2615
+ `,
2616
+ "apps/mf-remote/webpack.config.js": `const path = require('path');
2617
+ const webpack = require('webpack');
2618
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2619
+
2620
+ module.exports = {
2621
+ entry: './src/index.ts',
2622
+ mode: 'development',
2623
+ output: {
2624
+ path: path.resolve(__dirname, 'dist'),
2625
+ publicPath: 'auto',
2626
+ },
2627
+ devServer: {
2628
+ port: parseInt(process.env.REMOTE_PORT, 10) || 3001,
2629
+ hot: true,
2630
+ headers: { 'Access-Control-Allow-Origin': '*' },
2631
+ },
2632
+ module: {
2633
+ rules: [
2634
+ {
2635
+ test: /\\.(js|jsx|ts|tsx)$/,
2636
+ exclude: /node_modules/,
2637
+ use: {
2638
+ loader: 'esbuild-loader',
2639
+ options: { loader: 'tsx', jsx: 'automatic', target: 'es2020' },
2640
+ },
2641
+ },
2642
+ ],
2643
+ },
2644
+ resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
2645
+ plugins: [
2646
+ new webpack.container.ModuleFederationPlugin({
2647
+ name: 'mfRemote',
2648
+ filename: 'remoteEntry.js',
2649
+ exposes: { './Widget': './src/exposes/Widget.tsx' },
2650
+ shared: {
2651
+ react: { singleton: true, eager: true, requiredVersion: '^19.0.0' },
2652
+ 'react-dom': { singleton: true, eager: true, requiredVersion: '^19.0.0' },
2653
+ },
2654
+ }),
2655
+ new HtmlWebpackPlugin({ template: './public/index.html' }),
2656
+ ],
2657
+ };
2658
+ `,
2659
+ "apps/mf-remote/src/index.ts": `// Remote bootstrap page. MF consumers load ./Widget via remoteEntry.js.
2660
+ document.getElementById('root')!.innerHTML =
2661
+ '<div style="padding:2rem;font-family:system-ui">' +
2662
+ '<h2>mfRemote \u2014 running</h2>' +
2663
+ '<p>Exposes <code>mfRemote/Widget</code> via Module Federation.</p>' +
2664
+ '</div>';
2665
+ `,
2666
+ "apps/mf-remote/src/exposes/Widget.tsx": `import React from 'react';
2667
+
2668
+ // Exposed as mfRemote/Widget via ModuleFederationPlugin.
2669
+ // The shell loads this at runtime via @module-federation/enhanced/runtime.
2670
+ export default function Widget(): React.ReactElement {
2671
+ return (
2672
+ <section style={{
2673
+ padding: '1rem', border: '1px solid #e2e8f0',
2674
+ borderRadius: '8px', background: '#f8fafc', maxWidth: '400px',
2675
+ }}>
2676
+ <h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem' }}>Remote Widget</h2>
2677
+ <p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
2678
+ Loaded via <code>@module-federation/enhanced/runtime</code> \u2014 client-side only.
2679
+ </p>
2680
+ </section>
2681
+ );
2682
+ }
2683
+ `,
2684
+ "apps/mf-remote/public/index.html": `<!DOCTYPE html>
2685
+ <html lang="en">
2686
+ <head><meta charset="UTF-8" /><title>MF Remote</title></head>
2687
+ <body><div id="root"></div></body>
2688
+ </html>
2689
+ `,
2690
+ };
2691
+
2692
+ export const NEXTJS_MF_RUNTIME_API_LAB: FrontendLabWorkspace = {
2693
+ version: 1,
2694
+ label: "Next.js \u2014 MF Runtime API",
2695
+ type: "module-federation",
2696
+ activeFile: "apps/shell/src/components/FederatedWidget.tsx",
2697
+ files: NEXTJS_MF_RUNTIME_API_FILES,
2698
+ };
2699
+
2700
+ // ─── Rspack Shell + Webpack Remote ───────────────────────────────────────────
2701
+ const RSPACK_SHELL_FILES: Record<string, string> = {
2702
+ "README.md": `# Rspack Shell \u2014 Native Module Federation 2.0
2703
+
2704
+ ## What this shows
2705
+ An Rspack-bundled React shell consuming a webpack remote using native Module
2706
+ Federation support. Rspack mirrors webpack's API and ships MF support out of the
2707
+ box \u2014 no extra plugins or workarounds needed.
2708
+
2709
+ ## Key files
2710
+ - \`apps/rspack-shell/rspack.config.js\` \u2014 shell config with MF plugin + built-in SWC loader
2711
+ - \`apps/webpack-remote/webpack.config.js\` \u2014 remote exposing \`./Widget\`
2712
+
2713
+ ## Why Rspack for the shell
2714
+ If Module Federation is a hard requirement, the community recommends running the
2715
+ host/orchestrator on Rspack (or Modern.js) rather than Next.js. Next.js remotes
2716
+ still work \u2014 here a plain webpack remote is used for simplicity.
2717
+ `,
2718
+ "package.json": `{
2719
+ "name": "rspack-shell-mf-lab",
2720
+ "private": true,
2721
+ "workspaces": ["apps/rspack-shell", "apps/webpack-remote"],
2722
+ "scripts": {
2723
+ "dev": "concurrently -k -n remote,shell -c magenta,cyan 'npm run dev --workspace=rspack-mf-remote' 'npm run dev --workspace=rspack-mf-shell'"
2724
+ },
2725
+ "devDependencies": { "concurrently": "^9.2.1" }
2726
+ }
2727
+ `,
2728
+ "apps/rspack-shell/package.json": `{
2729
+ "name": "rspack-mf-shell",
2730
+ "private": true,
2731
+ "scripts": { "dev": "rspack serve" },
2732
+ "dependencies": {
2733
+ "react": "^18.0.0",
2734
+ "react-dom": "^18.0.0"
2735
+ },
2736
+ "devDependencies": {
2737
+ "@rspack/core": "latest",
2738
+ "@rspack/cli": "latest",
2739
+ "@types/react": "^18.0.0",
2740
+ "@types/react-dom": "^18.0.0",
2741
+ "typescript": "^5.6.0"
2742
+ }
2743
+ }
2744
+ `,
2745
+ "apps/rspack-shell/tsconfig.json": `{
2746
+ "compilerOptions": {
2747
+ "target": "ES2020",
2748
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
2749
+ "module": "ESNext",
2750
+ "moduleResolution": "bundler",
2751
+ "jsx": "react-jsx",
2752
+ "strict": true,
2753
+ "esModuleInterop": true,
2754
+ "allowSyntheticDefaultImports": true,
2755
+ "skipLibCheck": true,
2756
+ "noEmit": true
2757
+ },
2758
+ "include": ["src"]
2759
+ }
2760
+ `,
2761
+ "apps/rspack-shell/rspack.config.js": `const rspack = require('@rspack/core');
2762
+
2763
+ module.exports = {
2764
+ entry: './src/index.tsx',
2765
+ mode: 'development',
2766
+ output: { publicPath: 'auto' },
2767
+ devServer: {
2768
+ // PORT is read from env — injected by the lab runner.
2769
+ port: parseInt(process.env.HOST_PORT, 10) || 3000,
2770
+ hot: true,
2771
+ },
2772
+ resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
2773
+ module: {
2774
+ rules: [
2775
+ {
2776
+ test: /\\.(js|jsx|ts|tsx)$/,
2777
+ // Rspack ships a built-in SWC loader — no Babel or extra packages needed.
2778
+ // Use 'typescript' syntax to handle .ts/.tsx files.
2779
+ loader: 'builtin:swc-loader',
2780
+ options: {
2781
+ jsc: {
2782
+ parser: { syntax: 'typescript', tsx: true },
2783
+ transform: { react: { runtime: 'automatic' } },
2784
+ },
2785
+ },
2786
+ type: 'javascript/auto',
2787
+ },
2788
+ ],
2789
+ },
2790
+ plugins: [
2791
+ // Rspack has first-class Module Federation support via rspack.container.
2792
+ new rspack.container.ModuleFederationPlugin({
2793
+ name: 'rspackShell',
2794
+ remotes: {
2795
+ // mfRemote is the webpack remote at REMOTE_PORT.
2796
+ mfRemote:
2797
+ 'mfRemote@http://localhost:' +
2798
+ (process.env.REMOTE_PORT || '3001') +
2799
+ '/remoteEntry.js',
2800
+ },
2801
+ shared: {
2802
+ react: { singleton: true },
2803
+ 'react-dom': { singleton: true },
2804
+ },
2805
+ }),
2806
+ new rspack.HtmlRspackPlugin({ template: './index.html' }),
2807
+ ],
2808
+ };
2809
+ `,
2810
+ "apps/rspack-shell/index.html": `<!DOCTYPE html>
2811
+ <html lang="en">
2812
+ <head>
2813
+ <meta charset="UTF-8" />
2814
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2815
+ <title>Rspack Shell</title>
2816
+ </head>
2817
+ <body>
2818
+ <div id="root"></div>
2819
+ </body>
2820
+ </html>
2821
+ `,
2822
+ "apps/rspack-shell/src/mf.d.ts": `// Type declarations for Module Federation remotes.
2823
+ // Rspack resolves these at runtime — TypeScript needs a fallback declaration.
2824
+ declare module 'mfRemote/Widget' {
2825
+ import type { ComponentType } from 'react';
2826
+ const Widget: ComponentType;
2827
+ export default Widget;
2828
+ }
2829
+ `,
2830
+ "apps/rspack-shell/src/index.tsx": `import React from 'react';
2831
+ import { createRoot } from 'react-dom/client';
2832
+ import App from './App';
2833
+
2834
+ // Rspack's SWC loader handles TSX via builtin:swc-loader (no Babel needed).
2835
+ createRoot(document.getElementById('root')!).render(<App />);
2836
+ `,
2837
+ "apps/rspack-shell/src/App.tsx": `import React, { Suspense, lazy } from 'react';
2838
+ import type { ComponentType } from 'react';
2839
+
2840
+ // mfRemote is declared in rspack.config.js under remotes.
2841
+ // Rspack resolves this lazy import to the federated container at runtime.
2842
+ // The type declaration is in src/mf.d.ts.
2843
+ const RemoteWidget = lazy<ComponentType>(() => import('mfRemote/Widget'));
2844
+
2845
+ export default function App(): React.ReactElement {
2846
+ return (
2847
+ <main style={{ padding: '2rem', fontFamily: 'system-ui, sans-serif' }}>
2848
+ <h1>Rspack Shell</h1>
2849
+ <p style={{ color: '#64748b' }}>
2850
+ Bundled with Rspack \u2014 native Module Federation, no plugins or workarounds.
2851
+ The widget below is loaded from a separate webpack remote.
2852
+ </p>
2853
+ <Suspense fallback={<p>Loading remote widget\u2026</p>}>
2854
+ <RemoteWidget />
2855
+ </Suspense>
2856
+ </main>
2857
+ );
2858
+ }
2859
+ `,
2860
+ "apps/webpack-remote/package.json": `{
2861
+ "name": "rspack-mf-remote",
2862
+ "private": true,
2863
+ "scripts": { "dev": "webpack serve" },
2864
+ "dependencies": {
2865
+ "react": "^18.0.0",
2866
+ "react-dom": "^18.0.0"
2867
+ },
2868
+ "devDependencies": {
2869
+ "@types/react": "^18.0.0",
2870
+ "@types/react-dom": "^18.0.0",
2871
+ "esbuild": "^0.28.0",
2872
+ "esbuild-loader": "^4.4.3",
2873
+ "html-webpack-plugin": "^5",
2874
+ "typescript": "^5.6.0",
2875
+ "webpack": "^5",
2876
+ "webpack-cli": "^5",
2877
+ "webpack-dev-server": "^5"
2878
+ }
2879
+ }
2880
+ `,
2881
+ "apps/webpack-remote/tsconfig.json": `{
2882
+ "compilerOptions": {
2883
+ "target": "ES2020",
2884
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
2885
+ "module": "ESNext",
2886
+ "moduleResolution": "bundler",
2887
+ "jsx": "react-jsx",
2888
+ "strict": true,
2889
+ "esModuleInterop": true,
2890
+ "allowSyntheticDefaultImports": true,
2891
+ "skipLibCheck": true,
2892
+ "noEmit": true
2893
+ },
2894
+ "include": ["src"]
2895
+ }
2896
+ `,
2897
+ "apps/webpack-remote/webpack.config.js": `const path = require('path');
2898
+ const webpack = require('webpack');
2899
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2900
+
2901
+ module.exports = {
2902
+ entry: './src/index.js',
2903
+ mode: 'development',
2904
+ output: {
2905
+ path: path.resolve(__dirname, 'dist'),
2906
+ publicPath: 'auto',
2907
+ },
2908
+ devServer: {
2909
+ port: parseInt(process.env.REMOTE_PORT, 10) || 3001,
2910
+ hot: true,
2911
+ headers: { 'Access-Control-Allow-Origin': '*' },
2912
+ },
2913
+ module: {
2914
+ rules: [
2915
+ {
2916
+ test: /\\.(js|jsx|ts|tsx)$/,
2917
+ exclude: /node_modules/,
2918
+ use: {
2919
+ loader: 'esbuild-loader',
2920
+ options: { loader: 'tsx', jsx: 'automatic', target: 'es2020' },
2921
+ },
2922
+ },
2923
+ ],
2924
+ },
2925
+ resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
2926
+ plugins: [
2927
+ new webpack.container.ModuleFederationPlugin({
2928
+ name: 'mfRemote',
2929
+ filename: 'remoteEntry.js',
2930
+ exposes: { './Widget': './src/exposes/Widget.tsx' },
2931
+ shared: {
2932
+ react: { singleton: true, eager: true, requiredVersion: '^18.0.0' },
2933
+ 'react-dom': { singleton: true, eager: true, requiredVersion: '^18.0.0' },
2934
+ },
2935
+ }),
2936
+ new HtmlWebpackPlugin({ template: './public/index.html' }),
2937
+ ],
2938
+ };
2939
+ `,
2940
+ "apps/webpack-remote/src/index.js": `// Remote bootstrap page. MF consumers load ./Widget via remoteEntry.js.
2941
+ document.getElementById('root').innerHTML =
2942
+ '<div style="padding:2rem;font-family:system-ui">' +
2943
+ '<h2>webpack-remote \u2014 running</h2>' +
2944
+ '<p>Exposes <code>mfRemote/Widget</code> via Module Federation.</p>' +
2945
+ '</div>';
2946
+ `,
2947
+ "apps/webpack-remote/src/exposes/Widget.tsx": `import React from 'react';
2948
+
2949
+ // Exposed as mfRemote/Widget. The Rspack shell loads this via lazy import.
2950
+ export default function Widget(): React.ReactElement {
2951
+ return (
2952
+ <section style={{
2953
+ padding: '1rem', border: '1px solid #e2e8f0',
2954
+ borderRadius: '8px', background: '#f8fafc', maxWidth: '400px',
2955
+ }}>
2956
+ <h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem' }}>Remote Widget</h2>
2957
+ <p style={{ margin: 0, color: '#475569', fontSize: '0.875rem' }}>
2958
+ Served by a webpack remote, consumed by the Rspack shell via native MF.
2959
+ </p>
2960
+ </section>
2961
+ );
2962
+ }
2963
+ `,
2964
+ "apps/webpack-remote/public/index.html": `<!DOCTYPE html>
2965
+ <html lang="en">
2966
+ <head><meta charset="UTF-8" /><title>Webpack Remote</title></head>
2967
+ <body><div id="root"></div></body>
2968
+ </html>
2969
+ `,
2970
+ };
2971
+
2972
+ export const RSPACK_SHELL_LAB: FrontendLabWorkspace = {
2973
+ version: 1,
2974
+ label: "Rspack Shell \u2014 Native MF 2.0",
2975
+ type: "module-federation",
2976
+ activeFile: "apps/rspack-shell/rspack.config.js",
2977
+ files: RSPACK_SHELL_FILES,
2978
+ };
2979
+
2980
+ export function defaultForType(type: FrontendLabType): FrontendLabWorkspace {
2981
+ if (type === "nextjs") return DEFAULT_NEXTJS_LAB;
2982
+ if (type === "module-federation") return DEFAULT_MODULE_FEDERATION_LAB;
2983
+ return DEFAULT_REACT_LAB;
2984
+ }
2985
+
2986
+ export function cloneFrontendLabWorkspace(
2987
+ workspace?: FrontendLabWorkspace | null,
2988
+ type?: FrontendLabType,
2989
+ ): FrontendLabWorkspace {
2990
+ const resolvedType = workspace?.type ?? type ?? "react";
2991
+ const defaults = defaultForType(resolvedType);
2992
+ const source = workspace ?? defaults;
2993
+ const files =
2994
+ source.files && Object.keys(source.files).length > 0
2995
+ ? { ...source.files }
2996
+ : { ...defaults.files };
2997
+ const activeFile = files[source.activeFile]
2998
+ ? source.activeFile
2999
+ : (Object.keys(files)[0] ?? defaults.activeFile);
3000
+
3001
+ return {
3002
+ version: 1,
3003
+ label: source.label?.trim() || defaults.label,
3004
+ type: resolvedType,
3005
+ activeFile,
3006
+ files,
3007
+ };
3008
+ }
3009
+
3010
+ export function serializeFrontendLabWorkspace(
3011
+ workspace: FrontendLabWorkspace,
3012
+ ): string {
3013
+ return JSON.stringify(cloneFrontendLabWorkspace(workspace), null, 2);
3014
+ }
3015
+
3016
+ export function parseFrontendLabWorkspace(
3017
+ raw: string,
3018
+ ): FrontendLabWorkspace | null {
3019
+ try {
3020
+ const parsed = JSON.parse(raw) as Partial<FrontendLabWorkspace> & {
3021
+ files?: Record<string, unknown>;
3022
+ };
3023
+ if (!parsed || typeof parsed !== "object") return null;
3024
+ if (!parsed.files || typeof parsed.files !== "object") return null;
3025
+
3026
+ const files = Object.fromEntries(
3027
+ Object.entries(parsed.files).filter(
3028
+ (e): e is [string, string] => typeof e[1] === "string",
3029
+ ),
3030
+ );
3031
+ if (Object.keys(files).length === 0) return null;
3032
+
3033
+ const type: FrontendLabType =
3034
+ parsed.type === "nextjs"
3035
+ ? "nextjs"
3036
+ : parsed.type === "module-federation"
3037
+ ? "module-federation"
3038
+ : "react";
3039
+
3040
+ return cloneFrontendLabWorkspace({
3041
+ version: 1,
3042
+ type,
3043
+ label:
3044
+ typeof parsed.label === "string" && parsed.label.trim()
3045
+ ? parsed.label.trim()
3046
+ : defaultForType(type).label,
3047
+ activeFile:
3048
+ typeof parsed.activeFile === "string"
3049
+ ? parsed.activeFile
3050
+ : defaultForType(type).activeFile,
3051
+ files,
3052
+ });
3053
+ } catch {
3054
+ return null;
3055
+ }
3056
+ }
3057
+
3058
+ /** Returns the canonical entry file for "Run" → preview. */
3059
+ export function getEntryFile(workspace: FrontendLabWorkspace): string {
3060
+ if (workspace.type === "nextjs") {
3061
+ return workspace.files["app/page.tsx"]
3062
+ ? "app/page.tsx"
3063
+ : Object.keys(workspace.files)[0];
3064
+ }
3065
+ if (workspace.type === "module-federation") {
3066
+ return workspace.files["apps/host/src/App.tsx"]
3067
+ ? "apps/host/src/App.tsx"
3068
+ : workspace.files["apps/host/src/App.jsx"]
3069
+ ? "apps/host/src/App.jsx"
3070
+ : Object.keys(workspace.files)[0];
1643
3071
  }
1644
3072
  return workspace.files["main.tsx"]
1645
3073
  ? "main.tsx"