opencode-dashboard 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.
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Dashboard Server - Route Handlers
3
+ *
4
+ * Implements all 7 HTTP endpoints:
5
+ *
6
+ * Plugin API (internal):
7
+ * POST /api/plugin/register - Plugin registers with { projectPath, projectName }
8
+ * POST /api/plugin/event - Plugin pushes an event { pluginId, event, data }
9
+ * POST /api/plugin/heartbeat - Plugin sends { pluginId }
10
+ * DELETE /api/plugin/:id - Plugin deregisters on shutdown
11
+ *
12
+ * Dashboard API (public):
13
+ * GET /api/state - Full board state as JSON
14
+ * GET /api/events - SSE stream for real-time updates
15
+ * GET /api/health - Server health check
16
+ */
17
+
18
+ import { StateManager } from "./state";
19
+ import {
20
+ broadcast,
21
+ createSSEResponse,
22
+ closeAllClients,
23
+ clientCount,
24
+ reset as resetSSE,
25
+ } from "./sse";
26
+ import { join } from "path";
27
+
28
+ // --- Static File Serving ---
29
+
30
+ /**
31
+ * Directory containing the pre-built Vite frontend.
32
+ * In production (npm install), this is `../dist/` relative to server/.
33
+ */
34
+ const DIST_DIR = join(import.meta.dir, "../dist");
35
+
36
+ /** Content-type map for static file extensions */
37
+ const MIME_TYPES: Record<string, string> = {
38
+ ".html": "text/html; charset=utf-8",
39
+ ".js": "text/javascript; charset=utf-8",
40
+ ".css": "text/css; charset=utf-8",
41
+ ".json": "application/json",
42
+ ".svg": "image/svg+xml",
43
+ ".png": "image/png",
44
+ ".ico": "image/x-icon",
45
+ ".woff": "font/woff",
46
+ ".woff2": "font/woff2",
47
+ };
48
+
49
+ // --- State Management ---
50
+
51
+ /** Central state manager — processes events and persists to disk */
52
+ export const stateManager = new StateManager();
53
+
54
+ // --- Plugin Registry ---
55
+
56
+ interface PluginRecord {
57
+ pluginId: string;
58
+ projectPath: string;
59
+ projectName: string;
60
+ registeredAt: number;
61
+ lastHeartbeat: number;
62
+ }
63
+
64
+ /** Registered plugins, keyed by pluginId */
65
+ const plugins = new Map<string, PluginRecord>();
66
+
67
+ /** Server start time for uptime calculation */
68
+ const startTime = Date.now();
69
+
70
+ // --- Helpers ---
71
+
72
+ /** Add CORS headers for localhost origins */
73
+ function corsHeaders(origin?: string | null): Record<string, string> {
74
+ // Allow any localhost origin for development
75
+ const allowedOrigin =
76
+ origin && /^https?:\/\/localhost(:\d+)?$/.test(origin) ? origin : "*";
77
+ return {
78
+ "Access-Control-Allow-Origin": allowedOrigin,
79
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
80
+ "Access-Control-Allow-Headers": "Content-Type",
81
+ };
82
+ }
83
+
84
+ /** Create a JSON response with CORS headers */
85
+ function json(
86
+ data: unknown,
87
+ status: number = 200,
88
+ origin?: string | null
89
+ ): Response {
90
+ return new Response(JSON.stringify(data), {
91
+ status,
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ ...corsHeaders(origin),
95
+ },
96
+ });
97
+ }
98
+
99
+ /** Parse JSON body safely, returning null on failure */
100
+ async function parseBody(req: Request): Promise<unknown | null> {
101
+ try {
102
+ return await req.json();
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Generate a UUID v4 */
109
+ function generateId(): string {
110
+ return crypto.randomUUID();
111
+ }
112
+
113
+ // --- Route Handlers ---
114
+
115
+ /**
116
+ * POST /api/plugin/register
117
+ *
118
+ * Plugin registers with { projectPath, projectName }.
119
+ * Returns { pluginId }.
120
+ */
121
+ async function handleRegister(
122
+ req: Request,
123
+ origin?: string | null
124
+ ): Promise<Response> {
125
+ const body = await parseBody(req);
126
+
127
+ if (!body || typeof body !== "object") {
128
+ return json({ error: "Invalid JSON body" }, 400, origin);
129
+ }
130
+
131
+ const { projectPath, projectName } = body as Record<string, unknown>;
132
+
133
+ if (!projectPath || typeof projectPath !== "string") {
134
+ return json(
135
+ { error: "Missing or invalid 'projectPath' (must be a non-empty string)" },
136
+ 400,
137
+ origin
138
+ );
139
+ }
140
+
141
+ if (!projectName || typeof projectName !== "string") {
142
+ return json(
143
+ { error: "Missing or invalid 'projectName' (must be a non-empty string)" },
144
+ 400,
145
+ origin
146
+ );
147
+ }
148
+
149
+ const pluginId = generateId();
150
+ const now = Date.now();
151
+
152
+ plugins.set(pluginId, {
153
+ pluginId,
154
+ projectPath,
155
+ projectName,
156
+ registeredAt: now,
157
+ lastHeartbeat: now,
158
+ });
159
+
160
+ // Register in state manager (creates or re-activates project)
161
+ stateManager.registerPlugin(pluginId, projectPath, projectName);
162
+
163
+ console.log(
164
+ `[server] Plugin registered: ${pluginId} (${projectName} @ ${projectPath})`
165
+ );
166
+
167
+ // Broadcast connection to SSE clients
168
+ broadcast("project:connected", {
169
+ projectPath,
170
+ projectName,
171
+ pluginId,
172
+ });
173
+
174
+ return json({ pluginId }, 200, origin);
175
+ }
176
+
177
+ /**
178
+ * POST /api/plugin/event
179
+ *
180
+ * Plugin pushes an event { pluginId, event, data }.
181
+ * Server updates state + broadcasts to SSE.
182
+ */
183
+ async function handleEvent(
184
+ req: Request,
185
+ origin?: string | null
186
+ ): Promise<Response> {
187
+ const body = await parseBody(req);
188
+
189
+ if (!body || typeof body !== "object") {
190
+ return json({ error: "Invalid JSON body" }, 400, origin);
191
+ }
192
+
193
+ const { pluginId, event, data } = body as Record<string, unknown>;
194
+
195
+ if (!pluginId || typeof pluginId !== "string") {
196
+ return json(
197
+ { error: "Missing or invalid 'pluginId' (must be a non-empty string)" },
198
+ 400,
199
+ origin
200
+ );
201
+ }
202
+
203
+ if (!event || typeof event !== "string") {
204
+ return json(
205
+ { error: "Missing or invalid 'event' (must be a non-empty string)" },
206
+ 400,
207
+ origin
208
+ );
209
+ }
210
+
211
+ // Verify plugin is registered
212
+ const plugin = plugins.get(pluginId);
213
+ if (!plugin) {
214
+ return json({ error: `Unknown pluginId: ${pluginId}` }, 404, origin);
215
+ }
216
+
217
+ // Update heartbeat on any event
218
+ plugin.lastHeartbeat = Date.now();
219
+
220
+ // Process event through state manager (updates canonical state)
221
+ const isPlainObject =
222
+ data != null && typeof data === "object" && !Array.isArray(data);
223
+ const eventData = isPlainObject
224
+ ? (data as Record<string, unknown>)
225
+ : { data };
226
+
227
+ const processed = stateManager.processEvent(pluginId, event, eventData);
228
+
229
+ // Broadcast to all SSE clients — use state-enriched data if available
230
+ if (processed) {
231
+ broadcast(processed.event, {
232
+ ...processed.data,
233
+ _serverTimestamp: Date.now(),
234
+ });
235
+
236
+ // If beads:refreshed reconciled stale beads, broadcast individual
237
+ // bead:removed events so connected frontends remove them from the board.
238
+ if (
239
+ processed.event === "beads:refreshed" &&
240
+ Array.isArray(processed.data.removedBeadIds) &&
241
+ (processed.data.removedBeadIds as string[]).length > 0
242
+ ) {
243
+ const ts = Date.now();
244
+ for (const beadId of processed.data.removedBeadIds as string[]) {
245
+ broadcast("bead:removed", {
246
+ beadId,
247
+ projectPath: plugin.projectPath,
248
+ pipelineId: "default",
249
+ _reconciled: true,
250
+ _serverTimestamp: ts,
251
+ });
252
+ }
253
+ console.log(
254
+ `[server] Reconciled ${(processed.data.removedBeadIds as string[]).length} stale bead(s) for ${plugin.projectName}`
255
+ );
256
+ }
257
+ } else {
258
+ // Fallback: enrich and broadcast directly (unknown plugin in state)
259
+ const enrichedData = {
260
+ ...eventData,
261
+ projectPath: plugin.projectPath,
262
+ _serverTimestamp: Date.now(),
263
+ };
264
+ broadcast(event, enrichedData);
265
+ }
266
+
267
+ console.log(`[server] Event from ${pluginId}: ${event}`);
268
+
269
+ return json({ ok: true }, 200, origin);
270
+ }
271
+
272
+ /**
273
+ * POST /api/plugin/heartbeat
274
+ *
275
+ * Plugin sends { pluginId }. Server updates lastHeartbeat.
276
+ */
277
+ async function handleHeartbeat(
278
+ req: Request,
279
+ origin?: string | null
280
+ ): Promise<Response> {
281
+ const body = await parseBody(req);
282
+
283
+ if (!body || typeof body !== "object") {
284
+ return json({ error: "Invalid JSON body" }, 400, origin);
285
+ }
286
+
287
+ const { pluginId } = body as Record<string, unknown>;
288
+
289
+ if (!pluginId || typeof pluginId !== "string") {
290
+ return json(
291
+ { error: "Missing or invalid 'pluginId' (must be a non-empty string)" },
292
+ 400,
293
+ origin
294
+ );
295
+ }
296
+
297
+ const plugin = plugins.get(pluginId);
298
+ if (!plugin) {
299
+ return json({ error: `Unknown pluginId: ${pluginId}` }, 404, origin);
300
+ }
301
+
302
+ plugin.lastHeartbeat = Date.now();
303
+ stateManager.updateHeartbeat(pluginId);
304
+
305
+ return json({ ok: true }, 200, origin);
306
+ }
307
+
308
+ /**
309
+ * DELETE /api/plugin/:id
310
+ *
311
+ * Plugin deregisters on shutdown. Removes from state.
312
+ */
313
+ function handleDeregister(
314
+ pluginId: string,
315
+ origin?: string | null
316
+ ): Response {
317
+ const plugin = plugins.get(pluginId);
318
+ if (!plugin) {
319
+ return json({ error: `Unknown pluginId: ${pluginId}` }, 404, origin);
320
+ }
321
+
322
+ plugins.delete(pluginId);
323
+
324
+ // Mark project as disconnected in state (retains last-known state)
325
+ stateManager.deregisterPlugin(pluginId);
326
+
327
+ console.log(
328
+ `[server] Plugin deregistered: ${pluginId} (${plugin.projectName})`
329
+ );
330
+
331
+ // Broadcast disconnection to SSE clients
332
+ broadcast("project:disconnected", {
333
+ projectPath: plugin.projectPath,
334
+ pluginId,
335
+ });
336
+
337
+ return json({ ok: true }, 200, origin);
338
+ }
339
+
340
+ /**
341
+ * GET /api/state
342
+ *
343
+ * Returns full board state as JSON (all projects, all pipelines, all beads).
344
+ */
345
+ function handleState(origin?: string | null): Response {
346
+ return json(stateManager.toJSON(), 200, origin);
347
+ }
348
+
349
+ /**
350
+ * GET /api/events
351
+ *
352
+ * SSE stream for real-time updates.
353
+ * Sends "connected" + "state:full" events immediately, then streams updates.
354
+ */
355
+ function handleEvents(origin?: string | null): Response {
356
+ return createSSEResponse(
357
+ () =>
358
+ Array.from(plugins.values()).map((p) => ({
359
+ pluginId: p.pluginId,
360
+ projectPath: p.projectPath,
361
+ projectName: p.projectName,
362
+ })),
363
+ () => stateManager.toJSON(),
364
+ origin,
365
+ corsHeaders
366
+ );
367
+ }
368
+
369
+ /**
370
+ * GET /api/health
371
+ *
372
+ * Server health check.
373
+ */
374
+ function handleHealth(origin?: string | null): Response {
375
+ return json(
376
+ {
377
+ status: "ok",
378
+ uptime: Math.floor((Date.now() - startTime) / 1000),
379
+ plugins: plugins.size,
380
+ sseClients: clientCount(),
381
+ },
382
+ 200,
383
+ origin
384
+ );
385
+ }
386
+
387
+ // --- Router ---
388
+
389
+ /**
390
+ * Extract pluginId from a DELETE /api/plugin/:id path.
391
+ * Returns the id portion or null if the path doesn't match.
392
+ */
393
+ function extractPluginId(pathname: string): string | null {
394
+ const match = pathname.match(/^\/api\/plugin\/([^/]+)$/);
395
+ return match ? match[1] : null;
396
+ }
397
+
398
+ /**
399
+ * Main request router.
400
+ *
401
+ * Routes incoming requests to the appropriate handler based on
402
+ * method and pathname. Returns a 404 for unmatched routes.
403
+ */
404
+ export async function handleRequest(req: Request): Promise<Response> {
405
+ const url = new URL(req.url);
406
+ const { pathname } = url;
407
+ const method = req.method;
408
+ const origin = req.headers.get("Origin");
409
+
410
+ // Handle CORS preflight
411
+ if (method === "OPTIONS") {
412
+ return new Response(null, {
413
+ status: 204,
414
+ headers: corsHeaders(origin),
415
+ });
416
+ }
417
+
418
+ // --- Plugin API ---
419
+
420
+ if (pathname === "/api/plugin/register" && method === "POST") {
421
+ return handleRegister(req, origin);
422
+ }
423
+
424
+ if (pathname === "/api/plugin/event" && method === "POST") {
425
+ return handleEvent(req, origin);
426
+ }
427
+
428
+ if (pathname === "/api/plugin/heartbeat" && method === "POST") {
429
+ return handleHeartbeat(req, origin);
430
+ }
431
+
432
+ // DELETE /api/plugin/:id — must come after the specific plugin sub-routes
433
+ if (method === "DELETE") {
434
+ const pluginId = extractPluginId(pathname);
435
+ if (pluginId) {
436
+ return handleDeregister(pluginId, origin);
437
+ }
438
+ }
439
+
440
+ // --- Dashboard API ---
441
+
442
+ if (pathname === "/api/state" && method === "GET") {
443
+ return handleState(origin);
444
+ }
445
+
446
+ if (pathname === "/api/events" && method === "GET") {
447
+ return handleEvents(origin);
448
+ }
449
+
450
+ if (pathname === "/api/health" && method === "GET") {
451
+ return handleHealth(origin);
452
+ }
453
+
454
+ // --- Static File Serving (pre-built frontend) ---
455
+
456
+ if (method === "GET" && !pathname.startsWith("/api/")) {
457
+ const filePath = join(DIST_DIR, pathname === "/" ? "index.html" : pathname);
458
+ const file = Bun.file(filePath);
459
+ if (await file.exists()) {
460
+ const ext = filePath.substring(filePath.lastIndexOf("."));
461
+ return new Response(file, {
462
+ headers: {
463
+ "Content-Type": MIME_TYPES[ext] || "application/octet-stream",
464
+ },
465
+ });
466
+ }
467
+ // SPA fallback: serve index.html for client-side routing
468
+ const indexFile = Bun.file(join(DIST_DIR, "index.html"));
469
+ if (await indexFile.exists()) {
470
+ return new Response(indexFile, {
471
+ headers: { "Content-Type": "text/html; charset=utf-8" },
472
+ });
473
+ }
474
+ }
475
+
476
+ // --- Not Found ---
477
+
478
+ return json({ error: "Not found", path: pathname }, 404, origin);
479
+ }
480
+
481
+ // --- Exports for testing / external access ---
482
+
483
+ export { plugins, broadcast, closeAllClients as closeAllSSEClients, resetSSE };
484
+
485
+ // --- Plugin Health Monitoring ---
486
+
487
+ /**
488
+ * Periodically check plugin heartbeats.
489
+ * Plugins that haven't sent a heartbeat within 45 seconds are marked
490
+ * as disconnected (state retained, but connection badge shows DISCONNECTED).
491
+ */
492
+ const PLUGIN_HEALTH_CHECK_INTERVAL_MS = 30_000;
493
+ const PLUGIN_HEARTBEAT_TIMEOUT_MS = 45_000;
494
+
495
+ const pluginHealthInterval = setInterval(() => {
496
+ const now = Date.now();
497
+ for (const [pluginId, plugin] of plugins) {
498
+ if (now - plugin.lastHeartbeat > PLUGIN_HEARTBEAT_TIMEOUT_MS) {
499
+ console.log(
500
+ `[server] Plugin ${pluginId} (${plugin.projectName}) heartbeat timeout — marking disconnected`
501
+ );
502
+ plugins.delete(pluginId);
503
+ stateManager.deregisterPlugin(pluginId);
504
+ broadcast("project:disconnected", {
505
+ projectPath: plugin.projectPath,
506
+ pluginId,
507
+ reason: "heartbeat_timeout",
508
+ });
509
+ }
510
+ }
511
+ }, PLUGIN_HEALTH_CHECK_INTERVAL_MS);
512
+
513
+ pluginHealthInterval.unref();
514
+
515
+ /**
516
+ * Stop plugin health monitoring (for graceful shutdown).
517
+ */
518
+ export function stopHealthMonitoring(): void {
519
+ clearInterval(pluginHealthInterval);
520
+ }
package/server/sse.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Dashboard Server - SSE Client Management & Broadcasting
3
+ *
4
+ * Manages Server-Sent Events connections to dashboard browser clients.
5
+ * Handles:
6
+ * - Client connection/disconnection tracking
7
+ * - Event broadcasting to all connected clients
8
+ * - SSE heartbeat for stale client detection
9
+ * - state:full snapshot delivery on connect
10
+ * - Graceful shutdown
11
+ */
12
+
13
+ // --- Types ---
14
+
15
+ export interface SSEClient {
16
+ controller: ReadableStreamDefaultController<Uint8Array>;
17
+ connectedAt: number;
18
+ }
19
+
20
+ // --- SSE Manager ---
21
+
22
+ /** SSE message ID counter for reconnection support */
23
+ let messageId = 0;
24
+
25
+ /** Connected SSE clients */
26
+ const clients = new Set<SSEClient>();
27
+
28
+ const encoder = new TextEncoder();
29
+
30
+ /**
31
+ * Get the current number of connected SSE clients.
32
+ */
33
+ export function clientCount(): number {
34
+ return clients.size;
35
+ }
36
+
37
+ /**
38
+ * Get the current SSE message ID (for testing/debugging).
39
+ */
40
+ export function currentMessageId(): number {
41
+ return messageId;
42
+ }
43
+
44
+ /**
45
+ * Broadcast a named SSE event to all connected clients.
46
+ *
47
+ * Format:
48
+ * id: <messageId>
49
+ * event: <eventName>
50
+ * data: <JSON string>
51
+ *
52
+ * Clients that fail to receive the message are automatically removed.
53
+ */
54
+ export function broadcast(eventName: string, data: unknown): void {
55
+ messageId++;
56
+ const msg = `id: ${messageId}\nevent: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
57
+ const encoded = encoder.encode(msg);
58
+
59
+ for (const client of clients) {
60
+ try {
61
+ client.controller.enqueue(encoded);
62
+ } catch {
63
+ clients.delete(client);
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Create an SSE Response for a new client connection.
70
+ *
71
+ * Sends:
72
+ * 1. `retry: 3000` directive (reconnect interval)
73
+ * 2. `connected` event with metadata
74
+ * 3. `state:full` event with complete state snapshot
75
+ *
76
+ * @param getPlugins - Function returning current plugin list (avoids circular deps)
77
+ * @param getState - Function returning serialized state snapshot
78
+ * @param origin - Request Origin header for CORS
79
+ * @param corsHeaders - Function to generate CORS headers
80
+ */
81
+ export function createSSEResponse(
82
+ getPlugins: () => Array<{
83
+ pluginId: string;
84
+ projectPath: string;
85
+ projectName: string;
86
+ }>,
87
+ getState: () => unknown,
88
+ origin: string | null | undefined,
89
+ corsHeadersFn: (origin?: string | null) => Record<string, string>
90
+ ): Response {
91
+ let sseClient: SSEClient;
92
+
93
+ const stream = new ReadableStream<Uint8Array>({
94
+ start(controller) {
95
+ sseClient = {
96
+ controller,
97
+ connectedAt: Date.now(),
98
+ };
99
+ clients.add(sseClient);
100
+
101
+ // Set reconnect interval
102
+ controller.enqueue(encoder.encode("retry: 3000\n\n"));
103
+
104
+ // Send connection confirmation with current plugin list
105
+ messageId++;
106
+ const connectMsg = encoder.encode(
107
+ `id: ${messageId}\nevent: connected\ndata: ${JSON.stringify({
108
+ message: "SSE connected",
109
+ timestamp: Date.now(),
110
+ plugins: getPlugins(),
111
+ })}\n\n`
112
+ );
113
+ controller.enqueue(connectMsg);
114
+
115
+ // Send full state snapshot for initial load / reconnection
116
+ messageId++;
117
+ const stateMsg = encoder.encode(
118
+ `id: ${messageId}\nevent: state:full\ndata: ${JSON.stringify(getState())}\n\n`
119
+ );
120
+ controller.enqueue(stateMsg);
121
+ },
122
+ cancel() {
123
+ if (sseClient) {
124
+ clients.delete(sseClient);
125
+ }
126
+ },
127
+ });
128
+
129
+ return new Response(stream, {
130
+ headers: {
131
+ "Content-Type": "text/event-stream",
132
+ "Cache-Control": "no-cache",
133
+ Connection: "keep-alive",
134
+ ...corsHeadersFn(origin),
135
+ },
136
+ });
137
+ }
138
+
139
+ // --- SSE Heartbeat ---
140
+
141
+ /**
142
+ * Periodic SSE heartbeat to detect and clean up stale clients.
143
+ * SSE comment lines (starting with ':') are ignored by EventSource
144
+ * but keep the connection alive and detect disconnects.
145
+ */
146
+ const SSE_HEARTBEAT_INTERVAL_MS = 30_000;
147
+
148
+ const sseHeartbeatInterval = setInterval(() => {
149
+ const heartbeat = encoder.encode(`: heartbeat ${Date.now()}\n\n`);
150
+ for (const client of clients) {
151
+ try {
152
+ client.controller.enqueue(heartbeat);
153
+ } catch {
154
+ clients.delete(client);
155
+ }
156
+ }
157
+ }, SSE_HEARTBEAT_INTERVAL_MS);
158
+
159
+ // Don't prevent the process from exiting when this is the only pending timer
160
+ sseHeartbeatInterval.unref();
161
+
162
+ /**
163
+ * Gracefully close all SSE connections and clean up resources.
164
+ * Called during server shutdown.
165
+ */
166
+ export function closeAllClients(): void {
167
+ clearInterval(sseHeartbeatInterval);
168
+ for (const client of clients) {
169
+ try {
170
+ client.controller.close();
171
+ } catch {
172
+ // already closed
173
+ }
174
+ }
175
+ clients.clear();
176
+ }
177
+
178
+ /**
179
+ * Reset SSE state for testing.
180
+ * Removes all clients and resets the message ID counter.
181
+ */
182
+ export function reset(): void {
183
+ for (const client of clients) {
184
+ try {
185
+ client.controller.close();
186
+ } catch {
187
+ // already closed
188
+ }
189
+ }
190
+ clients.clear();
191
+ messageId = 0;
192
+ }
193
+
194
+ // --- Exports for testing ---
195
+
196
+ export { clients };