miravo 0.2.2 → 0.3.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.
Files changed (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +976 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. package/src/logger.ts +34 -6
@@ -1,25 +1,88 @@
1
- import type { EngineCoreMetrics, EngineStateSnapshot } from "@miravo/core";
2
- import type { CommandResult, EngineCommand } from "@miravo/shared";
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type {
4
+ EngineCoreMetrics,
5
+ EngineStateSnapshot,
6
+ EventLogQuery,
7
+ EventLogResult,
8
+ InstanceDetail,
9
+ ModelSchema,
10
+ TemplateRun,
11
+ } from "@miravo/core";
12
+ import type { CatalogTemplate, CommandResult, EngineCommand, LoggedEvent } from "@miravo/shared";
3
13
  import { EngineCommandSchema } from "@miravo/shared";
14
+ import type { WSClientData } from "./ws-hub.ts";
15
+ import { ALL_CHANNELS, WSHub } from "./ws-hub.ts";
16
+
17
+ /** Valid event types for the /events endpoint, derived from LoggedEvent discriminant. */
18
+ const VALID_EVENT_TYPES: ReadonlySet<string> = new Set<LoggedEvent["type"]>([
19
+ "fault:triggered",
20
+ "fault:cleared",
21
+ "lifecycle:changed",
22
+ "instance:created",
23
+ "instance:removed",
24
+ "engine:state-changed",
25
+ ]);
4
26
 
5
27
  interface AdminEngine {
6
- getMetrics(): { readonly core: EngineCoreMetrics };
28
+ getMetrics(): {
29
+ readonly core: EngineCoreMetrics;
30
+ readonly adapters: ReadonlyArray<{
31
+ readonly name: string;
32
+ readonly metrics: { readonly connected: boolean };
33
+ }>;
34
+ };
35
+ getAvailableAdapters(): ReadonlyArray<{
36
+ readonly name: string;
37
+ readonly enabled: boolean;
38
+ }>;
7
39
  getState(): EngineStateSnapshot;
8
- execute(command: EngineCommand): CommandResult;
40
+ execute(command: EngineCommand): Promise<CommandResult>;
41
+ getInstanceDetail(id: string): InstanceDetail | undefined;
42
+ getModelSchema(name: string): ModelSchema | undefined;
43
+ getEventLog(query?: EventLogQuery): EventLogResult;
44
+ getRunningTemplates(): readonly TemplateRun[];
9
45
  }
10
46
 
11
- interface AdminServerOptions {
12
- readonly engine: AdminEngine;
47
+ /** Network binding configuration. */
48
+ interface AdminServerNetworkOptions {
13
49
  readonly host: string;
14
50
  readonly port: number;
15
51
  readonly tickIntervalMs: number;
16
- readonly mqttEnabled: boolean;
17
- readonly isMqttConnected: () => boolean;
18
- readonly opcuaEnabled: boolean;
19
- readonly isOpcuaConnected: () => boolean;
52
+ }
53
+
54
+ /** Protocol adapter status configuration. */
55
+ interface AdminServerProtocolOptions {
20
56
  readonly getLastTickProgressAt: () => number;
21
57
  }
22
58
 
59
+ /** WebSocket streaming configuration. When provided, enables the /ws endpoint. */
60
+ interface AdminServerWSOptions {
61
+ readonly bus: import("@miravo/core").EventBus;
62
+ readonly getEngineState: () => import("@miravo/shared").EngineRunState;
63
+ readonly getTickInfo: () => { tick: number; simulationTime: number; instanceCount: number };
64
+ }
65
+
66
+ interface AdminServerOptions {
67
+ readonly engine: AdminEngine;
68
+ readonly network: AdminServerNetworkOptions;
69
+ readonly protocols: AdminServerProtocolOptions;
70
+ /** WebSocket streaming. When provided, enables /ws endpoint. */
71
+ readonly ws?: AdminServerWSOptions | undefined;
72
+ /** Bearer token for mutation endpoints. When set, POST /commands requires Authorization header. */
73
+ readonly adminToken?: string | undefined;
74
+ /** Path to console static assets directory. When set, serves the SPA at non-API routes. */
75
+ readonly consoleDir?: string | undefined;
76
+ /** Template catalog provider for GET /catalog/templates. */
77
+ readonly getCatalog?: (() => readonly CatalogTemplate[]) | undefined;
78
+ /** Simulation name for daemon identity verification via /readyz. */
79
+ readonly simulationName?: string | undefined;
80
+ /** Run ID for daemon identity verification via /readyz. */
81
+ readonly runId?: string | undefined;
82
+ /** Callback to request graceful shutdown. Used by POST /shutdown. */
83
+ readonly requestShutdown?: ((reason: string) => void) | undefined;
84
+ }
85
+
23
86
  interface AdminServerStatus {
24
87
  readonly status: "ok" | "fail";
25
88
  readonly reason?: string;
@@ -27,6 +90,7 @@ interface AdminServerStatus {
27
90
 
28
91
  interface AdminServerHandle {
29
92
  readonly endpoint: string;
93
+ readonly wsHub: WSHub | null;
30
94
  stop(): void;
31
95
  }
32
96
 
@@ -40,6 +104,8 @@ interface HealthResponseBody {
40
104
  readonly opcuaConnected: boolean | null;
41
105
  readonly stalled: boolean;
42
106
  readonly reason?: string;
107
+ readonly simulationName?: string;
108
+ readonly runId?: string;
43
109
  }
44
110
 
45
111
  interface AdminErrorResponse {
@@ -196,58 +262,168 @@ async function parseCommandRequest(request: Request): Promise<Response | EngineC
196
262
  * - `/commands`: command execution endpoint using EngineCommandSchema
197
263
  */
198
264
  function startAdminServer(options: AdminServerOptions): AdminServerHandle {
199
- const {
200
- engine,
201
- host,
202
- port,
203
- mqttEnabled,
204
- isMqttConnected,
205
- opcuaEnabled,
206
- isOpcuaConnected,
207
- getLastTickProgressAt,
208
- } = options;
265
+ const { engine } = options;
266
+ const { host, port } = options.network;
267
+ const { getLastTickProgressAt } = options.protocols;
268
+
269
+ const adminToken = options.adminToken;
270
+ const isLoopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
271
+ const isWildcard = host === "0.0.0.0" || host === "::";
272
+
273
+ /** Check bearer token on mutation endpoints. Returns error Response or null if OK. */
274
+ function checkAuth(request: Request): Response | null {
275
+ if (!adminToken) return null;
276
+ const authHeader = request.headers.get("authorization");
277
+ if (authHeader === `Bearer ${adminToken}`) return null;
278
+ return jsonResponse(
279
+ { message: "Unauthorized.", error: "Provide a valid Authorization: Bearer <token> header." },
280
+ 401,
281
+ );
282
+ }
283
+
284
+ /** Require an admin token for remote mutation endpoints. */
285
+ function checkRemoteMutationAuthConfigured(): Response | null {
286
+ if (isLoopback || adminToken) return null;
287
+ return jsonResponse(
288
+ {
289
+ message: "Mutation endpoints require authentication.",
290
+ error: "Set MIRAVO_ADMIN_TOKEN when binding to a non-loopback address.",
291
+ },
292
+ 403,
293
+ );
294
+ }
295
+
296
+ /**
297
+ * Require Content-Type: application/json on mutation endpoints.
298
+ * This forces cross-origin requests through a CORS preflight (since application/json
299
+ * is not a CORS-safe content type), which blocks form CSRF and no-cors fetch attacks.
300
+ */
301
+ function checkContentType(request: Request): Response | null {
302
+ const ct = request.headers.get("content-type");
303
+ if (ct && ct.split(";")[0]?.trim() === "application/json") return null;
304
+ return jsonResponse(
305
+ {
306
+ message: "Unsupported Media Type.",
307
+ error: "Content-Type must be application/json.",
308
+ },
309
+ 415,
310
+ );
311
+ }
312
+
313
+ /** Check Origin header on mutation endpoints for non-loopback binds. Returns error Response or null if OK. */
314
+ function checkOrigin(request: Request): Response | null {
315
+ // Loopback and wildcard binds skip Origin checking — wildcard (0.0.0.0)
316
+ // accepts connections from any interface, so we can't match a specific host.
317
+ if (isLoopback || isWildcard) return null;
318
+ const origin = request.headers.get("origin");
319
+ if (!origin) return null; // No origin = non-browser client, allow
320
+ try {
321
+ const originHost = new URL(origin).hostname;
322
+ if (originHost === host || originHost === "localhost" || originHost === "127.0.0.1")
323
+ return null;
324
+ } catch {
325
+ // Malformed origin
326
+ }
327
+ return jsonResponse({ message: "Forbidden.", error: "Origin not allowed." }, 403);
328
+ }
329
+
330
+ // Create WebSocket hub if ws config is provided
331
+ const wsHub = options.ws
332
+ ? new WSHub({
333
+ bus: options.ws.bus,
334
+ getEngineState: options.ws.getEngineState,
335
+ getTickInfo: options.ws.getTickInfo,
336
+ })
337
+ : null;
338
+
339
+ // Pre-resolve console dir and index.html once at startup (avoids per-request exists() check).
340
+ // Verify the file exists so we don't silently serve a missing build as a 200.
341
+ const consoleDir = options.consoleDir ? resolve(options.consoleDir) : null;
342
+ const consoleIndexPath = consoleDir ? `${consoleDir}/index.html` : null;
343
+ const consoleIndexFile =
344
+ consoleIndexPath && existsSync(consoleIndexPath) ? Bun.file(consoleIndexPath) : null;
209
345
 
210
346
  const server = Bun.serve({
211
347
  hostname: host,
212
348
  port,
213
- async fetch(request) {
349
+ async fetch(request, server) {
214
350
  const url = new URL(request.url);
215
351
  const pathname = url.pathname;
216
352
 
217
- const metrics = engine.getMetrics();
218
-
219
- const now = performance.now();
220
- const stallThresholdMs = Math.max(100, options.tickIntervalMs * 3);
221
- const lastTickProgressAt = getLastTickProgressAt();
222
- const stalled =
223
- metrics.core.state === "running" &&
224
- Number.isFinite(lastTickProgressAt) &&
225
- now - lastTickProgressAt > stallThresholdMs;
226
-
227
- const mqttConnected = mqttEnabled ? isMqttConnected() : false;
228
- const opcuaConnected = opcuaEnabled ? isOpcuaConnected() : false;
229
- const healthBody = buildHealthBody({
230
- metrics,
231
- stalled,
232
- mqttEnabled,
233
- mqttConnected,
234
- opcuaEnabled,
235
- opcuaConnected,
236
- });
237
-
238
- if (pathname === "/healthz") {
239
- const status = livenessStatus({ core: metrics.core, stalled });
240
- return jsonResponse(
241
- {
242
- status: status.status,
243
- ...healthBody,
244
- ...(status.reason ? { reason: status.reason } : {}),
245
- } satisfies HealthResponseBody,
246
- status.status === "ok" ? 200 : 503,
247
- );
353
+ // WebSocket upgrade for /ws
354
+ if (pathname === "/ws") {
355
+ if (!wsHub) {
356
+ return plainResponse("WebSocket not available", 503);
357
+ }
358
+ const originBlock = checkOrigin(request);
359
+ if (originBlock) return originBlock;
360
+ const upgraded = server.upgrade(request, {
361
+ // WSClientData — Bun's untyped websocket generic requires the cast
362
+ data: { channels: new Set(ALL_CHANNELS) } as unknown as undefined,
363
+ });
364
+ if (!upgraded) {
365
+ return plainResponse("WebSocket upgrade failed", 400);
366
+ }
367
+ // Bun requires a Response return, but upgrade() consumes the request
368
+ return undefined as unknown as Response;
248
369
  }
249
370
 
250
- if (pathname === "/readyz") {
371
+ // Health/metrics routes — compute expensive metrics only when needed
372
+ if (pathname === "/healthz" || pathname === "/readyz" || pathname === "/metrics") {
373
+ if (pathname === "/metrics" && request.method !== "GET") {
374
+ return plainResponse("Method Not Allowed", 405, "GET");
375
+ }
376
+
377
+ const metrics = engine.getMetrics();
378
+
379
+ if (pathname === "/metrics") {
380
+ return jsonResponse(metrics, 200);
381
+ }
382
+
383
+ const now = performance.now();
384
+ const stallThresholdMs = Math.max(100, options.network.tickIntervalMs * 3);
385
+ const lastTickProgressAt = getLastTickProgressAt();
386
+ const stalled =
387
+ metrics.core.state === "running" &&
388
+ Number.isFinite(lastTickProgressAt) &&
389
+ now - lastTickProgressAt > stallThresholdMs;
390
+
391
+ const availableAdapters = engine.getAvailableAdapters();
392
+ const mqttEnabled = availableAdapters.some(
393
+ (adapter) => adapter.name === "mqtt" && adapter.enabled,
394
+ );
395
+ const opcuaEnabled = availableAdapters.some(
396
+ (adapter) => adapter.name === "opcua" && adapter.enabled,
397
+ );
398
+
399
+ const mqttConnected = mqttEnabled
400
+ ? (metrics.adapters.find((a) => a.name === "mqtt")?.metrics.connected ?? false)
401
+ : false;
402
+ const opcuaConnected = opcuaEnabled
403
+ ? (metrics.adapters.find((a) => a.name === "opcua")?.metrics.connected ?? false)
404
+ : false;
405
+ const healthBody = buildHealthBody({
406
+ metrics,
407
+ stalled,
408
+ mqttEnabled,
409
+ mqttConnected,
410
+ opcuaEnabled,
411
+ opcuaConnected,
412
+ });
413
+
414
+ if (pathname === "/healthz") {
415
+ const status = livenessStatus({ core: metrics.core, stalled });
416
+ return jsonResponse(
417
+ {
418
+ status: status.status,
419
+ ...healthBody,
420
+ ...(status.reason ? { reason: status.reason } : {}),
421
+ } satisfies HealthResponseBody,
422
+ status.status === "ok" ? 200 : 503,
423
+ );
424
+ }
425
+
426
+ // /readyz
251
427
  const status = readinessStatus({
252
428
  core: metrics.core,
253
429
  stalled,
@@ -261,18 +437,13 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
261
437
  status: status.status,
262
438
  ...healthBody,
263
439
  ...(status.reason ? { reason: status.reason } : {}),
440
+ ...(options.simulationName ? { simulationName: options.simulationName } : {}),
441
+ ...(options.runId ? { runId: options.runId } : {}),
264
442
  } satisfies HealthResponseBody,
265
443
  status.status === "ok" ? 200 : 503,
266
444
  );
267
445
  }
268
446
 
269
- if (pathname === "/metrics") {
270
- if (request.method !== "GET") {
271
- return plainResponse("Method Not Allowed", 405, "GET");
272
- }
273
- return jsonResponse(metrics, 200);
274
- }
275
-
276
447
  if (pathname === "/state") {
277
448
  if (request.method !== "GET") {
278
449
  return plainResponse("Method Not Allowed", 405, "GET");
@@ -284,24 +455,204 @@ function startAdminServer(options: AdminServerOptions): AdminServerHandle {
284
455
  if (request.method !== "POST") {
285
456
  return plainResponse("Method Not Allowed", 405, "POST");
286
457
  }
458
+ const authConfigBlock = checkRemoteMutationAuthConfigured();
459
+ if (authConfigBlock) return authConfigBlock;
460
+ const ctBlock = checkContentType(request);
461
+ if (ctBlock) return ctBlock;
462
+ const originBlock = checkOrigin(request);
463
+ if (originBlock) return originBlock;
464
+ const authBlock = checkAuth(request);
465
+ if (authBlock) return authBlock;
287
466
  const commandOrError = await parseCommandRequest(request);
288
467
  if (commandOrError instanceof Response) {
289
468
  return commandOrError;
290
469
  }
291
- return jsonResponse(engine.execute(commandOrError), 200);
470
+ return jsonResponse(await engine.execute(commandOrError), 200);
471
+ }
472
+
473
+ // POST /shutdown — graceful shutdown for daemon mode
474
+ if (pathname === "/shutdown") {
475
+ if (request.method !== "POST") {
476
+ return plainResponse("Method Not Allowed", 405, "POST");
477
+ }
478
+ if (!options.requestShutdown) {
479
+ return jsonResponse(
480
+ {
481
+ message: "Shutdown not available.",
482
+ error: "This server does not support remote shutdown.",
483
+ },
484
+ 501,
485
+ );
486
+ }
487
+ const authConfigBlock = checkRemoteMutationAuthConfigured();
488
+ if (authConfigBlock) return authConfigBlock;
489
+ const shutdownCtBlock = checkContentType(request);
490
+ if (shutdownCtBlock) return shutdownCtBlock;
491
+ const originBlock = checkOrigin(request);
492
+ if (originBlock) return originBlock;
493
+ const authBlock = checkAuth(request);
494
+ if (authBlock) return authBlock;
495
+
496
+ let reason = "remote shutdown request";
497
+ try {
498
+ const body = (await request.json()) as unknown;
499
+ if (typeof body === "object" && body !== null && "reason" in body) {
500
+ const maybeReason = (body as Record<string, unknown>)["reason"];
501
+ if (typeof maybeReason === "string" && maybeReason.length > 0) {
502
+ reason = maybeReason;
503
+ }
504
+ }
505
+ } catch {
506
+ // No body or invalid JSON — use default reason
507
+ }
508
+
509
+ options.requestShutdown(reason);
510
+ return jsonResponse({ status: "shutting_down" }, 200);
511
+ }
512
+
513
+ // --- New REST endpoints (Phase 1c) ---
514
+
515
+ // GET /instances/:id
516
+ if (pathname.startsWith("/instances/")) {
517
+ if (request.method !== "GET") {
518
+ return plainResponse("Method Not Allowed", 405, "GET");
519
+ }
520
+ const instanceId = pathname.slice("/instances/".length);
521
+ if (!instanceId) {
522
+ return jsonResponse(
523
+ { message: "Missing instance ID.", error: "Use /instances/{id}" },
524
+ 400,
525
+ );
526
+ }
527
+ const detail = engine.getInstanceDetail(instanceId);
528
+ if (!detail) {
529
+ return jsonResponse(
530
+ { message: `Instance "${instanceId}" not found.`, error: "Check the instance ID." },
531
+ 404,
532
+ );
533
+ }
534
+ return jsonResponse(detail, 200);
535
+ }
536
+
537
+ // GET /models/:name
538
+ if (pathname.startsWith("/models/")) {
539
+ if (request.method !== "GET") {
540
+ return plainResponse("Method Not Allowed", 405, "GET");
541
+ }
542
+ const modelName = pathname.slice("/models/".length);
543
+ if (!modelName) {
544
+ return jsonResponse({ message: "Missing model name.", error: "Use /models/{name}" }, 400);
545
+ }
546
+ const schema = engine.getModelSchema(modelName);
547
+ if (!schema) {
548
+ return jsonResponse(
549
+ { message: `Model "${modelName}" not found.`, error: "Check the model name." },
550
+ 404,
551
+ );
552
+ }
553
+ return jsonResponse(schema, 200);
554
+ }
555
+
556
+ // GET /events
557
+ if (pathname === "/events") {
558
+ if (request.method !== "GET") {
559
+ return plainResponse("Method Not Allowed", 405, "GET");
560
+ }
561
+ const typeParam = url.searchParams.get("type");
562
+ if (typeParam && !VALID_EVENT_TYPES.has(typeParam)) {
563
+ return jsonResponse(
564
+ {
565
+ message: `Invalid event type "${typeParam}".`,
566
+ error: `Valid types: ${[...VALID_EVENT_TYPES].join(", ")}`,
567
+ },
568
+ 400,
569
+ );
570
+ }
571
+ const instanceIdParam = url.searchParams.get("instanceId");
572
+ const limitRaw = url.searchParams.get("limit");
573
+ const cursorRaw = url.searchParams.get("cursor");
574
+ const query: EventLogQuery = {};
575
+ if (typeParam)
576
+ (query as { type: EventLogQuery["type"] }).type = typeParam as EventLogQuery["type"];
577
+ if (instanceIdParam) (query as { instanceId: string }).instanceId = instanceIdParam;
578
+ const parsedLimit = limitRaw ? Number.parseInt(limitRaw, 10) : 0;
579
+ if (parsedLimit > 0 && Number.isFinite(parsedLimit))
580
+ (query as { limit: number }).limit = parsedLimit;
581
+ const parsedCursor = cursorRaw ? Number.parseInt(cursorRaw, 10) : Number.NaN;
582
+ if (Number.isFinite(parsedCursor)) (query as { cursor: number }).cursor = parsedCursor;
583
+ return jsonResponse(engine.getEventLog(query), 200);
584
+ }
585
+
586
+ // GET /catalog/templates
587
+ if (pathname === "/catalog/templates") {
588
+ if (request.method !== "GET") {
589
+ return plainResponse("Method Not Allowed", 405, "GET");
590
+ }
591
+ const catalog = options.getCatalog ? options.getCatalog() : [];
592
+ return jsonResponse(catalog, 200);
593
+ }
594
+
595
+ // GET /running-templates
596
+ if (pathname === "/running-templates") {
597
+ if (request.method !== "GET") {
598
+ return plainResponse("Method Not Allowed", 405, "GET");
599
+ }
600
+ return jsonResponse(engine.getRunningTemplates(), 200);
601
+ }
602
+
603
+ // --- Console static file serving ---
604
+ if (consoleIndexFile && consoleDir) {
605
+ if (pathname !== "/") {
606
+ // Resolve and verify the path stays within consoleDir to prevent traversal.
607
+ const resolvedFile = resolve(consoleDir, `.${pathname}`);
608
+ if (resolvedFile.startsWith(`${consoleDir}/`)) {
609
+ const file = Bun.file(resolvedFile);
610
+ if (await file.exists()) {
611
+ return new Response(file);
612
+ }
613
+ }
614
+ }
615
+ // SPA fallback: serve pre-verified index.html
616
+ return new Response(consoleIndexFile);
292
617
  }
293
618
 
294
619
  return plainResponse("Not Found", 404);
295
620
  },
621
+ websocket: wsHub
622
+ ? {
623
+ open(ws: import("bun").ServerWebSocket<unknown>) {
624
+ wsHub.onOpen(ws as import("bun").ServerWebSocket<WSClientData>);
625
+ },
626
+ message(ws: import("bun").ServerWebSocket<unknown>, message: string | Buffer) {
627
+ wsHub.onMessage(ws as import("bun").ServerWebSocket<WSClientData>, message);
628
+ },
629
+ close(ws: import("bun").ServerWebSocket<unknown>) {
630
+ wsHub.onClose(ws as import("bun").ServerWebSocket<WSClientData>);
631
+ },
632
+ }
633
+ : {
634
+ open() {},
635
+ message() {},
636
+ close() {},
637
+ },
296
638
  });
297
639
 
298
640
  return {
299
641
  endpoint: `http://${host}:${server.port}`,
642
+ wsHub,
300
643
  stop() {
644
+ wsHub?.close();
301
645
  server.stop(true);
302
646
  },
303
647
  };
304
648
  }
305
649
 
650
+ export type {
651
+ AdminEngine,
652
+ AdminServerHandle,
653
+ AdminServerNetworkOptions,
654
+ AdminServerOptions,
655
+ AdminServerProtocolOptions,
656
+ AdminServerWSOptions,
657
+ };
306
658
  export { startAdminServer };
307
- export type { AdminEngine, AdminServerHandle, AdminServerOptions };