@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 +57 -0
- package/index.html +27 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/public/.gitkeep +0 -0
- package/src/App.tsx +181 -0
- package/src/apis/fetch-api.ts +27 -0
- package/src/apis/logger-api.ts +16 -0
- package/src/apis/rpc-api.ts +33 -0
- package/src/hooks/usePluginLifecycle.ts +46 -0
- package/src/index.css +35 -0
- package/src/main.tsx +17 -0
- package/src/plugin-loader.test.ts +74 -0
- package/src/plugin-loader.ts +209 -0
- package/src/plugin-registry.test.ts +108 -0
- package/tailwind.config.js +80 -0
- package/tsconfig.json +6 -0
- package/vite.config.ts +63 -0
- package/vite.config.vendor.ts +40 -0
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
|
+
}
|
package/public/.gitkeep
ADDED
|
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
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
|
+
});
|