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.
- package/package.json +1 -1
- package/template/client/package-lock.json +75 -2
- package/template/client/package.json +3 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/browserSecurityTemplates.ts +75 -72
- package/template/client/src/components/CanvasLabModal.tsx +585 -0
- package/template/client/src/components/CodeRunnerModal.tsx +406 -22
- package/template/client/src/components/LabsPanel.tsx +63 -0
- package/template/client/src/components/Sidebar.tsx +12 -1
- package/template/client/src/components/WorkspaceSwitcher.tsx +36 -0
- package/template/client/src/reactLab.ts +1582 -154
- package/template/client/src/store.ts +24 -1
- package/template/client/src/types.ts +3 -1
- package/template/client/vite.config.ts +15 -8
- package/template/cockpit.json +1 -1
- package/template/server/src/google-drive.ts +4 -1
- package/template/server/src/index.ts +197 -53
- package/template/server/src/storage.ts +1 -0
|
@@ -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/
|
|
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(
|
|
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": `
|
|
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.
|
|
646
|
+
"apps/host/src/index.tsx": `import("./bootstrap");
|
|
615
647
|
`,
|
|
616
|
-
"apps/host/src/bootstrap.
|
|
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.
|
|
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.
|
|
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: "
|
|
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/
|
|
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.
|
|
877
|
+
"apps/profile/src/index.tsx": `import("./bootstrap");
|
|
826
878
|
`,
|
|
827
|
-
"apps/profile/src/bootstrap.
|
|
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.
|
|
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.
|
|
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.
|
|
887
|
-
"./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.
|
|
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.
|
|
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: "
|
|
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/
|
|
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.
|
|
1058
|
+
"apps/checkout/src/index.tsx": `import("./bootstrap");
|
|
988
1059
|
`,
|
|
989
|
-
"apps/checkout/src/bootstrap.
|
|
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.
|
|
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.
|
|
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.
|
|
1057
|
-
"./InspectorBridge": path.resolve(__dirname, "./src/inspectorBridge.
|
|
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.
|
|
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: "
|
|
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.
|
|
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.
|
|
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: "
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|
|
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.
|
|
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: "
|
|
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.
|
|
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.
|
|
1567
|
+
"apps/mfe-auth/src/index.ts": `import("./bootstrap");
|
|
1447
1568
|
`,
|
|
1448
|
-
"apps/mfe-auth/src/bootstrap.
|
|
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.
|
|
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.
|
|
1489
|
-
// mount.
|
|
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.
|
|
1681
|
+
activeFile: "apps/mfe-auth/src/mount.tsx",
|
|
1551
1682
|
files: MODULE_FEDERATION_ISOLATED_FILES,
|
|
1552
1683
|
};
|
|
1553
1684
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
|
1561
|
-
|
|
1562
|
-
type
|
|
1563
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
1908
|
+
return config;
|
|
1909
|
+
},
|
|
1910
|
+
};
|
|
1606
1911
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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"
|