@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 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
- <!-- Import Map for runtime plugins to share React with host app -->
10
- <!-- Runtime plugins use standard `import React from "react"` and browser resolves via this map -->
11
- <script type="importmap">
12
- {
13
- "imports": {
14
- "react": "/vendor/react.js",
15
- "react-dom": "/vendor/react-dom.js",
16
- "react-dom/client": "/vendor/react-dom-client.js",
17
- "react-router-dom": "/vendor/react-router-dom.js"
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.7.1",
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:vendor": "vite build --config vite.config.vendor.ts",
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.0",
25
- "@checkstack/announcement-frontend": "0.4.0",
26
- "@checkstack/auth-frontend": "0.7.0",
27
- "@checkstack/catalog-frontend": "0.11.0",
28
- "@checkstack/command-frontend": "0.3.0",
29
- "@checkstack/common": "0.13.0",
30
- "@checkstack/dependency-frontend": "0.5.0",
31
- "@checkstack/frontend-api": "0.7.0",
32
- "@checkstack/signal-common": "0.2.6",
33
- "@checkstack/signal-frontend": "0.2.0",
34
- "@checkstack/ui": "1.13.0",
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.4.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
- // Note: We don't mock @checkstack/frontend-api module-wide here because
13
- // it causes test isolation issues with other tests that use the real pluginRegistry.
14
- // Instead, we just verify behavior based on the function's outputs.
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. The `typeof url === "string"` guard means a stray non-string
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 these overrides on
34
- // `global` leaks into other files - e.g. the real-HTTP backend auth tests would
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("should discover and register local and remote plugins", async () => {
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
- // With eager loading, modules are already resolved objects, not async functions
67
- const mockModules = {
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
- // We also need to mock dynamic import() for remote plugins
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
- // Verify plugins are registered
81
- const registeredPlugins = pluginRegistry.getPlugins();
82
- expect(registeredPlugins.some((p) => p.metadata.pluginId === "local")).toBe(
83
- true
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
- // Verify CSS loading was attempted
87
- expect(mockFetch).toHaveBeenCalledWith(
88
- "/assets/plugins/remote-plugin/index.css",
89
- expect.objectContaining({ method: "HEAD" })
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
  });
@@ -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 with eager loading - already loaded)
49
- const entries = Object.entries(modules);
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
- continue;
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: Remote plugins (runtime)
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 (!registeredNames.has(plugin.name)) {
77
- console.log(`🔌 Attempting to load remote plugin: ${plugin.name}`);
78
- try {
79
- // 1. Load CSS if it exists
80
- const remoteCssUrl = `/assets/plugins/${plugin.name}/index.css`;
81
- try {
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 at runtime (for dynamic installation).
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 pluginId - The frontend plugin ID (e.g., "my-plugin-frontend")
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(pluginId: string): Promise<void> {
146
- console.log(`🔌 Loading single plugin: ${pluginId}`);
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
- // 1. Load CSS if it exists
156
- const remoteCssUrl = `/assets/plugins/${pluginId}/index.css`;
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 ${pluginId}:`, error);
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
- * Removes the plugin from the registry and cleans up CSS.
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., "my-plugin-frontend")
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 { createRequire } from "node:module";
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
- // Resolve the `vscode` npm-alias (= @codingame/monaco-vscode-extension-api) to
10
- // an absolute path so Vite can alias it. (Migration stage 1.)
11
- //
12
- // `@typefox/monaco-editor-react` and `monaco-languageclient` declare
13
- // `"vscode": "npm:@codingame/monaco-vscode-extension-api"`. Under bun's
14
- // isolated node_modules that alias is only materialized inside those two
15
- // packages — but the package that actually does `require("vscode")` at runtime
16
- // (@codingame/monaco-vscode-api) has no `vscode` in its own scope, so esbuild
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: [react()],
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. (Migration stage 1.)
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 Instance Sharing Strategy
140
+ // React instance sharing
71
141
  // ============================================================
72
- // This config works with two complementary mechanisms:
73
- //
74
- // 1. BUNDLED PLUGINS (core/* and plugins/*):
75
- // - resolve.dedupe forces Rollup to use single React copy
76
- // - Works at build time when all imports are visible
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"). Vite discovers and pre-bundles them through
99
- // the import graph instead, and the `require("vscode")` CJS interop is
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
- // Don't wipe dist/ the vendor build (build:vendor) writes to dist/vendor/
109
- // before this build runs, and we need to preserve those files
110
- emptyOutDir: false,
111
- rollupOptions: {
112
- output: {
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
- // See vscodeApiDir above: alias the `vscode` npm-alias to its real
176
- // package dir so @codingame's CJS `require("vscode")` resolves under
177
- // bun's isolated store instead of leaking a runtime require.
178
- vscode: vscodeApiDir,
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
  };
@@ -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
- });