@checkstack/frontend 0.0.2
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 +139 -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 +216 -0
- package/src/apis/fetch-api.ts +29 -0
- package/src/apis/logger-api.ts +16 -0
- package/src/apis/rpc-api.ts +30 -0
- package/src/hooks/usePluginLifecycle.ts +46 -0
- package/src/index.css +48 -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 +62 -0
- package/vite.config.vendor.ts +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @checkstack/frontend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/auth-frontend@0.0.2
|
|
10
|
+
- @checkstack/catalog-frontend@0.0.2
|
|
11
|
+
- @checkstack/command-frontend@0.0.2
|
|
12
|
+
- @checkstack/common@0.0.2
|
|
13
|
+
- @checkstack/frontend-api@0.0.2
|
|
14
|
+
- @checkstack/signal-common@0.0.2
|
|
15
|
+
- @checkstack/signal-frontend@0.0.2
|
|
16
|
+
- @checkstack/ui@0.0.2
|
|
17
|
+
|
|
18
|
+
## 0.1.4
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- ae33df2: Move command palette from dashboard to centered navbar position
|
|
23
|
+
|
|
24
|
+
- Converted `command-frontend` into a plugin with `NavbarCenterSlot` extension
|
|
25
|
+
- Added compact `NavbarSearch` component with responsive search trigger
|
|
26
|
+
- Moved `SearchDialog` from dashboard-frontend to command-frontend
|
|
27
|
+
- Keyboard shortcut (⌘K / Ctrl+K) now works on every page
|
|
28
|
+
- Renamed navbar slots for clarity:
|
|
29
|
+
- `NavbarSlot` → `NavbarRightSlot`
|
|
30
|
+
- `NavbarMainSlot` → `NavbarLeftSlot`
|
|
31
|
+
- Added new `NavbarCenterSlot` for centered content
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [52231ef]
|
|
34
|
+
- Updated dependencies [b0124ef]
|
|
35
|
+
- Updated dependencies [54cc787]
|
|
36
|
+
- Updated dependencies [a65e002]
|
|
37
|
+
- Updated dependencies [ae33df2]
|
|
38
|
+
- Updated dependencies [a65e002]
|
|
39
|
+
- Updated dependencies [32ea706]
|
|
40
|
+
- @checkstack/auth-frontend@0.3.0
|
|
41
|
+
- @checkstack/ui@0.1.2
|
|
42
|
+
- @checkstack/catalog-frontend@0.1.0
|
|
43
|
+
- @checkstack/common@0.2.0
|
|
44
|
+
- @checkstack/command-frontend@0.1.0
|
|
45
|
+
- @checkstack/frontend-api@0.1.0
|
|
46
|
+
- @checkstack/signal-common@0.1.1
|
|
47
|
+
- @checkstack/signal-frontend@0.1.1
|
|
48
|
+
|
|
49
|
+
## 0.1.3
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- Updated dependencies [1bf71bb]
|
|
54
|
+
- @checkstack/auth-frontend@0.2.1
|
|
55
|
+
- @checkstack/catalog-frontend@0.0.5
|
|
56
|
+
|
|
57
|
+
## 0.1.2
|
|
58
|
+
|
|
59
|
+
### Patch Changes
|
|
60
|
+
|
|
61
|
+
- Updated dependencies [e26c08e]
|
|
62
|
+
- @checkstack/auth-frontend@0.2.0
|
|
63
|
+
- @checkstack/catalog-frontend@0.0.4
|
|
64
|
+
|
|
65
|
+
## 0.1.1
|
|
66
|
+
|
|
67
|
+
### Patch Changes
|
|
68
|
+
|
|
69
|
+
- 0f8cc7d: Add runtime configuration API for Docker deployments
|
|
70
|
+
|
|
71
|
+
- Backend: Add `/api/config` endpoint serving `BASE_URL` at runtime
|
|
72
|
+
- Backend: Update CORS to use `BASE_URL` and auto-allow Vite dev server
|
|
73
|
+
- Backend: `INTERNAL_URL` now defaults to `localhost:3000` (no BASE_URL fallback)
|
|
74
|
+
- Frontend API: Add `RuntimeConfigProvider` context for runtime config
|
|
75
|
+
- Frontend: Use `RuntimeConfigProvider` from `frontend-api`
|
|
76
|
+
- Auth Frontend: Add `useAuthClient()` hook using runtime config
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [0f8cc7d]
|
|
79
|
+
- @checkstack/frontend-api@0.0.3
|
|
80
|
+
- @checkstack/auth-frontend@0.1.1
|
|
81
|
+
- @checkstack/catalog-frontend@0.0.3
|
|
82
|
+
- @checkstack/command-frontend@0.0.3
|
|
83
|
+
- @checkstack/ui@0.1.1
|
|
84
|
+
|
|
85
|
+
## 0.1.0
|
|
86
|
+
|
|
87
|
+
### Minor Changes
|
|
88
|
+
|
|
89
|
+
- b55fae6: Added realtime Signal Service for backend-to-frontend push notifications via WebSockets.
|
|
90
|
+
|
|
91
|
+
## New Packages
|
|
92
|
+
|
|
93
|
+
- **@checkstack/signal-common**: Shared types including `Signal`, `SignalService`, `createSignal()`, and WebSocket protocol messages
|
|
94
|
+
- **@checkstack/signal-backend**: `SignalServiceImpl` with EventBus integration and Bun WebSocket handler using native pub/sub
|
|
95
|
+
- **@checkstack/signal-frontend**: React `SignalProvider` and `useSignal()` hook for consuming typed signals
|
|
96
|
+
|
|
97
|
+
## Changes
|
|
98
|
+
|
|
99
|
+
- **@checkstack/backend-api**: Added `coreServices.signalService` reference for plugins to emit signals
|
|
100
|
+
- **@checkstack/backend**: Integrated WebSocket server at `/api/signals/ws` with session-based authentication
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
Backend plugins can emit signals:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { coreServices } from "@checkstack/backend-api";
|
|
108
|
+
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
|
|
109
|
+
|
|
110
|
+
const signalService = context.signalService;
|
|
111
|
+
await signalService.sendToUser(NOTIFICATION_RECEIVED, userId, { ... });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Frontend components subscribe to signals:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
118
|
+
import { NOTIFICATION_RECEIVED } from "@checkstack/notification-common";
|
|
119
|
+
|
|
120
|
+
useSignal(NOTIFICATION_RECEIVED, (payload) => {
|
|
121
|
+
// Handle realtime notification
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Patch Changes
|
|
126
|
+
|
|
127
|
+
- Updated dependencies [eff5b4e]
|
|
128
|
+
- Updated dependencies [ffc28f6]
|
|
129
|
+
- Updated dependencies [32f2535]
|
|
130
|
+
- Updated dependencies [b55fae6]
|
|
131
|
+
- Updated dependencies [b354ab3]
|
|
132
|
+
- @checkstack/ui@0.1.0
|
|
133
|
+
- @checkstack/common@0.1.0
|
|
134
|
+
- @checkstack/auth-frontend@0.1.0
|
|
135
|
+
- @checkstack/signal-common@0.1.0
|
|
136
|
+
- @checkstack/signal-frontend@0.1.0
|
|
137
|
+
- @checkstack/catalog-frontend@0.0.2
|
|
138
|
+
- @checkstack/command-frontend@0.0.2
|
|
139
|
+
- @checkstack/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>Checkstack Health Monitor</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": "@checkstack/frontend",
|
|
3
|
+
"version": "0.0.2",
|
|
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
|
+
"@checkstack/auth-frontend": "workspace:*",
|
|
16
|
+
"@checkstack/catalog-frontend": "workspace:*",
|
|
17
|
+
"@checkstack/command-frontend": "workspace:*",
|
|
18
|
+
"@checkstack/common": "workspace:*",
|
|
19
|
+
"@checkstack/frontend-api": "workspace:*",
|
|
20
|
+
"@checkstack/signal-common": "workspace:*",
|
|
21
|
+
"@checkstack/signal-frontend": "workspace:*",
|
|
22
|
+
"@checkstack/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
|
+
"@checkstack/scripts": "workspace:*",
|
|
35
|
+
"@checkstack/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,216 @@
|
|
|
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
|
+
NavbarRightSlot,
|
|
21
|
+
NavbarLeftSlot,
|
|
22
|
+
NavbarCenterSlot,
|
|
23
|
+
RuntimeConfigProvider,
|
|
24
|
+
useRuntimeConfigLoading,
|
|
25
|
+
useRuntimeConfig,
|
|
26
|
+
} from "@checkstack/frontend-api";
|
|
27
|
+
import { ConsoleLoggerApi } from "./apis/logger-api";
|
|
28
|
+
import { CoreFetchApi } from "./apis/fetch-api";
|
|
29
|
+
import { CoreRpcApi } from "./apis/rpc-api";
|
|
30
|
+
import {
|
|
31
|
+
PermissionDenied,
|
|
32
|
+
LoadingSpinner,
|
|
33
|
+
ToastProvider,
|
|
34
|
+
AmbientBackground,
|
|
35
|
+
} from "@checkstack/ui";
|
|
36
|
+
import { SignalProvider } from "@checkstack/signal-frontend";
|
|
37
|
+
import { usePluginLifecycle } from "./hooks/usePluginLifecycle";
|
|
38
|
+
import {
|
|
39
|
+
useCommands,
|
|
40
|
+
useGlobalShortcuts,
|
|
41
|
+
} from "@checkstack/command-frontend";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Component that registers global keyboard shortcuts for all commands.
|
|
45
|
+
* Uses react-router's navigate for SPA navigation.
|
|
46
|
+
*/
|
|
47
|
+
function GlobalShortcuts() {
|
|
48
|
+
const { commands } = useCommands();
|
|
49
|
+
const navigate = useNavigate();
|
|
50
|
+
|
|
51
|
+
// Pass "*" as permission since backend already filters by permission
|
|
52
|
+
useGlobalShortcuts(commands, navigate, ["*"]);
|
|
53
|
+
|
|
54
|
+
// This component renders nothing - it only registers event listeners
|
|
55
|
+
return <></>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RouteGuard: React.FC<{
|
|
59
|
+
children: React.ReactNode;
|
|
60
|
+
permission?: string;
|
|
61
|
+
}> = ({ children, permission }) => {
|
|
62
|
+
const permissionApi = useApi(permissionApiRef);
|
|
63
|
+
const { allowed, loading } = permissionApi.usePermission(permission || "");
|
|
64
|
+
|
|
65
|
+
if (loading) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="h-full flex items-center justify-center p-8">
|
|
68
|
+
<LoadingSpinner />
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isAllowed = permission ? allowed : true;
|
|
74
|
+
|
|
75
|
+
if (!isAllowed) {
|
|
76
|
+
return <PermissionDenied />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return <>{children}</>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inner component that handles plugin lifecycle and reactive routing.
|
|
84
|
+
* Must be inside SignalProvider to receive plugin signals.
|
|
85
|
+
*/
|
|
86
|
+
function AppContent() {
|
|
87
|
+
// Enable dynamic plugin loading/unloading via signals
|
|
88
|
+
// This causes re-renders when plugins change
|
|
89
|
+
usePluginLifecycle();
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<BrowserRouter>
|
|
93
|
+
{/* Global keyboard shortcuts for commands */}
|
|
94
|
+
<GlobalShortcuts />
|
|
95
|
+
<AmbientBackground className="text-foreground font-sans">
|
|
96
|
+
<header className="p-4 bg-card/80 backdrop-blur-sm shadow-sm border-b border-border z-50 relative">
|
|
97
|
+
<div className="flex items-center justify-between gap-4">
|
|
98
|
+
{/* Left: Logo and main navigation */}
|
|
99
|
+
<div className="flex items-center gap-8 flex-shrink-0">
|
|
100
|
+
<Link to="/">
|
|
101
|
+
<h1 className="text-xl font-bold text-primary">Checkstack</h1>
|
|
102
|
+
</Link>
|
|
103
|
+
<nav className="hidden md:flex gap-1">
|
|
104
|
+
<ExtensionSlot slot={NavbarLeftSlot} />
|
|
105
|
+
</nav>
|
|
106
|
+
</div>
|
|
107
|
+
{/* Center: Search (flexible width, centered) */}
|
|
108
|
+
<div className="flex-1 flex justify-center max-w-md">
|
|
109
|
+
<ExtensionSlot slot={NavbarCenterSlot} />
|
|
110
|
+
</div>
|
|
111
|
+
{/* Right: Other navbar items */}
|
|
112
|
+
<div className="flex gap-2 flex-shrink-0">
|
|
113
|
+
<ExtensionSlot slot={NavbarRightSlot} />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</header>
|
|
117
|
+
<main className="p-8 max-w-7xl mx-auto">
|
|
118
|
+
<Routes>
|
|
119
|
+
<Route
|
|
120
|
+
path="/"
|
|
121
|
+
element={
|
|
122
|
+
<div className="space-y-6">
|
|
123
|
+
<ExtensionSlot slot={DashboardSlot} />
|
|
124
|
+
</div>
|
|
125
|
+
}
|
|
126
|
+
/>
|
|
127
|
+
{/* Plugin Routes */}
|
|
128
|
+
{pluginRegistry.getAllRoutes().map((route) => (
|
|
129
|
+
<Route
|
|
130
|
+
key={route.path}
|
|
131
|
+
path={route.path}
|
|
132
|
+
element={
|
|
133
|
+
<RouteGuard permission={route.permission}>
|
|
134
|
+
{route.element}
|
|
135
|
+
</RouteGuard>
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
))}
|
|
139
|
+
</Routes>
|
|
140
|
+
</main>
|
|
141
|
+
</AmbientBackground>
|
|
142
|
+
</BrowserRouter>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* App wrapper that provides APIs and waits for runtime config to load.
|
|
148
|
+
*/
|
|
149
|
+
function AppWithApis() {
|
|
150
|
+
const isConfigLoading = useRuntimeConfigLoading();
|
|
151
|
+
const { baseUrl } = useRuntimeConfig();
|
|
152
|
+
|
|
153
|
+
const apiRegistry = useMemo(() => {
|
|
154
|
+
// Initialize API Registry with core apiRefs
|
|
155
|
+
const registryBuilder = new ApiRegistryBuilder()
|
|
156
|
+
.register(loggerApiRef, new ConsoleLoggerApi())
|
|
157
|
+
.register(permissionApiRef, {
|
|
158
|
+
usePermission: () => ({ loading: false, allowed: true }), // Default to allow all if no auth plugin present
|
|
159
|
+
useResourcePermission: () => ({ loading: false, allowed: true }),
|
|
160
|
+
useManagePermission: () => ({ loading: false, allowed: true }),
|
|
161
|
+
})
|
|
162
|
+
.registerFactory(fetchApiRef, (_registry) => {
|
|
163
|
+
return new CoreFetchApi(baseUrl);
|
|
164
|
+
})
|
|
165
|
+
.registerFactory(rpcApiRef, (_registry) => {
|
|
166
|
+
return new CoreRpcApi(baseUrl);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Register API factories from plugins
|
|
170
|
+
const plugins = pluginRegistry.getPlugins();
|
|
171
|
+
for (const plugin of plugins) {
|
|
172
|
+
if (plugin.apis) {
|
|
173
|
+
for (const api of plugin.apis) {
|
|
174
|
+
registryBuilder.registerFactory(api.ref, (registry) => {
|
|
175
|
+
// Adapt registry map to dependency getter
|
|
176
|
+
const deps = {
|
|
177
|
+
get: <T,>(ref: { id: string }) => registry.get(ref.id) as T,
|
|
178
|
+
};
|
|
179
|
+
return api.factory(deps);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return registryBuilder.build();
|
|
186
|
+
}, [baseUrl]);
|
|
187
|
+
|
|
188
|
+
// Show loading while fetching runtime config
|
|
189
|
+
if (isConfigLoading) {
|
|
190
|
+
return (
|
|
191
|
+
<div className="h-screen flex items-center justify-center bg-background">
|
|
192
|
+
<LoadingSpinner />
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<ApiProvider registry={apiRegistry}>
|
|
199
|
+
<SignalProvider backendUrl={baseUrl}>
|
|
200
|
+
<ToastProvider>
|
|
201
|
+
<AppContent />
|
|
202
|
+
</ToastProvider>
|
|
203
|
+
</SignalProvider>
|
|
204
|
+
</ApiProvider>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function App() {
|
|
209
|
+
return (
|
|
210
|
+
<RuntimeConfigProvider>
|
|
211
|
+
<AppWithApis />
|
|
212
|
+
</RuntimeConfigProvider>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default App;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FetchApi } from "@checkstack/frontend-api";
|
|
2
|
+
|
|
3
|
+
export class CoreFetchApi implements FetchApi {
|
|
4
|
+
private baseUrl: string;
|
|
5
|
+
|
|
6
|
+
constructor(baseUrl: string = "http://localhost:3000") {
|
|
7
|
+
this.baseUrl = baseUrl;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
11
|
+
const headers = new Headers(init?.headers);
|
|
12
|
+
|
|
13
|
+
return fetch(input, {
|
|
14
|
+
...init,
|
|
15
|
+
headers,
|
|
16
|
+
credentials: "include",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
forPlugin(pluginId: string): {
|
|
21
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
22
|
+
} {
|
|
23
|
+
return {
|
|
24
|
+
fetch: (path: string, init?: RequestInit) => {
|
|
25
|
+
return this.fetch(`${this.baseUrl}/api/${pluginId}${path}`, init);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LoggerApi } from "@checkstack/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,30 @@
|
|
|
1
|
+
import { RpcApi } from "@checkstack/frontend-api";
|
|
2
|
+
import { createORPCClient } from "@orpc/client";
|
|
3
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
4
|
+
import type { ClientDefinition, InferClient } from "@checkstack/common";
|
|
5
|
+
|
|
6
|
+
export class CoreRpcApi implements RpcApi {
|
|
7
|
+
public client: unknown;
|
|
8
|
+
private pluginClientCache: Map<string, unknown> = new Map();
|
|
9
|
+
|
|
10
|
+
constructor(baseUrl: string = "http://localhost:3000") {
|
|
11
|
+
const link = new RPCLink({
|
|
12
|
+
url: `${baseUrl}/api`,
|
|
13
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
|
|
14
|
+
fetch(input, { ...init, credentials: "include" }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
this.client = createORPCClient(link);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
forPlugin<T extends ClientDefinition>(def: T): InferClient<T> {
|
|
21
|
+
const { pluginId } = def;
|
|
22
|
+
if (!this.pluginClientCache.has(pluginId)) {
|
|
23
|
+
this.pluginClientCache.set(
|
|
24
|
+
pluginId,
|
|
25
|
+
(this.client as Record<string, unknown>)[pluginId]
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return this.pluginClientCache.get(pluginId) as InferClient<T>;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useReducer } from "react";
|
|
2
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
3
|
+
import {
|
|
4
|
+
PLUGIN_INSTALLED,
|
|
5
|
+
PLUGIN_DEREGISTERED,
|
|
6
|
+
} from "@checkstack/signal-common";
|
|
7
|
+
import { pluginRegistry } from "@checkstack/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,48 @@
|
|
|
1
|
+
@import "@checkstack/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
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Subtle pulse animation for navbar search */
|
|
38
|
+
@keyframes pulse-glow {
|
|
39
|
+
|
|
40
|
+
0%,
|
|
41
|
+
100% {
|
|
42
|
+
box-shadow: 0 0 0 0 hsl(var(--primary) / 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
50% {
|
|
46
|
+
box-shadow: 0 0 8px 2px hsl(var(--primary) / 0.15);
|
|
47
|
+
}
|
|
48
|
+
}
|
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 "@checkstack/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="checkstack-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 @checkstack/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("@checkstack/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 "@checkstack/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 "@checkstack/frontend-api";
|
|
3
|
+
import { FrontendPlugin } from "@checkstack/frontend-api";
|
|
4
|
+
import { createRoutes } from "@checkstack/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,62 @@
|
|
|
1
|
+
import { defineConfig } 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(() => {
|
|
10
|
+
// Backend URL for proxy - always targets local backend in dev
|
|
11
|
+
const backendUrl = "http://localhost:3000";
|
|
12
|
+
return {
|
|
13
|
+
// Tell Vite to look for .env files in monorepo root
|
|
14
|
+
envDir: monorepoRoot,
|
|
15
|
+
plugins: [react()],
|
|
16
|
+
server: {
|
|
17
|
+
proxy: {
|
|
18
|
+
// Proxy API requests and WebSocket connections to backend
|
|
19
|
+
// Use regex to ensure /api-docs doesn't match (it starts with /api but isn't an API call)
|
|
20
|
+
"^/api/": {
|
|
21
|
+
target: backendUrl,
|
|
22
|
+
ws: true, // Enable WebSocket proxy
|
|
23
|
+
},
|
|
24
|
+
"/assets": backendUrl,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
// ============================================================
|
|
28
|
+
// React Instance Sharing Strategy
|
|
29
|
+
// ============================================================
|
|
30
|
+
// This config works with two complementary mechanisms:
|
|
31
|
+
//
|
|
32
|
+
// 1. BUNDLED PLUGINS (core/* and plugins/*):
|
|
33
|
+
// - resolve.dedupe forces Rollup to use single React copy
|
|
34
|
+
// - Works at build time when all imports are visible
|
|
35
|
+
//
|
|
36
|
+
// 2. RUNTIME PLUGINS (loaded dynamically via import()):
|
|
37
|
+
// - Import Maps in index.html resolve "react" → /vendor/react.js
|
|
38
|
+
// - Vendor bundles built by vite.config.vendor.ts
|
|
39
|
+
// - dedupe can't help here since plugins load AFTER build
|
|
40
|
+
//
|
|
41
|
+
// Both mechanisms ensure all code uses the same React instance.
|
|
42
|
+
// ============================================================
|
|
43
|
+
|
|
44
|
+
// Pre-bundle React deps for faster dev server startup (dev mode only)
|
|
45
|
+
optimizeDeps: {
|
|
46
|
+
include: ["react", "react-dom", "react-router-dom"],
|
|
47
|
+
},
|
|
48
|
+
build: {
|
|
49
|
+
// Use esnext to support top-level await and modern ES features
|
|
50
|
+
target: "esnext",
|
|
51
|
+
},
|
|
52
|
+
resolve: {
|
|
53
|
+
// Force all monorepo packages to use the same React copy at build time.
|
|
54
|
+
// Without this, each workspace package can bundle its own React copy,
|
|
55
|
+
// causing "useContext is null" errors from context mismatch.
|
|
56
|
+
dedupe: ["react", "react-dom", "react-router-dom", "react/jsx-runtime"],
|
|
57
|
+
alias: {
|
|
58
|
+
"@": path.resolve(__dirname, "./src"),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
@@ -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
|
+
});
|