@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 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
+ }
@@ -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,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
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }
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
+ });