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.
- package/README.md +116 -30
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-BXtW73cF.css +2 -0
- package/console-dist/assets/index-ChSocZFW.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +20 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +10 -7
- package/src/__tests__/integration/executable-methods-runner.ts +52 -45
- package/src/__tests__/integration/executable-methods.integration.ts +609 -47
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +1 -1
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +22 -1
- package/src/commands/models.ts +14 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +976 -192
- package/src/commands/status.test.ts +3 -1
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
package/src/lib/admin-server.ts
CHANGED
|
@@ -1,25 +1,88 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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(): {
|
|
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
|
-
|
|
12
|
-
|
|
47
|
+
/** Network binding configuration. */
|
|
48
|
+
interface AdminServerNetworkOptions {
|
|
13
49
|
readonly host: string;
|
|
14
50
|
readonly port: number;
|
|
15
51
|
readonly tickIntervalMs: number;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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 };
|