@checkstack/frontend 0.7.1 → 0.8.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/CHANGELOG.md +57 -0
- package/index.html +75 -11
- package/package.json +16 -15
- package/src/main.tsx +6 -0
- package/src/plugin-loader.test.ts +52 -53
- package/src/plugin-loader.ts +76 -112
- package/vite.config.ts +116 -102
- package/vite.config.vendor.ts +0 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# @checkstack/frontend
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 968c12f: Show a loading spinner on initial app load instead of a blank screen. The host
|
|
8
|
+
boots by awaiting plugin registration + Module Federation init before React
|
|
9
|
+
mounts, which left `#root` empty for a few seconds. `index.html` now renders an
|
|
10
|
+
inline, theme-aware boot splash (visible before the JS/CSS bundles load, with a
|
|
11
|
+
no-flash light/dark head start mirroring the saved theme, and reduced-motion
|
|
12
|
+
safe) that `main.tsx` removes once the app has rendered.
|
|
13
|
+
- 968c12f: Make installed (runtime) frontend plugins actually load, via Module Federation 2.0. Previously a packed external plugin's frontend could not run: the host only shared React/router with runtime plugins, and there was no working way to share the framework/UI singletons (hand-rolled import-map externalisation hit an unsolvable rolldown CJS-interop wall).
|
|
14
|
+
|
|
15
|
+
- **Host (`@checkstack/frontend`)** now uses `@module-federation/vite` as an MF host and loads runtime plugins through the MF runtime (`registerRemotes` + `loadRemote`) instead of a raw `import()`. The shared set (react, react-dom, react-router-dom, @tanstack/react-query, @checkstack/frontend-api) is owned by the host; plugins reuse those exact instances via the share scope. The old hand-rolled vendor build + import map are removed.
|
|
16
|
+
- **`@checkstack/ui`** is bundled per consumer (tree-shaken); its Theme / Toast / Performance React contexts are unified across the host and bundled-in-plugin copies via a registered (globalThis-keyed) context, so a plugin's `useTheme`/`useToast`/`usePerformance` resolve to the host's providers. The ONE exception is the Monaco / VS Code **CodeEditor**, now exposed as the `@checkstack/ui/code-editor` subpath and shared as an MF singleton: the host owns the single editor instance (and builds its `?worker&url` workers), and plugins reuse it. A plugin can now render `<CodeEditor>` (directly or via `ScriptTestPanel` / template/JSON fields) without bundling Monaco.
|
|
17
|
+
- **Scaffold + pack (`@checkstack/scripts`)** build frontend plugins as MF remotes (`vite build` with the federation plugin, exposing `./plugin`, manifest enabled, DTS disabled). The CodeEditor is shared with `import: false` so the plugin is a consume-only participant - it never bundles a local fallback of the editor, keeping the heavy `@codingame/*` / `monaco-languageclient` / `vscode` subtree out of the plugin entirely (so no `vscode` alias or ES-worker config is needed in the plugin build). `plugin-pack` builds frontend packages with `NODE_ENV=production` (the MF plugin skips the remote under `NODE_ENV=test`) and ships only `dist/`. The scaffolded route now declares a `nav` entry so it appears in the sidebar.
|
|
18
|
+
- **Backend (`@checkstack/backend`)** serves a plugin's MF assets under its (possibly scoped) package name (`/assets/plugins/@scope/name/*`), with correct content types, and the SPA catch-all defers those paths so the federation manifest/remoteEntry are not shadowed by `index.html`.
|
|
19
|
+
|
|
20
|
+
Verified end-to-end by the external-plugin install E2E (scaffold → pack → install via the Plugin Manager UI → frontend + backend + co-loaded core plugins all work).
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- ed251b6: Make `@checkstack/ui`'s Monaco `CodeEditor` render in standalone `bun run dev`, and consolidate the Monaco editor Vite settings into one shared helper so they can't drift.
|
|
25
|
+
|
|
26
|
+
- **Shared config (now in `@checkstack/ui`).** `@checkstack/ui` exports a `monacoViteConfig` helper (`@checkstack/ui/src/vite-monaco`) with the editor's Vite settings - `worker.format: "es"` and the `vscode` resolve alias (so `require("vscode")` doesn't leak into the browser). `@checkstack/ui` owns `CodeEditor` and the editor dependencies, so all three consumers now share one source: the app's `vite.config.ts`, `@checkstack/dev-server`, and `@checkstack/ui`'s own Storybook config (each previously hand-rolled its own copy).
|
|
27
|
+
- **Pre-built workers (dev server).** In a standalone plugin, `@checkstack/ui` is a _pre-bundled npm dependency_, and Vite's dependency optimizer can't process the Monaco language workers it imports via `?worker&url` - the dev server used to crash (and serving `@checkstack/ui` as source instead broke the CJS/ESM interop of its other deps). The dev server now pre-builds the three Monaco workers (editor / TypeScript / JSON) into static ES-module bundles, serves them, and redirects the `?worker&url` imports to them via `resolve.alias` (which applies during pre-bundling). `@checkstack/ui` stays pre-bundled and the workers resolve, so the editor renders. Builds are content-addressed and cached under `node_modules/.cache/checkstack-dev-monaco` (concurrency-safe atomic promotion), so only the first run after a dependency change pays the build cost. React is deduped so the editor's hooks share the dev shell's React instance.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [ed251b6]
|
|
30
|
+
- Updated dependencies [968c12f]
|
|
31
|
+
- @checkstack/ui@1.14.0
|
|
32
|
+
- @checkstack/about-frontend@0.3.3
|
|
33
|
+
- @checkstack/announcement-frontend@0.4.3
|
|
34
|
+
- @checkstack/auth-frontend@0.7.3
|
|
35
|
+
- @checkstack/catalog-frontend@0.11.3
|
|
36
|
+
- @checkstack/command-frontend@0.3.3
|
|
37
|
+
- @checkstack/dependency-frontend@0.5.3
|
|
38
|
+
- @checkstack/common@0.14.1
|
|
39
|
+
- @checkstack/frontend-api@0.7.2
|
|
40
|
+
- @checkstack/signal-common@0.2.8
|
|
41
|
+
- @checkstack/signal-frontend@0.2.2
|
|
42
|
+
|
|
43
|
+
## 0.7.2
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [1fee9da]
|
|
48
|
+
- @checkstack/common@0.14.1
|
|
49
|
+
- @checkstack/about-frontend@0.3.2
|
|
50
|
+
- @checkstack/announcement-frontend@0.4.2
|
|
51
|
+
- @checkstack/auth-frontend@0.7.2
|
|
52
|
+
- @checkstack/catalog-frontend@0.11.2
|
|
53
|
+
- @checkstack/command-frontend@0.3.2
|
|
54
|
+
- @checkstack/dependency-frontend@0.5.2
|
|
55
|
+
- @checkstack/frontend-api@0.7.2
|
|
56
|
+
- @checkstack/signal-common@0.2.8
|
|
57
|
+
- @checkstack/ui@1.13.2
|
|
58
|
+
- @checkstack/signal-frontend@0.2.2
|
|
59
|
+
|
|
3
60
|
## 0.7.1
|
|
4
61
|
|
|
5
62
|
### Patch Changes
|
package/index.html
CHANGED
|
@@ -6,23 +6,87 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Checkstack Health Monitor</title>
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
|
-
<!--
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
<!--
|
|
10
|
+
Resolve the saved theme BEFORE first paint so the boot splash (and the
|
|
11
|
+
app's first frame) match the user's light/dark choice instead of flashing.
|
|
12
|
+
Mirrors ThemeProvider (storageKey "checkstack-ui-theme"); ThemeProvider
|
|
13
|
+
re-applies the class on mount, so this is purely a no-flash head start.
|
|
14
|
+
-->
|
|
15
|
+
<script>
|
|
16
|
+
(function () {
|
|
17
|
+
try {
|
|
18
|
+
var t = localStorage.getItem("checkstack-ui-theme") || "system";
|
|
19
|
+
var dark =
|
|
20
|
+
t === "dark" ||
|
|
21
|
+
(t === "system" &&
|
|
22
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
23
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
/* localStorage unavailable (private mode) - default to light */
|
|
18
26
|
}
|
|
19
|
-
}
|
|
27
|
+
})();
|
|
20
28
|
</script>
|
|
29
|
+
<!--
|
|
30
|
+
Boot splash styles. Inlined (not in the app CSS bundle) so the spinner is
|
|
31
|
+
visible during the blank window while main.tsx loads plugins + initialises
|
|
32
|
+
Module Federation, before React mounts. Colours mirror the theme tokens in
|
|
33
|
+
core/ui/src/themes.css. Removed by main.tsx once the app renders.
|
|
34
|
+
-->
|
|
35
|
+
<style>
|
|
36
|
+
#boot-splash {
|
|
37
|
+
position: fixed;
|
|
38
|
+
inset: 0;
|
|
39
|
+
z-index: 9999;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
background: hsl(0 0% 100%);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
html.dark #boot-splash {
|
|
47
|
+
background: hsl(240 10% 4%);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#boot-splash .boot-splash__spinner {
|
|
51
|
+
width: 2.5rem;
|
|
52
|
+
height: 2.5rem;
|
|
53
|
+
border-radius: 9999px;
|
|
54
|
+
border: 3px solid hsl(262 83% 58% / 0.2);
|
|
55
|
+
border-top-color: hsl(262 83% 58%);
|
|
56
|
+
animation: boot-splash-spin 0.7s linear infinite;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
html.dark #boot-splash .boot-splash__spinner {
|
|
60
|
+
border-color: hsl(263 70% 65% / 0.2);
|
|
61
|
+
border-top-color: hsl(263 70% 65%);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Respect reduced-motion: show a static ring instead of spinning. */
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
#boot-splash .boot-splash__spinner {
|
|
67
|
+
animation: none;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@keyframes boot-splash-spin {
|
|
72
|
+
to {
|
|
73
|
+
transform: rotate(360deg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
21
77
|
</head>
|
|
22
78
|
|
|
23
79
|
<body>
|
|
24
80
|
<div id="root"></div>
|
|
81
|
+
<!--
|
|
82
|
+
Sibling overlay (not a child of #root) so React mounts into an empty
|
|
83
|
+
container. It covers the viewport until main.tsx removes it after the first
|
|
84
|
+
render, so the app is already painted underneath when the splash goes away.
|
|
85
|
+
-->
|
|
86
|
+
<div id="boot-splash" role="status" aria-label="Loading Checkstack">
|
|
87
|
+
<div class="boot-splash__spinner"></div>
|
|
88
|
+
</div>
|
|
25
89
|
<script type="module" src="/src/main.tsx"></script>
|
|
26
90
|
</body>
|
|
27
91
|
|
|
28
|
-
</html>
|
|
92
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "frontend"
|
|
@@ -13,25 +13,26 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"dev": "vite",
|
|
16
|
-
"build
|
|
17
|
-
"build": "bun run build:vendor && vite build",
|
|
16
|
+
"build": "vite build",
|
|
18
17
|
"typecheck": "tsgo -b",
|
|
19
18
|
"lint": "bun run lint:code",
|
|
20
19
|
"preview": "vite preview",
|
|
21
20
|
"lint:code": "eslint . --max-warnings 0"
|
|
22
21
|
},
|
|
23
22
|
"dependencies": {
|
|
24
|
-
"@checkstack/about-frontend": "0.3.
|
|
25
|
-
"@checkstack/announcement-frontend": "0.4.
|
|
26
|
-
"@checkstack/auth-frontend": "0.7.
|
|
27
|
-
"@checkstack/catalog-frontend": "0.11.
|
|
28
|
-
"@checkstack/command-frontend": "0.3.
|
|
29
|
-
"@checkstack/common": "0.
|
|
30
|
-
"@checkstack/dependency-frontend": "0.5.
|
|
31
|
-
"@checkstack/frontend-api": "0.7.
|
|
32
|
-
"@checkstack/signal-common": "0.2.
|
|
33
|
-
"@checkstack/signal-frontend": "0.2.
|
|
34
|
-
"@checkstack/ui": "1.
|
|
23
|
+
"@checkstack/about-frontend": "0.3.3",
|
|
24
|
+
"@checkstack/announcement-frontend": "0.4.3",
|
|
25
|
+
"@checkstack/auth-frontend": "0.7.3",
|
|
26
|
+
"@checkstack/catalog-frontend": "0.11.3",
|
|
27
|
+
"@checkstack/command-frontend": "0.3.3",
|
|
28
|
+
"@checkstack/common": "0.14.1",
|
|
29
|
+
"@checkstack/dependency-frontend": "0.5.3",
|
|
30
|
+
"@checkstack/frontend-api": "0.7.2",
|
|
31
|
+
"@checkstack/signal-common": "0.2.8",
|
|
32
|
+
"@checkstack/signal-frontend": "0.2.2",
|
|
33
|
+
"@checkstack/ui": "1.14.0",
|
|
34
|
+
"@module-federation/runtime": "^2.5",
|
|
35
|
+
"@module-federation/vite": "^1.16",
|
|
35
36
|
"@orpc/client": "^1.14.4",
|
|
36
37
|
"@tanstack/react-query": "^5.100.14",
|
|
37
38
|
"@tanstack/react-query-devtools": "^5.100.14",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"tailwindcss-animate": "^1.0.7"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
|
-
"@checkstack/scripts": "0.
|
|
52
|
+
"@checkstack/scripts": "0.5.0",
|
|
52
53
|
"@checkstack/tsconfig": "0.0.7",
|
|
53
54
|
"@types/react": "^18.2.64",
|
|
54
55
|
"@types/react-dom": "^18.2.21",
|
package/src/main.tsx
CHANGED
|
@@ -15,3 +15,9 @@ ReactDOM.createRoot(document.querySelector("#root")!).render(
|
|
|
15
15
|
</ThemeProvider>
|
|
16
16
|
</React.StrictMode>,
|
|
17
17
|
);
|
|
18
|
+
|
|
19
|
+
// Remove the inline boot splash (see index.html) now that the app has mounted.
|
|
20
|
+
// createRoot's initial render commits synchronously, so the app is already in
|
|
21
|
+
// the DOM underneath the overlay - removing it reveals the app with no blank
|
|
22
|
+
// frame.
|
|
23
|
+
document.querySelector("#boot-splash")?.remove();
|
|
@@ -1,21 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
describe,
|
|
3
|
-
it,
|
|
4
|
-
expect,
|
|
5
|
-
mock,
|
|
6
|
-
beforeAll,
|
|
7
|
-
beforeEach,
|
|
8
|
-
afterAll,
|
|
9
|
-
} from "bun:test";
|
|
10
|
-
import { loadPlugins } from "./plugin-loader";
|
|
1
|
+
import { describe, it, expect, mock, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
11
2
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
3
|
+
// The loader registers each runtime (installed) plugin as a Module Federation
|
|
4
|
+
// remote (`registerRemotes`) and loads its exposed `./plugin` (`loadRemote`).
|
|
5
|
+
// Stub both so the test exercises the loader's wiring without a real federation
|
|
6
|
+
// host. `@module-federation/runtime` has exactly one importer in the repo
|
|
7
|
+
// (plugin-loader.ts) and no other test imports it, so this process-wide
|
|
8
|
+
// `mock.module` can't leak into another suite's expectations.
|
|
9
|
+
const registerRemotes = mock(
|
|
10
|
+
(_remotes: Array<{ name: string; entry: string }>) => {},
|
|
11
|
+
);
|
|
12
|
+
const loadRemote = mock((id: string) => {
|
|
13
|
+
// The host loads `<remoteName>/plugin`; hand back a valid FrontendPlugin.
|
|
14
|
+
if (id === "remote_plugin/plugin") {
|
|
15
|
+
return Promise.resolve({
|
|
16
|
+
default: { metadata: { pluginId: "remote-plugin" }, extensions: [] },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return Promise.resolve(undefined);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
mock.module("@module-federation/runtime", () => ({
|
|
23
|
+
registerRemotes,
|
|
24
|
+
loadRemote,
|
|
25
|
+
}));
|
|
15
26
|
|
|
16
|
-
// Mock fetch
|
|
17
|
-
// argument can never throw (defensive; the override is also scoped to this
|
|
18
|
-
// suite's lifecycle below).
|
|
27
|
+
// Mock fetch for the enabled-plugins endpoint.
|
|
19
28
|
const mockFetch = mock((url: string) => {
|
|
20
29
|
if (url === "/api/plugins") {
|
|
21
30
|
return Promise.resolve({
|
|
@@ -23,73 +32,63 @@ const mockFetch = mock((url: string) => {
|
|
|
23
32
|
json: () => Promise.resolve([{ name: "remote-plugin", path: "/dist" }]),
|
|
24
33
|
} as unknown as Response);
|
|
25
34
|
}
|
|
26
|
-
// Mock HEAD request for CSS
|
|
27
|
-
if (typeof url === "string" && url.endsWith(".css")) {
|
|
28
|
-
return Promise.resolve({ ok: true } as unknown as Response);
|
|
29
|
-
}
|
|
30
35
|
return Promise.resolve({ ok: false } as unknown as Response);
|
|
31
36
|
});
|
|
32
37
|
|
|
33
|
-
// bun test runs every file in ONE shared process, so leaving
|
|
34
|
-
// `global` leaks into other files -
|
|
35
|
-
// hit this mock instead of the real fetch and crash. Scope them to this suite.
|
|
38
|
+
// bun test runs every file in ONE shared process, so leaving fetch overridden
|
|
39
|
+
// on `global` leaks into other files - scope it to this suite's lifecycle.
|
|
36
40
|
const originalFetch = global.fetch;
|
|
37
|
-
const originalDocument = global.document;
|
|
38
41
|
|
|
39
42
|
beforeAll(() => {
|
|
40
43
|
(global as unknown as { fetch: typeof fetch }).fetch = mockFetch as never;
|
|
41
|
-
global.document = {
|
|
42
|
-
createElement: mock(() => ({})),
|
|
43
|
-
head: {
|
|
44
|
-
append: mock(),
|
|
45
|
-
},
|
|
46
|
-
} as unknown as Document;
|
|
47
44
|
});
|
|
48
45
|
|
|
49
46
|
afterAll(() => {
|
|
50
47
|
(global as unknown as { fetch: typeof fetch }).fetch = originalFetch;
|
|
51
|
-
(global as unknown as { document: Document }).document = originalDocument;
|
|
52
48
|
});
|
|
53
49
|
|
|
54
50
|
describe("frontend loadPlugins", () => {
|
|
55
51
|
beforeEach(() => {
|
|
56
52
|
mockFetch.mockClear();
|
|
53
|
+
registerRemotes.mockClear();
|
|
54
|
+
loadRemote.mockClear();
|
|
57
55
|
});
|
|
58
56
|
|
|
59
|
-
it("
|
|
60
|
-
// Import the real pluginRegistry to verify registration
|
|
57
|
+
it("registers bundled plugins and loads enabled runtime plugins as MF remotes", async () => {
|
|
61
58
|
const { pluginRegistry } = await import("@checkstack/frontend-api");
|
|
59
|
+
// Dynamic import so the `mock.module` above is in effect for the loader.
|
|
60
|
+
const { loadPlugins } = await import("./plugin-loader");
|
|
62
61
|
|
|
63
|
-
// Reset registry before test
|
|
64
62
|
pluginRegistry.reset();
|
|
65
63
|
|
|
66
|
-
//
|
|
67
|
-
|
|
64
|
+
// Bundled (local) plugins arrive as already-resolved modules from the eager
|
|
65
|
+
// `import.meta.glob`; the override stands in for that map.
|
|
66
|
+
const bundledModules = {
|
|
68
67
|
"../../../plugins/local-frontend/src/index.tsx": {
|
|
69
68
|
default: { metadata: { pluginId: "local" }, extensions: [] },
|
|
70
69
|
},
|
|
71
70
|
};
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
mock.module("/assets/plugins/remote-plugin/index.js", () => ({
|
|
75
|
-
default: { metadata: { pluginId: "remote-plugin" }, extensions: [] },
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
await loadPlugins(mockModules);
|
|
72
|
+
await loadPlugins(bundledModules);
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
74
|
+
const ids = pluginRegistry
|
|
75
|
+
.getPlugins()
|
|
76
|
+
.map((p) => p.metadata.pluginId);
|
|
77
|
+
// Bundled local plugin registered from the eager module map.
|
|
78
|
+
expect(ids).toContain("local");
|
|
79
|
+
// Runtime plugin loaded via MF and registered.
|
|
80
|
+
expect(ids).toContain("remote-plugin");
|
|
85
81
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
// The remote is registered under its identifier-safe name, pointing at the
|
|
83
|
+
// backend-served manifest, and loaded via its exposed `./plugin`.
|
|
84
|
+
expect(registerRemotes).toHaveBeenCalledWith([
|
|
85
|
+
{
|
|
86
|
+
name: "remote_plugin",
|
|
87
|
+
entry: "/assets/plugins/remote-plugin/mf-manifest.json",
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
expect(loadRemote).toHaveBeenCalledWith("remote_plugin/plugin");
|
|
91
91
|
|
|
92
|
-
// Clean up
|
|
93
92
|
pluginRegistry.reset();
|
|
94
93
|
});
|
|
95
94
|
});
|
package/src/plugin-loader.ts
CHANGED
|
@@ -2,6 +2,48 @@ import {
|
|
|
2
2
|
FrontendPlugin,
|
|
3
3
|
pluginRegistry,
|
|
4
4
|
} from "@checkstack/frontend-api";
|
|
5
|
+
import { loadRemote, registerRemotes } from "@module-federation/runtime";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Module Federation remote name for a runtime plugin, derived deterministically
|
|
9
|
+
* from its npm package name so the host and the scaffolded plugin's federation
|
|
10
|
+
* config agree without coordination. MF remote names must be identifier-safe.
|
|
11
|
+
* e.g. `@checkstackit/widget-frontend` -> `checkstackit_widget_frontend`.
|
|
12
|
+
*/
|
|
13
|
+
function mfRemoteName(packageName: string): string {
|
|
14
|
+
return packageName.replace(/^@/, "").replaceAll(/[^a-zA-Z0-9]/g, "_");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register a runtime plugin as a Module Federation remote (manifest served by
|
|
19
|
+
* the backend at /assets/plugins/<pkg>/mf-manifest.json) and load its exposed
|
|
20
|
+
* `./plugin` module. MF's share scope hands the remote the host's React /
|
|
21
|
+
* Router / QueryClient / framework-api instances, and injects the remote's CSS.
|
|
22
|
+
*/
|
|
23
|
+
async function loadRemotePluginModule(packageName: string): Promise<unknown> {
|
|
24
|
+
const name = mfRemoteName(packageName);
|
|
25
|
+
registerRemotes([
|
|
26
|
+
{
|
|
27
|
+
name,
|
|
28
|
+
entry: `/assets/plugins/${packageName}/mf-manifest.json`,
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
return loadRemote(`${name}/plugin`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function registerFromModule(mod: unknown, label: string): boolean {
|
|
35
|
+
if (typeof mod !== "object" || mod === null) return false;
|
|
36
|
+
const pluginExport = Object.values(mod as Record<string, unknown>).find(
|
|
37
|
+
(exp): exp is FrontendPlugin => isFrontendPlugin(exp),
|
|
38
|
+
);
|
|
39
|
+
if (!pluginExport) {
|
|
40
|
+
console.warn(`⚠️ No valid FrontendPlugin export found in ${label}`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
console.log(`🔌 Registering plugin: ${pluginExport.metadata.pluginId}`);
|
|
44
|
+
pluginRegistry.register(pluginExport);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
5
47
|
|
|
6
48
|
export async function loadPlugins(overrideModules?: Record<string, unknown>) {
|
|
7
49
|
console.log("🔌 discovering plugins...");
|
|
@@ -45,24 +87,21 @@ export async function loadPlugins(overrideModules?: Record<string, unknown>) {
|
|
|
45
87
|
// 3. Load and register enabled plugins
|
|
46
88
|
const registeredNames = new Set<string>();
|
|
47
89
|
|
|
48
|
-
// Phase 1: Local plugins (bundled
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const [path, mod] of entries) {
|
|
90
|
+
// Phase 1: Local plugins (bundled into the host via eager glob — already
|
|
91
|
+
// loaded). These share React/etc. with the host by virtue of being in the
|
|
92
|
+
// same build.
|
|
93
|
+
for (const [path, mod] of Object.entries(modules)) {
|
|
52
94
|
try {
|
|
53
|
-
if (typeof mod !== "object" || mod === null)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const pluginExport = Object.values(mod as Record<string, unknown>).find(
|
|
58
|
-
(exp): exp is FrontendPlugin => isFrontendPlugin(exp)
|
|
59
|
-
);
|
|
60
|
-
|
|
95
|
+
if (typeof mod !== "object" || mod === null) continue;
|
|
96
|
+
const pluginExport = Object.values(
|
|
97
|
+
mod as Record<string, unknown>,
|
|
98
|
+
).find((exp): exp is FrontendPlugin => isFrontendPlugin(exp));
|
|
61
99
|
if (pluginExport) {
|
|
62
|
-
const pluginId = pluginExport.metadata.pluginId;
|
|
63
|
-
console.log(`🔌 Registering local plugin: ${pluginId}`);
|
|
64
100
|
pluginRegistry.register(pluginExport);
|
|
65
|
-
registeredNames.add(pluginId);
|
|
101
|
+
registeredNames.add(pluginExport.metadata.pluginId);
|
|
102
|
+
console.log(
|
|
103
|
+
`🔌 Registered local plugin: ${pluginExport.metadata.pluginId}`,
|
|
104
|
+
);
|
|
66
105
|
} else {
|
|
67
106
|
console.warn(`⚠️ No valid FrontendPlugin export found in ${path}`);
|
|
68
107
|
}
|
|
@@ -71,50 +110,18 @@ export async function loadPlugins(overrideModules?: Record<string, unknown>) {
|
|
|
71
110
|
}
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
// Phase 2:
|
|
113
|
+
// Phase 2: Runtime (installed) plugins — loaded as Module Federation
|
|
114
|
+
// remotes so they share the host's singletons.
|
|
75
115
|
for (const plugin of enabledPlugins) {
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const cssCheck = await fetch(remoteCssUrl, { method: "HEAD" });
|
|
83
|
-
if (cssCheck.ok) {
|
|
84
|
-
console.log(`🎨 Loading remote styles for: ${plugin.name}`);
|
|
85
|
-
const link = document.createElement("link");
|
|
86
|
-
link.rel = "stylesheet";
|
|
87
|
-
link.href = remoteCssUrl;
|
|
88
|
-
document.head.append(link);
|
|
89
|
-
}
|
|
90
|
-
} catch (error) {
|
|
91
|
-
console.debug(`No separate CSS found for ${plugin.name}`, error);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 2. Load JS entry point
|
|
95
|
-
const remoteUrl = `/assets/plugins/${plugin.name}/index.js`;
|
|
96
|
-
const mod = await import(/* @vite-ignore */ remoteUrl);
|
|
97
|
-
|
|
98
|
-
const pluginExport = Object.values(
|
|
99
|
-
mod as Record<string, unknown>
|
|
100
|
-
).find((exp): exp is FrontendPlugin => isFrontendPlugin(exp));
|
|
101
|
-
|
|
102
|
-
if (pluginExport) {
|
|
103
|
-
const pluginId = pluginExport.metadata.pluginId;
|
|
104
|
-
console.log(`🔌 Registering enabled remote plugin: ${pluginId}`);
|
|
105
|
-
pluginRegistry.register(pluginExport);
|
|
106
|
-
registeredNames.add(pluginId);
|
|
107
|
-
} else {
|
|
108
|
-
console.warn(
|
|
109
|
-
`⚠️ No valid FrontendPlugin export found for remote plugin ${plugin.name}`
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
} catch (error) {
|
|
113
|
-
console.error(
|
|
114
|
-
`❌ Failed to load remote plugin ${plugin.name}:`,
|
|
115
|
-
error
|
|
116
|
-
);
|
|
116
|
+
if (registeredNames.has(plugin.name)) continue;
|
|
117
|
+
console.log(`🔌 Loading remote plugin: ${plugin.name}`);
|
|
118
|
+
try {
|
|
119
|
+
const mod = await loadRemotePluginModule(plugin.name);
|
|
120
|
+
if (registerFromModule(mod, `remote plugin ${plugin.name}`)) {
|
|
121
|
+
registeredNames.add(plugin.name);
|
|
117
122
|
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`❌ Failed to load remote plugin ${plugin.name}:`, error);
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
} catch (error) {
|
|
@@ -137,73 +144,30 @@ function isFrontendPlugin(candidate: unknown): candidate is FrontendPlugin {
|
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/**
|
|
140
|
-
* Load a single plugin
|
|
141
|
-
* Fetches the plugin from the backend and registers it.
|
|
147
|
+
* Load a single runtime plugin (for dynamic installation without a reload).
|
|
142
148
|
*
|
|
143
|
-
* @param
|
|
149
|
+
* @param packageName - The plugin's npm package name, matching the
|
|
150
|
+
* /assets/plugins/<packageName>/ path the backend serves.
|
|
144
151
|
*/
|
|
145
|
-
export async function loadSinglePlugin(
|
|
146
|
-
console.log(`🔌 Loading single plugin: ${
|
|
147
|
-
|
|
148
|
-
// Skip if already registered
|
|
149
|
-
if (pluginRegistry.hasPlugin(pluginId)) {
|
|
150
|
-
console.warn(`⚠️ Plugin ${pluginId} already registered`);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
152
|
+
export async function loadSinglePlugin(packageName: string): Promise<void> {
|
|
153
|
+
console.log(`🔌 Loading single plugin: ${packageName}`);
|
|
154
154
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const cssCheck = await fetch(remoteCssUrl, { method: "HEAD" });
|
|
159
|
-
if (cssCheck.ok) {
|
|
160
|
-
console.log(`🎨 Loading remote styles for: ${pluginId}`);
|
|
161
|
-
const link = document.createElement("link");
|
|
162
|
-
link.rel = "stylesheet";
|
|
163
|
-
link.href = remoteCssUrl;
|
|
164
|
-
link.id = `plugin-css-${pluginId}`;
|
|
165
|
-
document.head.append(link);
|
|
166
|
-
}
|
|
167
|
-
} catch (error) {
|
|
168
|
-
console.debug(`No separate CSS found for ${pluginId}`, error);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// 2. Load JS entry point
|
|
172
|
-
const remoteUrl = `/assets/plugins/${pluginId}/index.js`;
|
|
173
|
-
const mod = await import(/* @vite-ignore */ remoteUrl);
|
|
174
|
-
|
|
175
|
-
const pluginExport = Object.values(mod as Record<string, unknown>).find(
|
|
176
|
-
(exp): exp is FrontendPlugin => isFrontendPlugin(exp)
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
if (pluginExport) {
|
|
180
|
-
const pluginId = pluginExport.metadata.pluginId;
|
|
181
|
-
console.log(`🔌 Registering plugin: ${pluginId}`);
|
|
182
|
-
pluginRegistry.register(pluginExport);
|
|
183
|
-
} else {
|
|
184
|
-
console.warn(`⚠️ No valid FrontendPlugin export found for ${pluginId}`);
|
|
185
|
-
}
|
|
155
|
+
const mod = await loadRemotePluginModule(packageName);
|
|
156
|
+
registerFromModule(mod, packageName);
|
|
186
157
|
} catch (error) {
|
|
187
|
-
console.error(`❌ Failed to load plugin ${
|
|
158
|
+
console.error(`❌ Failed to load plugin ${packageName}:`, error);
|
|
188
159
|
throw error;
|
|
189
160
|
}
|
|
190
161
|
}
|
|
191
162
|
|
|
192
163
|
/**
|
|
193
|
-
* Unload a plugin at runtime (for dynamic deregistration).
|
|
194
|
-
*
|
|
164
|
+
* Unload a plugin at runtime (for dynamic deregistration). Removes it from the
|
|
165
|
+
* registry so its routes/slots stop rendering. (The MF remote container stays
|
|
166
|
+
* registered for the session; re-installing reuses it.)
|
|
195
167
|
*
|
|
196
|
-
* @param pluginId - The frontend plugin ID (e.g
|
|
168
|
+
* @param pluginId - The frontend plugin ID (e.g. "widget").
|
|
197
169
|
*/
|
|
198
170
|
export function unloadPlugin(pluginId: string): void {
|
|
199
171
|
console.log(`🔌 Unloading plugin: ${pluginId}`);
|
|
200
|
-
|
|
201
|
-
// Remove from registry
|
|
202
172
|
pluginRegistry.unregister(pluginId);
|
|
203
|
-
|
|
204
|
-
// Remove CSS if we added it
|
|
205
|
-
const cssLink = document.querySelector(`#plugin-css-${pluginId}`);
|
|
206
|
-
if (cssLink) {
|
|
207
|
-
cssLink.remove();
|
|
208
|
-
}
|
|
209
173
|
}
|
package/vite.config.ts
CHANGED
|
@@ -1,47 +1,117 @@
|
|
|
1
1
|
import { defineConfig } from "vite";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { federation } from "@module-federation/vite";
|
|
5
|
+
// Relative (not the bare `@checkstack/ui/...` specifier): Vite's config loader
|
|
6
|
+
// externalizes bare node_modules imports, which would leave this `.ts` file
|
|
7
|
+
// un-transpiled at config-load time (ERR_UNKNOWN_FILE_EXTENSION). A relative
|
|
8
|
+
// path is bundled into the config instead.
|
|
9
|
+
import { monacoViteConfig } from "../ui/src/vite-monaco";
|
|
5
10
|
|
|
6
11
|
// Monorepo root is 2 levels up from core/frontend
|
|
7
12
|
const monorepoRoot = path.resolve(__dirname, "../..");
|
|
8
13
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// can't resolve it and leaks a runtime `require` into the browser ("Calling
|
|
18
|
-
// require for vscode in an environment that doesn't expose the require
|
|
19
|
-
// function"). Aliasing every `vscode` specifier to the real package dir fixes
|
|
20
|
-
// it. We resolve the alias *through* @typefox so the path follows bun's store
|
|
21
|
-
// layout on any machine/CI rather than being hardcoded.
|
|
22
|
-
const localRequire = createRequire(path.join(__dirname, "vite.config.ts"));
|
|
23
|
-
const typefoxDir = path.dirname(
|
|
24
|
-
localRequire.resolve("@typefox/monaco-editor-react", {
|
|
25
|
-
paths: [path.resolve(__dirname, "../ui")],
|
|
26
|
-
}),
|
|
27
|
-
);
|
|
28
|
-
const vscodeApiDir = path.dirname(
|
|
29
|
-
localRequire.resolve("vscode", { paths: [typefoxDir] }),
|
|
30
|
-
);
|
|
14
|
+
// The Monaco / VS Code editor stack (`@checkstack/ui`'s CodeEditor) needs
|
|
15
|
+
// `worker.format: "es"` and a `vscode` resolve alias. Both are produced by this
|
|
16
|
+
// shared helper - also consumed by @checkstack/dev-server - so the app's config
|
|
17
|
+
// and the standalone-plugin dev config never drift. The editor deps live in
|
|
18
|
+
// `core/ui`, so we resolve from there.
|
|
19
|
+
const monaco = monacoViteConfig({
|
|
20
|
+
resolveFrom: [path.resolve(__dirname, "../ui")],
|
|
21
|
+
});
|
|
31
22
|
|
|
32
23
|
// https://vitejs.dev/config/
|
|
33
|
-
export default defineConfig(() => {
|
|
24
|
+
export default defineConfig(({ command }) => {
|
|
34
25
|
// Backend URL for proxy - always targets local backend in dev
|
|
35
26
|
const backendUrl = "http://localhost:3000";
|
|
27
|
+
|
|
28
|
+
// The shared editor singleton is ONLY wired up for production builds. In dev
|
|
29
|
+
// (`vite serve`) runtime plugins are compiled straight into the host by
|
|
30
|
+
// @checkstack/dev-server, so nothing consumes the share - and worse, an
|
|
31
|
+
// `eager` share forces vite's dep optimizer to crawl the editor's dynamic
|
|
32
|
+
// `?worker&url` Monaco subtree, whose `@codingame/*` worker files are not
|
|
33
|
+
// resolvable as bare specifiers from this app root (see the optimizeDeps note
|
|
34
|
+
// below), which fails dep optimization with UNLOADABLE_DEPENDENCY. In dev the
|
|
35
|
+
// host just bundles @checkstack/ui's editor normally (worker config + vscode
|
|
36
|
+
// alias are applied unconditionally below), exactly as before this change.
|
|
37
|
+
const editorShare =
|
|
38
|
+
command === "build"
|
|
39
|
+
? {
|
|
40
|
+
// The Monaco / VS Code editor (the only shared piece of
|
|
41
|
+
// @checkstack/ui). MUST be `eager`: runtime plugins consume it with
|
|
42
|
+
// `import: false` (no local fallback), and @module-federation/vite's
|
|
43
|
+
// consume-only shim READS `__mf_module_cache__.share[...]` after
|
|
44
|
+
// bootstrap rather than lazily invoking the provider - so the host
|
|
45
|
+
// has to have populated the share scope eagerly or the plugin throws
|
|
46
|
+
// "imported before federation bootstrap finished". Eager is safe: the
|
|
47
|
+
// editor's module scope is light (Monaco lives behind a dynamic import
|
|
48
|
+
// inside CodeEditor.tsx), so this loads the wrapper, NOT Monaco, at
|
|
49
|
+
// startup. `requiredVersion: false` skips negotiation (workspace:*,
|
|
50
|
+
// not a semver range). The deep specifier matches the re-export in
|
|
51
|
+
// core/ui/src/index.ts and the value imports inside @checkstack/ui, so
|
|
52
|
+
// every editor reference dedupes to one singleton.
|
|
53
|
+
"@checkstack/ui/code-editor": {
|
|
54
|
+
singleton: true,
|
|
55
|
+
eager: true,
|
|
56
|
+
requiredVersion: false,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: {};
|
|
60
|
+
|
|
36
61
|
return {
|
|
37
62
|
// Tell Vite to look for .env files in monorepo root
|
|
38
63
|
envDir: monorepoRoot,
|
|
39
|
-
plugins: [
|
|
64
|
+
plugins: [
|
|
65
|
+
react(),
|
|
66
|
+
// Module Federation 2.0 host. Runtime (installed) frontend plugins are
|
|
67
|
+
// built as MF remotes and registered at runtime (see plugin-loader.ts),
|
|
68
|
+
// so no remotes are declared here. `shared` makes the host the single
|
|
69
|
+
// owner of these singletons; remotes reuse the host's instances via the
|
|
70
|
+
// share scope — this is what lets a separately-built plugin share the
|
|
71
|
+
// host's React / Router / QueryClient / framework contexts (and is why
|
|
72
|
+
// it works where a hand-rolled import-map externalisation could not).
|
|
73
|
+
// @checkstack/ui is intentionally NOT shared wholesale: it is bundled per
|
|
74
|
+
// consumer (tree-shaken) and its few React contexts are unified via a
|
|
75
|
+
// registered (globalThis-keyed) context — see
|
|
76
|
+
// core/ui/src/utils/registered-context.ts. The ONE exception is the
|
|
77
|
+
// CodeEditor (Monaco / VS Code) stack: on a production build it is shared
|
|
78
|
+
// as a singleton so the host owns the single editor instance (and builds
|
|
79
|
+
// its `?worker&url` workers) while runtime plugins reuse it instead of
|
|
80
|
+
// bundling Monaco. See `editorShare` above for why this is build-only.
|
|
81
|
+
federation({
|
|
82
|
+
name: "checkstack_host",
|
|
83
|
+
remotes: {},
|
|
84
|
+
shared: {
|
|
85
|
+
react: { singleton: true, eager: true, requiredVersion: "^18.0.0" },
|
|
86
|
+
"react-dom": {
|
|
87
|
+
singleton: true,
|
|
88
|
+
eager: true,
|
|
89
|
+
requiredVersion: "^18.0.0",
|
|
90
|
+
},
|
|
91
|
+
"react-router-dom": {
|
|
92
|
+
singleton: true,
|
|
93
|
+
eager: true,
|
|
94
|
+
requiredVersion: "^7.0.0",
|
|
95
|
+
},
|
|
96
|
+
"@tanstack/react-query": {
|
|
97
|
+
singleton: true,
|
|
98
|
+
eager: true,
|
|
99
|
+
requiredVersion: "^5.0.0",
|
|
100
|
+
},
|
|
101
|
+
// First-party, version-locked to the host: skip version negotiation
|
|
102
|
+
// (its dep range is `workspace:*`, not a semver range).
|
|
103
|
+
"@checkstack/frontend-api": {
|
|
104
|
+
singleton: true,
|
|
105
|
+
eager: true,
|
|
106
|
+
requiredVersion: false,
|
|
107
|
+
},
|
|
108
|
+
...editorShare,
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
],
|
|
40
112
|
// The @typefox/monaco-editor-react + @codingame/monaco-vscode-* stack
|
|
41
|
-
// loads its language services in ES module workers.
|
|
42
|
-
worker:
|
|
43
|
-
format: "es",
|
|
44
|
-
},
|
|
113
|
+
// loads its language services in ES module workers.
|
|
114
|
+
worker: monaco.worker,
|
|
45
115
|
server: {
|
|
46
116
|
proxy: {
|
|
47
117
|
// Proxy API requests and WebSocket connections to backend
|
|
@@ -67,20 +137,13 @@ export default defineConfig(() => {
|
|
|
67
137
|
},
|
|
68
138
|
},
|
|
69
139
|
// ============================================================
|
|
70
|
-
// React
|
|
140
|
+
// React instance sharing
|
|
71
141
|
// ============================================================
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// 2. RUNTIME PLUGINS (loaded dynamically via import()):
|
|
79
|
-
// - Import Maps in index.html resolve "react" → /vendor/react.js
|
|
80
|
-
// - Vendor bundles built by vite.config.vendor.ts
|
|
81
|
-
// - dedupe can't help here since plugins load AFTER build
|
|
82
|
-
//
|
|
83
|
-
// Both mechanisms ensure all code uses the same React instance.
|
|
142
|
+
// - BUNDLED plugins (core/* and plugins/*): `resolve.dedupe` forces one
|
|
143
|
+
// React copy at build time when all imports are visible.
|
|
144
|
+
// - RUNTIME (installed) plugins: Module Federation's share scope (see the
|
|
145
|
+
// `federation()` plugin above) hands them the host's React instance at
|
|
146
|
+
// load time.
|
|
84
147
|
// ============================================================
|
|
85
148
|
|
|
86
149
|
// Pre-bundle React deps for faster dev server startup (dev mode only)
|
|
@@ -95,9 +158,8 @@ export default defineConfig(() => {
|
|
|
95
158
|
// @checkstack/ui's node_modules under bun's isolated store and are not
|
|
96
159
|
// resolvable as bare specifiers from this app root, so listing them
|
|
97
160
|
// errors ("Failed to resolve dependency ... present in
|
|
98
|
-
// optimizeDeps.include").
|
|
99
|
-
//
|
|
100
|
-
// handled by the `vscode` resolve.alias below.
|
|
161
|
+
// optimizeDeps.include"). The `require("vscode")` CJS interop is handled
|
|
162
|
+
// by the `vscode` resolve.alias below.
|
|
101
163
|
],
|
|
102
164
|
},
|
|
103
165
|
build: {
|
|
@@ -105,59 +167,11 @@ export default defineConfig(() => {
|
|
|
105
167
|
target: "esnext",
|
|
106
168
|
// Generate sourcemaps for production debugging
|
|
107
169
|
sourcemap: true,
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Split heavy / stable vendor code into dedicated chunks so the
|
|
114
|
-
// initial (login) load stays small and chunks cache independently.
|
|
115
|
-
// This complements the `React.lazy(CodeEditor)` split: it does not by
|
|
116
|
-
// itself keep Monaco off the login page (that's the lazy boundary in
|
|
117
|
-
// CodeEditor.tsx), but it guarantees the whole `@codingame/*` /
|
|
118
|
-
// monaco stack lands in ONE chunk that is only fetched when an editor
|
|
119
|
-
// mounts, rather than smeared across many shared chunks.
|
|
120
|
-
manualChunks(id) {
|
|
121
|
-
if (!id.includes("node_modules")) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
// NB: do NOT try to hand-group lucide icon modules here. The same
|
|
125
|
-
// per-icon modules are reached BOTH statically (`import { Plus }
|
|
126
|
-
// from "lucide-react"` across the app, which must stay eager) and
|
|
127
|
-
// dynamically (DynamicIcon's lazy icon registry). A manualChunk
|
|
128
|
-
// keys off module id only, so it can't tell the two apart and ends
|
|
129
|
-
// up pulling the whole icon set into the eager graph. The lazy
|
|
130
|
-
// boundary for the data-driven icon set lives in DynamicIcon /
|
|
131
|
-
// iconRegistry instead (see core/ui).
|
|
132
|
-
// NOTE: we deliberately do NOT hand-group the Monaco / VS Code
|
|
133
|
-
// editor stack here. Rollup's natural code-splitting already
|
|
134
|
-
// isolates it: every `@codingame/*` / `@typefox/*` /
|
|
135
|
-
// monaco-languageclient module is reachable only through the lazy
|
|
136
|
-
// `CodeEditor` / `validateScripts` boundaries (see CodeEditor.tsx),
|
|
137
|
-
// so it lands in dynamically-imported chunks that the initial
|
|
138
|
-
// (login) load never fetches. A manual `monaco` chunk is actively
|
|
139
|
-
// harmful: a tiny `@codingame/*` module is pulled in EAGERLY as a
|
|
140
|
-
// transitive dep of non-editor code, and folding it into one big
|
|
141
|
-
// chunk with the lazy editor body makes the entire ~10 MB chunk a
|
|
142
|
-
// static dependency of the entry, re-shipping Monaco to the login
|
|
143
|
-
// page. Leaving it to natural splitting keeps the heavy editor body
|
|
144
|
-
// lazy while that tiny eager stub stays inlined where it belongs.
|
|
145
|
-
|
|
146
|
-
// The React runtime: one stable chunk shared across the whole app.
|
|
147
|
-
// `dedupe` above already guarantees a single copy; this only
|
|
148
|
-
// controls which output file it lands in.
|
|
149
|
-
if (
|
|
150
|
-
id.includes("/react/") ||
|
|
151
|
-
id.includes("/react-dom/") ||
|
|
152
|
-
id.includes("/react-router") ||
|
|
153
|
-
id.includes("/scheduler/")
|
|
154
|
-
) {
|
|
155
|
-
return "react-vendor";
|
|
156
|
-
}
|
|
157
|
-
return;
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
},
|
|
170
|
+
// Monaco stays off the initial load via the `React.lazy(CodeEditor)`
|
|
171
|
+
// boundary (see CodeEditor.tsx) — verified preserved under Module
|
|
172
|
+
// Federation's automatic code-splitting. We do NOT hand-group chunks:
|
|
173
|
+
// `@module-federation/vite` owns code-splitting and ignores
|
|
174
|
+
// `manualChunks`.
|
|
161
175
|
},
|
|
162
176
|
resolve: {
|
|
163
177
|
// Force all monorepo packages to use the same React copy at build time.
|
|
@@ -172,10 +186,10 @@ export default defineConfig(() => {
|
|
|
172
186
|
],
|
|
173
187
|
alias: {
|
|
174
188
|
"@": path.resolve(__dirname, "./src"),
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
189
|
+
// Alias the `vscode` npm-alias to its real package dir so @codingame's
|
|
190
|
+
// CJS `require("vscode")` resolves under bun's isolated store instead
|
|
191
|
+
// of leaking a runtime require. Resolved by monacoViteConfig above.
|
|
192
|
+
...monaco.resolve.alias,
|
|
179
193
|
},
|
|
180
194
|
},
|
|
181
195
|
};
|
package/vite.config.vendor.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from "vite";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Vite config for building vendor bundles as ESM.
|
|
6
|
-
* These bundles are served via Import Maps so runtime plugins
|
|
7
|
-
* can use standard `import React from "react"` syntax.
|
|
8
|
-
*
|
|
9
|
-
* We point directly to node_modules - no custom entry files needed.
|
|
10
|
-
*/
|
|
11
|
-
export default defineConfig({
|
|
12
|
-
// Don't copy public/ contents — the main build handles that
|
|
13
|
-
publicDir: false,
|
|
14
|
-
build: {
|
|
15
|
-
outDir: "dist/vendor",
|
|
16
|
-
emptyOutDir: true,
|
|
17
|
-
lib: {
|
|
18
|
-
formats: ["es"],
|
|
19
|
-
// Point directly to node_modules packages
|
|
20
|
-
entry: {
|
|
21
|
-
react: path.resolve(__dirname, "node_modules/react/index.js"),
|
|
22
|
-
"react-dom": path.resolve(__dirname, "node_modules/react-dom/index.js"),
|
|
23
|
-
"react-dom-client": path.resolve(
|
|
24
|
-
__dirname,
|
|
25
|
-
"node_modules/react-dom/client.js"
|
|
26
|
-
),
|
|
27
|
-
"react-router-dom": path.resolve(
|
|
28
|
-
__dirname,
|
|
29
|
-
"node_modules/react-router-dom/dist/index.js"
|
|
30
|
-
),
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
rollupOptions: {
|
|
34
|
-
output: {
|
|
35
|
-
entryFileNames: "[name].js",
|
|
36
|
-
chunkFileNames: "[name]-[hash].js",
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
minify: false,
|
|
40
|
-
sourcemap: true,
|
|
41
|
-
},
|
|
42
|
-
});
|