@checkmate-monitor/frontend 0.1.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 ADDED
@@ -0,0 +1,57 @@
1
+ # @checkmate-monitor/frontend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
8
+
9
+ ## New Packages
10
+
11
+ - **@checkmate-monitor/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
12
+ - **@checkmate-monitor/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
13
+ - **@checkmate-monitor/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
14
+
15
+ ## Changes
16
+
17
+ - **@checkmate-monitor/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
18
+ - **@checkmate-monitor/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
19
+
20
+ ## Usage
21
+
22
+ Backend plugins can emit signals:
23
+
24
+ ```typescript
25
+ import { coreServices } from "@checkmate-monitor/backend-api";
26
+ import { NOTIFICATION_RECEIVED } from "@checkmate-monitor/notification-common";
27
+
28
+ const signalService = context.signalService;
29
+ await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
30
+ ```
31
+
32
+ Frontend components subscribe to signals:
33
+
34
+ ```tsx
35
+ import { useSignal } from "@checkmate-monitor/signal-frontend";
36
+ import { NOTIFICATION_RECEIVED } from "@checkmate-monitor/notification-common";
37
+
38
+ useSignal(NOTIFICATION_RECEIVED, (payload) => {
39
+ // Handle realtime notification
40
+ });
41
+ ```
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [eff5b4e]
46
+ - Updated dependencies [ffc28f6]
47
+ - Updated dependencies [32f2535]
48
+ - Updated dependencies [b55fae6]
49
+ - Updated dependencies [b354ab3]
50
+ - @checkmate-monitor/ui@0.1.0
51
+ - @checkmate-monitor/common@0.1.0
52
+ - @checkmate-monitor/auth-frontend@0.1.0
53
+ - @checkmate-monitor/signal-common@0.1.0
54
+ - @checkmate-monitor/signal-frontend@0.1.0
55
+ - @checkmate-monitor/catalog-frontend@0.0.2
56
+ - @checkmate-monitor/command-frontend@0.0.2
57
+ - @checkmate-monitor/frontend-api@0.0.2
package/index.html ADDED
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Checkmate</title>
8
+ <!-- Import Map for runtime plugins to share React with host app -->
9
+ <!-- Runtime plugins use standard `import React from "react"` and browser resolves via this map -->
10
+ <script type="importmap">
11
+ {
12
+ "imports": {
13
+ "react": "/vendor/react.js",
14
+ "react-dom": "/vendor/react-dom.js",
15
+ "react-dom/client": "/vendor/react-dom-client.js",
16
+ "react-router-dom": "/vendor/react-router-dom.js"
17
+ }
18
+ }
19
+ </script>
20
+ </head>
21
+
22
+ <body>
23
+ <div id="root"></div>
24
+ <script type="module" src="/src/main.tsx"></script>
25
+ </body>
26
+
27
+ </html>
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@checkmate-monitor/frontend",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build:vendor": "vite build --config vite.config.vendor.ts",
8
+ "build": "bun run build:vendor && vite build",
9
+ "typecheck": "tsc --noEmit",
10
+ "lint": "bun run lint:code",
11
+ "preview": "vite preview",
12
+ "lint:code": "eslint . --max-warnings 0"
13
+ },
14
+ "dependencies": {
15
+ "@checkmate-monitor/auth-frontend": "workspace:*",
16
+ "@checkmate-monitor/catalog-frontend": "workspace:*",
17
+ "@checkmate-monitor/command-frontend": "workspace:*",
18
+ "@checkmate-monitor/common": "workspace:*",
19
+ "@checkmate-monitor/frontend-api": "workspace:*",
20
+ "@checkmate-monitor/signal-common": "workspace:*",
21
+ "@checkmate-monitor/signal-frontend": "workspace:*",
22
+ "@checkmate-monitor/ui": "workspace:*",
23
+ "@orpc/client": "^1.13.2",
24
+ "better-auth": "^1.1.8",
25
+ "class-variance-authority": "^0.7.0",
26
+ "clsx": "^2.1.0",
27
+ "lucide-react": "^0.344.0",
28
+ "react": "^18.2.0",
29
+ "react-dom": "^18.2.0",
30
+ "react-router-dom": "^6.22.0",
31
+ "tailwind-merge": "^2.2.0"
32
+ },
33
+ "devDependencies": {
34
+ "@checkmate-monitor/scripts": "workspace:*",
35
+ "@checkmate-monitor/tsconfig": "workspace:*",
36
+ "@types/react": "^18.2.64",
37
+ "@types/react-dom": "^18.2.21",
38
+ "@vitejs/plugin-react": "^4.2.1",
39
+ "autoprefixer": "^10.4.18",
40
+ "postcss": "^8.4.35",
41
+ "tailwindcss": "^3.4.1",
42
+ "tailwindcss-animate": "^1.0.7",
43
+ "vite": "^5.1.6"
44
+ }
45
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
File without changes
package/src/App.tsx ADDED
@@ -0,0 +1,181 @@
1
+ import { useMemo } from "react";
2
+ import {
3
+ BrowserRouter,
4
+ Routes,
5
+ Route,
6
+ Link,
7
+ useNavigate,
8
+ } from "react-router-dom";
9
+ import {
10
+ ApiProvider,
11
+ ApiRegistryBuilder,
12
+ loggerApiRef,
13
+ permissionApiRef,
14
+ fetchApiRef,
15
+ rpcApiRef,
16
+ useApi,
17
+ ExtensionSlot,
18
+ pluginRegistry,
19
+ DashboardSlot,
20
+ NavbarSlot,
21
+ NavbarMainSlot,
22
+ } from "@checkmate-monitor/frontend-api";
23
+ import { ConsoleLoggerApi } from "./apis/logger-api";
24
+ import { CoreFetchApi } from "./apis/fetch-api";
25
+ import { CoreRpcApi } from "./apis/rpc-api";
26
+ import {
27
+ PermissionDenied,
28
+ LoadingSpinner,
29
+ ToastProvider,
30
+ AmbientBackground,
31
+ } from "@checkmate-monitor/ui";
32
+ import { SignalProvider } from "@checkmate-monitor/signal-frontend";
33
+ import { usePluginLifecycle } from "./hooks/usePluginLifecycle";
34
+ import {
35
+ useCommands,
36
+ useGlobalShortcuts,
37
+ } from "@checkmate-monitor/command-frontend";
38
+
39
+ /**
40
+ * Component that registers global keyboard shortcuts for all commands.
41
+ * Uses react-router's navigate for SPA navigation.
42
+ */
43
+ function GlobalShortcuts() {
44
+ const { commands } = useCommands();
45
+ const navigate = useNavigate();
46
+
47
+ // Pass "*" as permission since backend already filters by permission
48
+ useGlobalShortcuts(commands, navigate, ["*"]);
49
+
50
+ // This component renders nothing - it only registers event listeners
51
+ return <></>;
52
+ }
53
+
54
+ const RouteGuard: React.FC<{
55
+ children: React.ReactNode;
56
+ permission?: string;
57
+ }> = ({ children, permission }) => {
58
+ const permissionApi = useApi(permissionApiRef);
59
+ const { allowed, loading } = permissionApi.usePermission(permission || "");
60
+
61
+ if (loading) {
62
+ return (
63
+ <div className="h-full flex items-center justify-center p-8">
64
+ <LoadingSpinner />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ const isAllowed = permission ? allowed : true;
70
+
71
+ if (!isAllowed) {
72
+ return <PermissionDenied />;
73
+ }
74
+
75
+ return <>{children}</>;
76
+ };
77
+
78
+ /**
79
+ * Inner component that handles plugin lifecycle and reactive routing.
80
+ * Must be inside SignalProvider to receive plugin signals.
81
+ */
82
+ function AppContent() {
83
+ // Enable dynamic plugin loading/unloading via signals
84
+ // This causes re-renders when plugins change
85
+ usePluginLifecycle();
86
+
87
+ return (
88
+ <BrowserRouter>
89
+ {/* Global keyboard shortcuts for commands */}
90
+ <GlobalShortcuts />
91
+ <AmbientBackground className="text-foreground font-sans">
92
+ <header className="p-4 bg-card/80 backdrop-blur-sm shadow-sm border-b border-border flex justify-between items-center z-50 relative">
93
+ <div className="flex items-center gap-8">
94
+ <Link to="/">
95
+ <h1 className="text-xl font-bold text-primary">Checkmate</h1>
96
+ </Link>
97
+ <nav className="hidden md:flex gap-1">
98
+ <ExtensionSlot slot={NavbarMainSlot} />
99
+ </nav>
100
+ </div>
101
+ <div className="flex gap-2">
102
+ <ExtensionSlot slot={NavbarSlot} />
103
+ </div>
104
+ </header>
105
+ <main className="p-8 max-w-7xl mx-auto">
106
+ <Routes>
107
+ <Route
108
+ path="/"
109
+ element={
110
+ <div className="space-y-6">
111
+ <ExtensionSlot slot={DashboardSlot} />
112
+ </div>
113
+ }
114
+ />
115
+ {/* Plugin Routes */}
116
+ {pluginRegistry.getAllRoutes().map((route) => (
117
+ <Route
118
+ key={route.path}
119
+ path={route.path}
120
+ element={
121
+ <RouteGuard permission={route.permission}>
122
+ {route.element}
123
+ </RouteGuard>
124
+ }
125
+ />
126
+ ))}
127
+ </Routes>
128
+ </main>
129
+ </AmbientBackground>
130
+ </BrowserRouter>
131
+ );
132
+ }
133
+
134
+ function App() {
135
+ const apiRegistry = useMemo(() => {
136
+ // Initialize API Registry with core apiRefs
137
+ const registryBuilder = new ApiRegistryBuilder()
138
+ .register(loggerApiRef, new ConsoleLoggerApi())
139
+ .register(permissionApiRef, {
140
+ usePermission: () => ({ loading: false, allowed: true }), // Default to allow all if no auth plugin present
141
+ useResourcePermission: () => ({ loading: false, allowed: true }),
142
+ useManagePermission: () => ({ loading: false, allowed: true }),
143
+ })
144
+ .registerFactory(fetchApiRef, (_registry) => {
145
+ return new CoreFetchApi();
146
+ })
147
+ .registerFactory(rpcApiRef, (_registry) => {
148
+ return new CoreRpcApi();
149
+ });
150
+
151
+ // Register API factories from plugins
152
+ const plugins = pluginRegistry.getPlugins();
153
+ for (const plugin of plugins) {
154
+ if (plugin.apis) {
155
+ for (const api of plugin.apis) {
156
+ registryBuilder.registerFactory(api.ref, (registry) => {
157
+ // Adapt registry map to dependency getter
158
+ const deps = {
159
+ get: <T,>(ref: { id: string }) => registry.get(ref.id) as T,
160
+ };
161
+ return api.factory(deps);
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ return registryBuilder.build();
168
+ }, []);
169
+
170
+ return (
171
+ <ApiProvider registry={apiRegistry}>
172
+ <SignalProvider>
173
+ <ToastProvider>
174
+ <AppContent />
175
+ </ToastProvider>
176
+ </SignalProvider>
177
+ </ApiProvider>
178
+ );
179
+ }
180
+
181
+ export default App;
@@ -0,0 +1,27 @@
1
+ import { FetchApi } from "@checkmate-monitor/frontend-api";
2
+
3
+ export class CoreFetchApi implements FetchApi {
4
+ constructor() {}
5
+
6
+ async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
7
+ const headers = new Headers(init?.headers);
8
+
9
+ return fetch(input, {
10
+ ...init,
11
+ headers,
12
+ credentials: "include",
13
+ });
14
+ }
15
+
16
+ forPlugin(pluginId: string): {
17
+ fetch(path: string, init?: RequestInit): Promise<Response>;
18
+ } {
19
+ return {
20
+ fetch: (path: string, init?: RequestInit) => {
21
+ const baseUrl =
22
+ import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
23
+ return this.fetch(`${baseUrl}/api/${pluginId}${path}`, init);
24
+ },
25
+ };
26
+ }
27
+ }
@@ -0,0 +1,16 @@
1
+ import { LoggerApi } from "@checkmate-monitor/frontend-api";
2
+
3
+ export class ConsoleLoggerApi implements LoggerApi {
4
+ info(message: string, ...args: unknown[]) {
5
+ console.info(`[INFO] ${message}`, ...args);
6
+ }
7
+ error(message: string, ...args: unknown[]) {
8
+ console.error(`[ERROR] ${message}`, ...args);
9
+ }
10
+ warn(message: string, ...args: unknown[]) {
11
+ console.warn(`[WARN] ${message}`, ...args);
12
+ }
13
+ debug(message: string, ...args: unknown[]) {
14
+ console.debug(`[DEBUG] ${message}`, ...args);
15
+ }
16
+ }
@@ -0,0 +1,33 @@
1
+ import { RpcApi } from "@checkmate-monitor/frontend-api";
2
+ import { createORPCClient } from "@orpc/client";
3
+ import { RPCLink } from "@orpc/client/fetch";
4
+ import type { ClientDefinition, InferClient } from "@checkmate-monitor/common";
5
+
6
+ export class CoreRpcApi implements RpcApi {
7
+ public client: unknown;
8
+ private pluginClientCache: Map<string, unknown> = new Map();
9
+
10
+ constructor() {
11
+ const baseUrl =
12
+ import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
13
+
14
+ const link = new RPCLink({
15
+ url: `${baseUrl}/api`,
16
+ fetch: (input: RequestInfo | URL, init?: RequestInit) =>
17
+ fetch(input, { ...init, credentials: "include" }),
18
+ });
19
+
20
+ this.client = createORPCClient(link);
21
+ }
22
+
23
+ forPlugin<T extends ClientDefinition>(def: T): InferClient<T> {
24
+ const { pluginId } = def;
25
+ if (!this.pluginClientCache.has(pluginId)) {
26
+ this.pluginClientCache.set(
27
+ pluginId,
28
+ (this.client as Record<string, unknown>)[pluginId]
29
+ );
30
+ }
31
+ return this.pluginClientCache.get(pluginId) as InferClient<T>;
32
+ }
33
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useReducer } from "react";
2
+ import { useSignal } from "@checkmate-monitor/signal-frontend";
3
+ import {
4
+ PLUGIN_INSTALLED,
5
+ PLUGIN_DEREGISTERED,
6
+ } from "@checkmate-monitor/signal-common";
7
+ import { pluginRegistry } from "@checkmate-monitor/frontend-api";
8
+ import { loadSinglePlugin, unloadPlugin } from "../plugin-loader";
9
+
10
+ /**
11
+ * Hook that listens to plugin lifecycle signals and dynamically loads/unloads plugins.
12
+ * Must be used within SignalProvider context.
13
+ *
14
+ * Returns the current registry version to trigger re-renders when plugins change.
15
+ */
16
+ export function usePluginLifecycle(): { version: number } {
17
+ // Force re-render when registry changes
18
+ const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
19
+
20
+ // Subscribe to registry changes
21
+ useEffect(() => {
22
+ return pluginRegistry.subscribe(forceUpdate);
23
+ }, []);
24
+
25
+ // Listen for plugin installation signal
26
+ useSignal(PLUGIN_INSTALLED, async ({ pluginId }) => {
27
+ console.log(`📥 Received PLUGIN_INSTALLED signal for: ${pluginId}`);
28
+
29
+ // Only load if not already registered
30
+ if (!pluginRegistry.hasPlugin(pluginId)) {
31
+ try {
32
+ await loadSinglePlugin(pluginId);
33
+ } catch (error) {
34
+ console.error(`❌ Failed to load plugin ${pluginId}:`, error);
35
+ }
36
+ }
37
+ });
38
+
39
+ // Listen for plugin deregistration signal
40
+ useSignal(PLUGIN_DEREGISTERED, ({ pluginId }) => {
41
+ console.log(`📥 Received PLUGIN_DEREGISTERED signal for: ${pluginId}`);
42
+ unloadPlugin(pluginId);
43
+ });
44
+
45
+ return { version: pluginRegistry.getVersion() };
46
+ }
package/src/index.css ADDED
@@ -0,0 +1,35 @@
1
+ @import "@checkmate-monitor/ui/src/themes.css";
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ /* Prism JSON highlighting */
8
+ .token.property {
9
+ color: #4f46e5;
10
+ }
11
+
12
+ .token.string {
13
+ color: #059669;
14
+ }
15
+
16
+ .token.number {
17
+ color: #d97706;
18
+ }
19
+
20
+ .token.boolean {
21
+ color: #2563eb;
22
+ }
23
+
24
+ .token.punctuation {
25
+ color: #4b5563;
26
+ }
27
+
28
+ .token.operator {
29
+ color: #4b5563;
30
+ }
31
+
32
+ .editor pre,
33
+ .editor code {
34
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
35
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App.tsx";
4
+ import "./index.css";
5
+ import { loadPlugins } from "./plugin-loader.ts";
6
+ import { ThemeProvider } from "@checkmate-monitor/ui";
7
+
8
+ // Initialize plugins before rendering
9
+ await loadPlugins();
10
+
11
+ ReactDOM.createRoot(document.querySelector("#root")!).render(
12
+ <React.StrictMode>
13
+ <ThemeProvider defaultTheme="light" storageKey="checkmate-ui-theme">
14
+ <App />
15
+ </ThemeProvider>
16
+ </React.StrictMode>
17
+ );
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { loadPlugins } from "./plugin-loader";
3
+
4
+ // Note: We don't mock @checkmate-monitor/frontend-api module-wide here because
5
+ // it causes test isolation issues with other tests that use the real pluginRegistry.
6
+ // Instead, we just verify behavior based on the function's outputs.
7
+
8
+ // Mock fetch
9
+ const mockFetch = mock((url: string) => {
10
+ if (url === "/api/plugins") {
11
+ return Promise.resolve({
12
+ ok: true,
13
+ json: () => Promise.resolve([{ name: "remote-plugin", path: "/dist" }]),
14
+ } as unknown as Response);
15
+ }
16
+ // Mock HEAD request for CSS
17
+ if (url.endsWith(".css")) {
18
+ return Promise.resolve({ ok: true } as unknown as Response);
19
+ }
20
+ return Promise.resolve({ ok: false } as unknown as Response);
21
+ });
22
+
23
+ (global as any).fetch = mockFetch;
24
+
25
+ // Mock document
26
+ global.document = {
27
+ createElement: mock(() => ({})),
28
+ head: {
29
+ append: mock(),
30
+ },
31
+ } as unknown as Document;
32
+
33
+ describe("frontend loadPlugins", () => {
34
+ beforeEach(() => {
35
+ mockFetch.mockClear();
36
+ });
37
+
38
+ it("should discover and register local and remote plugins", async () => {
39
+ // Import the real pluginRegistry to verify registration
40
+ const { pluginRegistry } = await import("@checkmate-monitor/frontend-api");
41
+
42
+ // Reset registry before test
43
+ pluginRegistry.reset();
44
+
45
+ // With eager loading, modules are already resolved objects, not async functions
46
+ const mockModules = {
47
+ "../../../plugins/local-frontend/src/index.tsx": {
48
+ default: { metadata: { pluginId: "local" }, extensions: [] },
49
+ },
50
+ };
51
+
52
+ // We also need to mock dynamic import() for remote plugins
53
+ mock.module("/assets/plugins/remote-plugin/index.js", () => ({
54
+ default: { metadata: { pluginId: "remote-plugin" }, extensions: [] },
55
+ }));
56
+
57
+ await loadPlugins(mockModules);
58
+
59
+ // Verify plugins are registered
60
+ const registeredPlugins = pluginRegistry.getPlugins();
61
+ expect(registeredPlugins.some((p) => p.metadata.pluginId === "local")).toBe(
62
+ true
63
+ );
64
+
65
+ // Verify CSS loading was attempted
66
+ expect(mockFetch).toHaveBeenCalledWith(
67
+ "/assets/plugins/remote-plugin/index.css",
68
+ expect.objectContaining({ method: "HEAD" })
69
+ );
70
+
71
+ // Clean up
72
+ pluginRegistry.reset();
73
+ });
74
+ });
@@ -0,0 +1,209 @@
1
+ import {
2
+ FrontendPlugin,
3
+ pluginRegistry,
4
+ } from "@checkmate-monitor/frontend-api";
5
+
6
+ export async function loadPlugins(overrideModules?: Record<string, unknown>) {
7
+ console.log("🔌 discovering plugins...");
8
+
9
+ // 1. Fetch enabled plugins from backend
10
+ try {
11
+ const response = await fetch("/api/plugins");
12
+ if (!response.ok) {
13
+ console.error("Failed to fetch enabled plugins:", response.statusText);
14
+ return;
15
+ }
16
+ const enabledPlugins: { name: string; path: string }[] =
17
+ await response.json();
18
+
19
+ // 2. Get all available local plugins using eager loading
20
+ // This avoids dynamic import issues in production builds
21
+ // Load from both core/ (essential) and plugins/ (providers)
22
+ let modules: Record<string, unknown>;
23
+ if (overrideModules) {
24
+ modules = overrideModules;
25
+ } else {
26
+ const coreModules =
27
+ // @ts-expect-error - Vite specific property
28
+ import.meta.glob("../../*-frontend/src/index.tsx", { eager: true });
29
+
30
+ const pluginModules =
31
+ // @ts-expect-error - Vite specific property
32
+ import.meta.glob("../../../plugins/*-frontend/src/index.tsx", {
33
+ eager: true,
34
+ });
35
+
36
+ modules = { ...coreModules, ...pluginModules };
37
+ }
38
+
39
+ console.log(
40
+ `🔌 Found ${
41
+ Object.keys(modules).length
42
+ } locally available frontend plugins.`
43
+ );
44
+
45
+ // 3. Load and register enabled plugins
46
+ const registeredNames = new Set<string>();
47
+
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) {
52
+ 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
+
61
+ if (pluginExport) {
62
+ const pluginId = pluginExport.metadata.pluginId;
63
+ console.log(`🔌 Registering local plugin: ${pluginId}`);
64
+ pluginRegistry.register(pluginExport);
65
+ registeredNames.add(pluginId);
66
+ } else {
67
+ console.warn(`⚠️ No valid FrontendPlugin export found in ${path}`);
68
+ }
69
+ } catch (error) {
70
+ console.error(`❌ Failed to load local plugin from ${path}`, error);
71
+ }
72
+ }
73
+
74
+ // Phase 2: Remote plugins (runtime)
75
+ 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
+ );
117
+ }
118
+ }
119
+ }
120
+ } catch (error) {
121
+ console.error("❌ Critical error loading plugins:", error);
122
+ }
123
+ }
124
+
125
+ function isFrontendPlugin(candidate: unknown): candidate is FrontendPlugin {
126
+ if (typeof candidate !== "object" || candidate === null) return false;
127
+
128
+ const p = candidate as Record<string, unknown>;
129
+
130
+ // Check for metadata with pluginId
131
+ if (typeof p.metadata !== "object" || p.metadata === null) return false;
132
+ const metadata = p.metadata as Record<string, unknown>;
133
+ if (typeof metadata.pluginId !== "string") return false;
134
+
135
+ // Must have at least one frontend-specific property
136
+ return "extensions" in p || "routes" in p || "apis" in p;
137
+ }
138
+
139
+ /**
140
+ * Load a single plugin at runtime (for dynamic installation).
141
+ * Fetches the plugin from the backend and registers it.
142
+ *
143
+ * @param pluginId - The frontend plugin ID (e.g., "my-plugin-frontend")
144
+ */
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
+
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
+ }
186
+ } catch (error) {
187
+ console.error(`❌ Failed to load plugin ${pluginId}:`, error);
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Unload a plugin at runtime (for dynamic deregistration).
194
+ * Removes the plugin from the registry and cleans up CSS.
195
+ *
196
+ * @param pluginId - The frontend plugin ID (e.g., "my-plugin-frontend")
197
+ */
198
+ export function unloadPlugin(pluginId: string): void {
199
+ console.log(`🔌 Unloading plugin: ${pluginId}`);
200
+
201
+ // Remove from registry
202
+ 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
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { pluginRegistry, createSlot } from "@checkmate-monitor/frontend-api";
3
+ import { FrontendPlugin } from "@checkmate-monitor/frontend-api";
4
+ import { createRoutes } from "@checkmate-monitor/common";
5
+ import React from "react";
6
+
7
+ // Create test slots
8
+ const slotA = createSlot("slot-a");
9
+ const sharedSlot = createSlot("shared-slot");
10
+
11
+ // Create test routes using the new pattern
12
+ const testRoutes = createRoutes("test", {
13
+ home: "/",
14
+ config: "/config",
15
+ });
16
+
17
+ const pluginBRoutes = createRoutes("plugin-b", {
18
+ home: "/",
19
+ });
20
+
21
+ describe("PluginRegistry", () => {
22
+ beforeEach(() => {
23
+ // Clear the registry before each test.
24
+ // Since it's a singleton, we need to manually clear its private state if possible,
25
+ // or just accept that it's additive if we don't want to refactor.
26
+ // Let's refactor the registry to be a class we can instantiate for testing.
27
+ // For now, I'll just check if I can clear it.
28
+ pluginRegistry.reset();
29
+ });
30
+
31
+ const mockPlugin: FrontendPlugin = {
32
+ metadata: { pluginId: "test" },
33
+ extensions: [
34
+ {
35
+ id: "extension-1",
36
+ slot: slotA,
37
+ component: () => React.createElement("div", null, "Hello"),
38
+ },
39
+ ],
40
+ routes: [
41
+ {
42
+ route: testRoutes.routes.home,
43
+ element: React.createElement("div", null, "Test Route"),
44
+ },
45
+ ],
46
+ };
47
+
48
+ it("should register a plugin and its extensions", () => {
49
+ pluginRegistry.register(mockPlugin);
50
+
51
+ expect(pluginRegistry.getPlugins()).toContain(mockPlugin);
52
+ const extensions = pluginRegistry.getExtensions(slotA.id);
53
+ expect(extensions).toHaveLength(1);
54
+ expect(extensions[0].id).toBe("extension-1");
55
+ });
56
+
57
+ it("should return empty array for unknown slotId", () => {
58
+ const extensions = pluginRegistry.getExtensions("unknown-slot");
59
+ expect(extensions).toEqual([]);
60
+ });
61
+
62
+ it("should aggregate all routes from registered plugins", () => {
63
+ const pluginB: FrontendPlugin = {
64
+ metadata: { pluginId: "plugin-b" },
65
+ routes: [{ route: pluginBRoutes.routes.home }],
66
+ };
67
+
68
+ pluginRegistry.register(mockPlugin);
69
+ pluginRegistry.register(pluginB);
70
+
71
+ const routes = pluginRegistry.getAllRoutes();
72
+ expect(routes).toHaveLength(2);
73
+ expect(routes.map((r) => r.path)).toContain("/test/");
74
+ expect(routes.map((r) => r.path)).toContain("/plugin-b/");
75
+ });
76
+
77
+ it("should allow multiple plugins to register extensions for the same slot", () => {
78
+ const pluginA: FrontendPlugin = {
79
+ metadata: { pluginId: "plugin-a" },
80
+ extensions: [
81
+ {
82
+ id: "ext-a",
83
+ slot: sharedSlot,
84
+ component: () => React.createElement("div", null, "A"),
85
+ },
86
+ ],
87
+ };
88
+
89
+ const pluginB: FrontendPlugin = {
90
+ metadata: { pluginId: "plugin-b" },
91
+ extensions: [
92
+ {
93
+ id: "ext-b",
94
+ slot: sharedSlot,
95
+ component: () => React.createElement("div", null, "B"),
96
+ },
97
+ ],
98
+ };
99
+
100
+ pluginRegistry.register(pluginA);
101
+ pluginRegistry.register(pluginB);
102
+
103
+ const extensions = pluginRegistry.getExtensions(sharedSlot.id);
104
+ expect(extensions).toHaveLength(2);
105
+ expect(extensions.map((e) => e.id)).toContain("ext-a");
106
+ expect(extensions.map((e) => e.id)).toContain("ext-b");
107
+ });
108
+ });
@@ -0,0 +1,80 @@
1
+ import tailwindcssAnimate from "tailwindcss-animate";
2
+
3
+ /** @type {import('tailwindcss').Config} */
4
+ export default {
5
+ darkMode: ["class"],
6
+ content: [
7
+ "./index.html",
8
+ "./src/**/*.{js,ts,jsx,tsx}",
9
+ // Core frontend plugins (siblings in core/)
10
+ "../*-frontend/src/**/*.{js,ts,jsx,tsx}",
11
+ // External plugins
12
+ "../../plugins/*-frontend/src/**/*.{js,ts,jsx,tsx}",
13
+ // Shared UI library
14
+ "../ui/src/**/*.{js,ts,jsx,tsx}",
15
+ ],
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ border: "hsl(var(--border))",
20
+ input: "hsl(var(--input))",
21
+ ring: "hsl(var(--ring))",
22
+ background: "hsl(var(--background))",
23
+ foreground: "hsl(var(--foreground))",
24
+ primary: {
25
+ DEFAULT: "hsl(var(--primary))",
26
+ foreground: "hsl(var(--primary-foreground))",
27
+ },
28
+ secondary: {
29
+ DEFAULT: "hsl(var(--secondary))",
30
+ foreground: "hsl(var(--secondary-foreground))",
31
+ },
32
+ destructive: {
33
+ DEFAULT: "hsl(var(--destructive))",
34
+ foreground: "hsl(var(--destructive-foreground))",
35
+ },
36
+ muted: {
37
+ DEFAULT: "hsl(var(--muted))",
38
+ foreground: "hsl(var(--muted-foreground))",
39
+ },
40
+ accent: {
41
+ DEFAULT: "hsl(var(--accent))",
42
+ foreground: "hsl(var(--accent-foreground))",
43
+ },
44
+ popover: {
45
+ DEFAULT: "hsl(var(--popover))",
46
+ foreground: "hsl(var(--popover-foreground))",
47
+ },
48
+ card: {
49
+ DEFAULT: "hsl(var(--card))",
50
+ foreground: "hsl(var(--card-foreground))",
51
+ },
52
+ chart: {
53
+ 1: "hsl(var(--chart-1))",
54
+ 2: "hsl(var(--chart-2))",
55
+ 3: "hsl(var(--chart-3))",
56
+ 4: "hsl(var(--chart-4))",
57
+ 5: "hsl(var(--chart-5))",
58
+ },
59
+ success: {
60
+ DEFAULT: "hsl(var(--success))",
61
+ foreground: "hsl(var(--success-foreground))",
62
+ },
63
+ warning: {
64
+ DEFAULT: "hsl(var(--warning))",
65
+ foreground: "hsl(var(--warning-foreground))",
66
+ },
67
+ info: {
68
+ DEFAULT: "hsl(var(--info))",
69
+ foreground: "hsl(var(--info-foreground))",
70
+ },
71
+ },
72
+ borderRadius: {
73
+ lg: "var(--radius)",
74
+ md: "calc(var(--radius) - 2px)",
75
+ sm: "calc(var(--radius) - 4px)",
76
+ },
77
+ },
78
+ },
79
+ plugins: [tailwindcssAnimate],
80
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { defineConfig, loadEnv } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "node:path";
4
+
5
+ // Monorepo root is 2 levels up from core/frontend
6
+ const monorepoRoot = path.resolve(__dirname, "../..");
7
+
8
+ // https://vitejs.dev/config/
9
+ export default defineConfig(({ mode }) => {
10
+ // Load env from monorepo root
11
+ const env = loadEnv(mode, monorepoRoot, "");
12
+ const target = env.VITE_API_BASE_URL || "http://localhost:3000";
13
+ return {
14
+ // Tell Vite to look for .env files in monorepo root
15
+ envDir: monorepoRoot,
16
+ plugins: [react()],
17
+ server: {
18
+ proxy: {
19
+ // Proxy API requests and WebSocket connections to backend
20
+ // Use regex to ensure /api-docs doesn't match (it starts with /api but isn't an API call)
21
+ "^/api/": {
22
+ target,
23
+ ws: true, // Enable WebSocket proxy
24
+ },
25
+ "/assets": target,
26
+ },
27
+ },
28
+ // ============================================================
29
+ // React Instance Sharing Strategy
30
+ // ============================================================
31
+ // This config works with two complementary mechanisms:
32
+ //
33
+ // 1. BUNDLED PLUGINS (core/* and plugins/*):
34
+ // - resolve.dedupe forces Rollup to use single React copy
35
+ // - Works at build time when all imports are visible
36
+ //
37
+ // 2. RUNTIME PLUGINS (loaded dynamically via import()):
38
+ // - Import Maps in index.html resolve "react" → /vendor/react.js
39
+ // - Vendor bundles built by vite.config.vendor.ts
40
+ // - dedupe can't help here since plugins load AFTER build
41
+ //
42
+ // Both mechanisms ensure all code uses the same React instance.
43
+ // ============================================================
44
+
45
+ // Pre-bundle React deps for faster dev server startup (dev mode only)
46
+ optimizeDeps: {
47
+ include: ["react", "react-dom", "react-router-dom"],
48
+ },
49
+ build: {
50
+ // Use esnext to support top-level await and modern ES features
51
+ target: "esnext",
52
+ },
53
+ resolve: {
54
+ // Force all monorepo packages to use the same React copy at build time.
55
+ // Without this, each workspace package can bundle its own React copy,
56
+ // causing "useContext is null" errors from context mismatch.
57
+ dedupe: ["react", "react-dom", "react-router-dom", "react/jsx-runtime"],
58
+ alias: {
59
+ "@": path.resolve(__dirname, "./src"),
60
+ },
61
+ },
62
+ };
63
+ });
@@ -0,0 +1,40 @@
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
+ build: {
13
+ outDir: "public/vendor",
14
+ emptyOutDir: true,
15
+ lib: {
16
+ formats: ["es"],
17
+ // Point directly to node_modules packages
18
+ entry: {
19
+ react: path.resolve(__dirname, "node_modules/react/index.js"),
20
+ "react-dom": path.resolve(__dirname, "node_modules/react-dom/index.js"),
21
+ "react-dom-client": path.resolve(
22
+ __dirname,
23
+ "node_modules/react-dom/client.js"
24
+ ),
25
+ "react-router-dom": path.resolve(
26
+ __dirname,
27
+ "node_modules/react-router-dom/dist/index.js"
28
+ ),
29
+ },
30
+ },
31
+ rollupOptions: {
32
+ output: {
33
+ entryFileNames: "[name].js",
34
+ chunkFileNames: "[name]-[hash].js",
35
+ },
36
+ },
37
+ minify: false,
38
+ sourcemap: true,
39
+ },
40
+ });