@helioslx/core 0.2.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 (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CONTRIBUTING.md +83 -0
  3. package/LICENSE +256 -0
  4. package/README.md +159 -0
  5. package/SECURITY.md +67 -0
  6. package/dist/contracts-C4kpAdZq.d.ts +265 -0
  7. package/dist/contracts-C4kpAdZq.d.ts.map +1 -0
  8. package/dist/http.d.ts +643 -0
  9. package/dist/http.d.ts.map +1 -0
  10. package/dist/http.js +670 -0
  11. package/dist/http.js.map +1 -0
  12. package/dist/index.d.ts +40 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +270 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/node.d.ts +114 -0
  17. package/dist/node.d.ts.map +1 -0
  18. package/dist/node.js +334 -0
  19. package/dist/node.js.map +1 -0
  20. package/dist/redis.d.ts +46 -0
  21. package/dist/redis.d.ts.map +1 -0
  22. package/dist/redis.js +174 -0
  23. package/dist/redis.js.map +1 -0
  24. package/dist/source-DB1oq2GT.js +884 -0
  25. package/dist/source-DB1oq2GT.js.map +1 -0
  26. package/dist/source-D_XNWXS9.d.ts +76 -0
  27. package/dist/source-D_XNWXS9.d.ts.map +1 -0
  28. package/dist/testing.d.ts +38 -0
  29. package/dist/testing.d.ts.map +1 -0
  30. package/dist/testing.js +98 -0
  31. package/dist/testing.js.map +1 -0
  32. package/dist/validation-BdsVeyAE.js +151 -0
  33. package/dist/validation-BdsVeyAE.js.map +1 -0
  34. package/examples/embedded-elysia-http.ts +30 -0
  35. package/examples/full-frame-fade.ts +25 -0
  36. package/examples/graceful-shutdown.ts +19 -0
  37. package/examples/quickstart.ts +30 -0
  38. package/examples/redis.ts +30 -0
  39. package/examples/sparse-channels.ts +23 -0
  40. package/examples/viewer-subscription.ts +38 -0
  41. package/examples/viewer-tui.ts +201 -0
  42. package/package.json +108 -0
  43. package/src/contracts.ts +302 -0
  44. package/src/http.ts +1101 -0
  45. package/src/index.ts +61 -0
  46. package/src/memory-store.ts +45 -0
  47. package/src/node.ts +578 -0
  48. package/src/output-engine.ts +778 -0
  49. package/src/redis.ts +258 -0
  50. package/src/source.ts +502 -0
  51. package/src/testing.ts +139 -0
  52. package/src/validation.ts +328 -0
  53. package/src/viewer.ts +368 -0
package/src/http.ts ADDED
@@ -0,0 +1,1101 @@
1
+ import { Elysia, t } from "elysia";
2
+ import { openapi } from "@elysiajs/openapi";
3
+ import { node } from "@elysiajs/node";
4
+ import {
5
+ SLOT_COUNT,
6
+ type ChannelValues,
7
+ type ChannelWrite,
8
+ type SacnSourceContract,
9
+ type TransitionWrite,
10
+ type UniverseContract,
11
+ type ViewerPacket,
12
+ type ViewerServiceContract,
13
+ } from "./contracts.js";
14
+ import {
15
+ DependencyUnavailableError,
16
+ PersistenceError,
17
+ SacnLifecycleError,
18
+ SacnValidationError,
19
+ TransportError,
20
+ } from "./validation.js";
21
+
22
+ export interface HttpAuthContext {
23
+ readonly request: Request;
24
+ }
25
+
26
+ export type HttpAuthHook = (
27
+ context: HttpAuthContext,
28
+ ) => boolean | Response | void | Promise<boolean | Response | void>;
29
+
30
+ export interface SacnHttpAdapterOptions {
31
+ readonly source: SacnSourceContract;
32
+ readonly viewer?: ViewerServiceContract;
33
+ /** Defaults to `/sacn`. */
34
+ readonly prefix?: string;
35
+ /** Authentication hook; returning false produces 401, and Response is forwarded. */
36
+ readonly auth?: HttpAuthHook;
37
+ /** Called for every normalized viewer packet; useful for WebSocket/SSE bridges. */
38
+ readonly onViewerPacket?: (packet: ViewerPacket) => void;
39
+ /** Value used by deprecated compatibility routes. */
40
+ readonly sunset?: string;
41
+ /** OpenAPI document path. Defaults to `/openapi`. */
42
+ readonly openapiPath?: string;
43
+ /** Maximum concurrent viewer WebSocket clients. Defaults to 64. */
44
+ readonly maxWebSocketClients?: number;
45
+ /** Coalesced packet queue per WebSocket client. Defaults to 32 universes. */
46
+ readonly webSocketQueueCapacity?: number;
47
+ }
48
+
49
+ interface RouteSet {
50
+ headers: Record<string, string | number>;
51
+ status?: number | string;
52
+ }
53
+
54
+ interface OutputParams {
55
+ universe: string;
56
+ priority: string;
57
+ }
58
+
59
+ const outputParamsSchema = t.Object({
60
+ universe: t.String({ pattern: "^-?\\d+$" }),
61
+ priority: t.String({ pattern: "^-?\\d+$" }),
62
+ });
63
+ const frameValuesSchema = t.Array(t.Integer({ minimum: 0, maximum: 255 }), {
64
+ minItems: SLOT_COUNT,
65
+ maxItems: SLOT_COUNT,
66
+ });
67
+ const outputOptionsSchema = {
68
+ cid: t.Optional(t.String()),
69
+ idleFps: t.Optional(t.Number({ exclusiveMinimum: 0, maximum: 1000 })),
70
+ sourceName: t.Optional(t.String({ minLength: 1, maxLength: 64 })),
71
+ };
72
+ const frameBodySchema = t.Object({
73
+ values: frameValuesSchema,
74
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
75
+ ...outputOptionsSchema,
76
+ });
77
+ const upsertBodySchema = t.Object({
78
+ initialValues: t.Optional(frameValuesSchema),
79
+ values: t.Optional(frameValuesSchema),
80
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
81
+ ...outputOptionsSchema,
82
+ });
83
+ const channelWritesSchema = t.Array(
84
+ t.Object({
85
+ channel: t.Integer({ minimum: 1, maximum: SLOT_COUNT }),
86
+ value: t.Integer({ minimum: 0, maximum: 255 }),
87
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
88
+ }),
89
+ { minItems: 1 },
90
+ );
91
+ const channelsBodySchema = t.Union([
92
+ t.Object({
93
+ channels: t.Union([
94
+ channelWritesSchema,
95
+ t.Record(t.String(), t.Integer({ minimum: 0, maximum: 255 })),
96
+ ]),
97
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
98
+ ...outputOptionsSchema,
99
+ }),
100
+ t.Object({
101
+ changes: channelWritesSchema,
102
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
103
+ ...outputOptionsSchema,
104
+ }),
105
+ ]);
106
+ const outputSnapshotSchema = t.Object({
107
+ activeTransitions: t.Integer({ minimum: 0 }),
108
+ cid: t.String(),
109
+ current: frameValuesSchema,
110
+ dirty: t.Boolean(),
111
+ frameMode: t.Union([t.Literal("active"), t.Literal("idle")]),
112
+ idleFps: t.Number(),
113
+ lastError: t.Union([t.String(), t.Null()]),
114
+ lastSent: frameValuesSchema,
115
+ lastSentAt: t.Union([t.Number(), t.Null()]),
116
+ nextDueAt: t.Number(),
117
+ priority: t.Integer(),
118
+ sequence: t.Integer(),
119
+ sourceName: t.Union([t.String(), t.Null()]),
120
+ target: frameValuesSchema,
121
+ universe: t.Integer(),
122
+ updatedAt: t.Number(),
123
+ });
124
+ const errorSchema = t.Object({ error: t.String() });
125
+ const viewerStateSchema = t.Object({
126
+ universes: t.Array(t.Integer({ minimum: 1, maximum: 63_999 })),
127
+ });
128
+ const engineTelemetrySchema = t.Object({
129
+ closed: t.Boolean(),
130
+ running: t.Boolean(),
131
+ outputCount: t.Integer({ minimum: 0 }),
132
+ outputs: t.Array(outputSnapshotSchema),
133
+ loopIterations: t.Integer({ minimum: 0 }),
134
+ lastLoopStartedAt: t.Union([t.Number(), t.Null()]),
135
+ lastLoopCompletedAt: t.Union([t.Number(), t.Null()]),
136
+ lastLoopDurationMs: t.Union([t.Number(), t.Null()]),
137
+ transport: t.Object({
138
+ sendAttempts: t.Integer({ minimum: 0 }),
139
+ sendFailures: t.Integer({ minimum: 0 }),
140
+ sendRetries: t.Integer({ minimum: 0 }),
141
+ sendSuccesses: t.Integer({ minimum: 0 }),
142
+ sendTimeouts: t.Integer({ minimum: 0 }),
143
+ }),
144
+ });
145
+ const viewerTelemetrySchema = t.Object({
146
+ droppedUpdates: t.Integer({ minimum: 0 }),
147
+ packetsReceived: t.Integer({ minimum: 0 }),
148
+ selectedUniverses: t.Array(t.Integer()),
149
+ streamCount: t.Integer({ minimum: 0 }),
150
+ });
151
+ const viewerUniverseParamsSchema = t.Object({
152
+ universe: t.String({ pattern: "^-?\\d+$" }),
153
+ });
154
+ const errorResponseDocument = (description: string) => ({
155
+ description,
156
+ content: {
157
+ "application/json": {
158
+ schema: { $ref: "#/components/schemas/Error" },
159
+ },
160
+ },
161
+ });
162
+ const outputResponseDocument = {
163
+ 200: {
164
+ description: "Output snapshot",
165
+ content: {
166
+ "application/json": {
167
+ schema: { $ref: "#/components/schemas/OutputSnapshot" },
168
+ },
169
+ },
170
+ },
171
+ 400: errorResponseDocument("Invalid request"),
172
+ 409: errorResponseDocument("Lifecycle conflict"),
173
+ 503: errorResponseDocument("Transport or persistence unavailable"),
174
+ };
175
+ const outputLookupResponseDocument = {
176
+ ...outputResponseDocument,
177
+ 404: errorResponseDocument("Output not found"),
178
+ };
179
+ const clearResponseDocument = {
180
+ 200: {
181
+ description: "Output cleared",
182
+ content: {
183
+ "application/json": {
184
+ schema: {
185
+ type: "object",
186
+ required: ["ok"],
187
+ properties: { ok: { type: "boolean", const: true } },
188
+ },
189
+ },
190
+ },
191
+ },
192
+ 400: errorResponseDocument("Invalid request"),
193
+ 404: errorResponseDocument("Output not found"),
194
+ 409: errorResponseDocument("Lifecycle conflict"),
195
+ 503: errorResponseDocument("Transport or persistence unavailable"),
196
+ };
197
+ const componentResponse = (schema: string, description: string) => ({
198
+ 200: {
199
+ description,
200
+ content: {
201
+ "application/json": {
202
+ schema: { $ref: `#/components/schemas/${schema}` },
203
+ },
204
+ },
205
+ },
206
+ 400: errorResponseDocument("Invalid request"),
207
+ 409: errorResponseDocument("Lifecycle conflict"),
208
+ 503: errorResponseDocument("Dependency unavailable"),
209
+ });
210
+
211
+ const integerParam = (value: string, label: string): number => {
212
+ if (!/^-?\d+$/.test(value)) {
213
+ throw new SacnValidationError(
214
+ `${label} must be an integer.`,
215
+ label === "Universe" ? "INVALID_UNIVERSE" : "INVALID_PRIORITY",
216
+ );
217
+ }
218
+ return Number(value);
219
+ };
220
+
221
+ const addressOf = (params: OutputParams) => ({
222
+ universe: integerParam(params.universe, "Universe"),
223
+ priority: integerParam(params.priority, "Priority"),
224
+ });
225
+
226
+ const bodyObject = (body: unknown): Record<string, unknown> => {
227
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
228
+ throw new SacnValidationError(
229
+ "Request body must be an object.",
230
+ "INVALID_FRAME",
231
+ );
232
+ }
233
+ return body as Record<string, unknown>;
234
+ };
235
+
236
+ const messageOf = (error: unknown): string =>
237
+ error instanceof Error ? error.message : "Internal server error.";
238
+
239
+ const statusOf = (error: unknown): number => {
240
+ if (error instanceof SacnValidationError) return 400;
241
+ if (error instanceof SacnLifecycleError) return 409;
242
+ if (
243
+ error instanceof DependencyUnavailableError ||
244
+ error instanceof PersistenceError ||
245
+ error instanceof TransportError
246
+ ) {
247
+ return 503;
248
+ }
249
+ return 500;
250
+ };
251
+
252
+ const fail = (set: RouteSet, error: unknown): { error: string } => {
253
+ set.status = statusOf(error);
254
+ return { error: messageOf(error) };
255
+ };
256
+
257
+ const deprecated = (
258
+ set: RouteSet,
259
+ successor: string,
260
+ sunset: string,
261
+ ): void => {
262
+ set.headers.deprecation = "true";
263
+ set.headers.link = `<${successor}>; rel="successor-version"`;
264
+ set.headers.sunset = sunset;
265
+ };
266
+
267
+ const channelsFrom = (
268
+ body: unknown,
269
+ ): {
270
+ mode: "set" | "fade" | "transition";
271
+ values?: ChannelValues;
272
+ writes?: readonly TransitionWrite[];
273
+ durationMs?: number;
274
+ idleFps?: number;
275
+ sourceName?: string;
276
+ cid?: string;
277
+ } => {
278
+ const record = bodyObject(body);
279
+ const sharedDuration =
280
+ typeof record.durationMs === "number" ? record.durationMs : undefined;
281
+ const value = record.channels ?? record.changes;
282
+ const options = {
283
+ ...(sharedDuration === undefined ? {} : { durationMs: sharedDuration }),
284
+ ...(record.idleFps === undefined
285
+ ? {}
286
+ : { idleFps: record.idleFps as number }),
287
+ ...(record.sourceName === undefined
288
+ ? {}
289
+ : { sourceName: record.sourceName as string }),
290
+ ...(record.cid === undefined ? {} : { cid: record.cid as string }),
291
+ };
292
+
293
+ if (
294
+ value !== null &&
295
+ typeof value === "object" &&
296
+ !Array.isArray(value)
297
+ ) {
298
+ const values: Record<number, number> = {};
299
+ for (const [key, channelValue] of Object.entries(value)) {
300
+ values[Number(key)] = channelValue as number;
301
+ }
302
+ if (sharedDuration !== undefined && sharedDuration > 0) {
303
+ return { mode: "fade", values, ...options };
304
+ }
305
+ return { mode: "set", values, ...options };
306
+ }
307
+
308
+ if (!Array.isArray(value)) {
309
+ throw new SacnValidationError(
310
+ "Body must contain a channels array or map.",
311
+ "INVALID_CHANNEL",
312
+ );
313
+ }
314
+
315
+ const writes: ChannelWrite[] = value.map((item) => {
316
+ const change = bodyObject(item);
317
+ const durationMs =
318
+ typeof change.durationMs === "number" ? change.durationMs : undefined;
319
+ return {
320
+ channel: change.channel as number,
321
+ value: change.value as number,
322
+ ...(durationMs === undefined ? {} : { durationMs }),
323
+ };
324
+ });
325
+
326
+ const hasPerChannelDuration = writes.some(
327
+ (write) => write.durationMs !== undefined && write.durationMs > 0,
328
+ );
329
+ if (hasPerChannelDuration) {
330
+ return {
331
+ mode: "transition",
332
+ writes: writes.map((write) => ({
333
+ channel: write.channel,
334
+ value: write.value,
335
+ durationMs: write.durationMs ?? sharedDuration ?? 0,
336
+ })),
337
+ ...options,
338
+ };
339
+ }
340
+ const values: Record<number, number> = {};
341
+ for (const write of writes) values[write.channel] = write.value;
342
+ if (sharedDuration !== undefined && sharedDuration > 0) {
343
+ return { mode: "fade", values, ...options };
344
+ }
345
+ return { mode: "set", values, ...options };
346
+ };
347
+
348
+ const frameFrom = (
349
+ body: unknown,
350
+ ): {
351
+ values: readonly number[] | Uint8Array;
352
+ durationMs?: number;
353
+ idleFps?: number;
354
+ sourceName?: string;
355
+ cid?: string;
356
+ } => {
357
+ const record = bodyObject(body);
358
+ const values = record.values ?? record.initialValues;
359
+ if (!Array.isArray(values) && !(values instanceof Uint8Array)) {
360
+ throw new SacnValidationError(
361
+ "Body must contain a values array.",
362
+ "INVALID_FRAME",
363
+ );
364
+ }
365
+ const durationMs = record.durationMs;
366
+ return {
367
+ values,
368
+ ...(durationMs === undefined ? {} : { durationMs: durationMs as number }),
369
+ ...(record.idleFps === undefined
370
+ ? {}
371
+ : { idleFps: record.idleFps as number }),
372
+ ...(record.sourceName === undefined
373
+ ? {}
374
+ : { sourceName: record.sourceName as string }),
375
+ ...(record.cid === undefined ? {} : { cid: record.cid as string }),
376
+ };
377
+ };
378
+
379
+ const universeOf = (
380
+ source: SacnSourceContract,
381
+ params: OutputParams,
382
+ extras: {
383
+ cid?: string;
384
+ idleFps?: number;
385
+ sourceName?: string;
386
+ } = {},
387
+ ): UniverseContract => {
388
+ const address = addressOf(params);
389
+ return source.universe(address.universe, {
390
+ priority: address.priority,
391
+ ...extras,
392
+ });
393
+ };
394
+
395
+ const applyChannels = async (
396
+ universe: UniverseContract,
397
+ parsed: ReturnType<typeof channelsFrom>,
398
+ ) => {
399
+ if (parsed.mode === "fade" && parsed.values) {
400
+ return universe.fadeChannels(parsed.values, {
401
+ durationMs: parsed.durationMs ?? 0,
402
+ });
403
+ }
404
+ if (parsed.mode === "transition" && parsed.writes) {
405
+ return universe.transition(parsed.writes);
406
+ }
407
+ if (parsed.values) {
408
+ return universe.setChannels(parsed.values);
409
+ }
410
+ throw new SacnValidationError(
411
+ "Body must contain channel values.",
412
+ "INVALID_CHANNEL",
413
+ );
414
+ };
415
+
416
+ /**
417
+ * Creates an unbound Elysia route plugin. CORS and server binding deliberately
418
+ * remain the host application's responsibility.
419
+ */
420
+ export const createSacnHttpAdapter = (options: SacnHttpAdapterOptions) => {
421
+ const prefix = options.prefix ?? "/sacn";
422
+ const sunset = options.sunset ?? "Wed, 01 Jul 2027 00:00:00 GMT";
423
+ const maxWebSocketClients = options.maxWebSocketClients ?? 64;
424
+ const webSocketQueueCapacity = options.webSocketQueueCapacity ?? 32;
425
+ if (!Number.isInteger(maxWebSocketClients) || maxWebSocketClients < 1) {
426
+ throw new SacnValidationError(
427
+ "Maximum WebSocket clients must be a positive integer.",
428
+ "INVALID_FRAME",
429
+ );
430
+ }
431
+ if (!Number.isInteger(webSocketQueueCapacity) || webSocketQueueCapacity < 1) {
432
+ throw new SacnValidationError(
433
+ "WebSocket queue capacity must be a positive integer.",
434
+ "INVALID_FRAME",
435
+ );
436
+ }
437
+ interface ViewerClient {
438
+ readonly socket: {
439
+ send(payload: unknown): unknown;
440
+ close(): unknown;
441
+ };
442
+ readonly pending: Map<number, ViewerPacket>;
443
+ scheduled: boolean;
444
+ }
445
+ const clients = new Set<ViewerClient>();
446
+ const closeClient = (client: ViewerClient): void => {
447
+ clients.delete(client);
448
+ client.pending.clear();
449
+ try {
450
+ client.socket.close();
451
+ } catch {
452
+ // The client is already discarded.
453
+ }
454
+ };
455
+ const flushClient = (client: ViewerClient): void => {
456
+ client.scheduled = false;
457
+ const packets = [...client.pending.values()];
458
+ client.pending.clear();
459
+ for (const packet of packets) {
460
+ try {
461
+ const status = client.socket.send({ packet, type: "viewer-packet" });
462
+ if (typeof status === "number" && status <= 0) {
463
+ closeClient(client);
464
+ return;
465
+ }
466
+ } catch {
467
+ closeClient(client);
468
+ return;
469
+ }
470
+ }
471
+ };
472
+ const dispatchViewerPacket = (packet: ViewerPacket): void => {
473
+ try {
474
+ options.onViewerPacket?.(packet);
475
+ } catch {
476
+ // Host packet hooks are isolated from WebSocket fanout.
477
+ }
478
+ for (const client of clients) {
479
+ if (client.pending.has(packet.universe)) {
480
+ client.pending.delete(packet.universe);
481
+ } else if (client.pending.size >= webSocketQueueCapacity) {
482
+ const oldestUniverse = client.pending.keys().next().value;
483
+ if (oldestUniverse !== undefined) client.pending.delete(oldestUniverse);
484
+ }
485
+ client.pending.set(packet.universe, packet);
486
+ if (!client.scheduled) {
487
+ client.scheduled = true;
488
+ queueMicrotask(() => flushClient(client));
489
+ }
490
+ }
491
+ };
492
+ const unsubscribePackets =
493
+ options.viewer
494
+ ? options.viewer.subscribe(dispatchViewerPacket)
495
+ : undefined;
496
+
497
+ const app = new Elysia({ adapter: node(), prefix })
498
+ .use(
499
+ openapi({
500
+ path: options.openapiPath ?? "/openapi",
501
+ documentation: {
502
+ components: {
503
+ schemas: {
504
+ Error: errorSchema as never,
505
+ EngineTelemetry: engineTelemetrySchema as never,
506
+ OutputSnapshot: outputSnapshotSchema as never,
507
+ ViewerState: viewerStateSchema as never,
508
+ ViewerTelemetry: viewerTelemetrySchema as never,
509
+ },
510
+ },
511
+ info: {
512
+ title: "@helioslx/core sACN API",
513
+ version: "0.1.0",
514
+ },
515
+ },
516
+ }),
517
+ )
518
+ .onBeforeHandle(async ({ request, set }) => {
519
+ try {
520
+ const result = await options.auth?.({ request });
521
+ if (result instanceof Response) return result;
522
+ if (result === false) {
523
+ set.status = 401;
524
+ return { error: "Unauthorized." };
525
+ }
526
+ } catch (error) {
527
+ return fail(set, error);
528
+ }
529
+ })
530
+ .onError(({ code, error, set }) => {
531
+ if (code === "VALIDATION") {
532
+ set.status = 400;
533
+ return { error: error.message };
534
+ }
535
+ })
536
+ .onStop(() => {
537
+ unsubscribePackets?.();
538
+ clients.clear();
539
+ })
540
+ .get("/health/live", () => ({ status: "ok" as const }), {
541
+ response: { 200: t.Object({ status: t.Literal("ok") }) },
542
+ })
543
+ .get(
544
+ "/health/ready",
545
+ ({ set }) => {
546
+ const telemetry = options.source.getTelemetry();
547
+ if (!telemetry.running || telemetry.closed) {
548
+ set.status = 503;
549
+ return { status: "not-ready" as const };
550
+ }
551
+ return { status: "ready" as const };
552
+ },
553
+ {
554
+ response: {
555
+ 200: t.Object({ status: t.Literal("ready") }),
556
+ 503: t.Object({ status: t.Literal("not-ready") }),
557
+ },
558
+ },
559
+ )
560
+ .get("/universes", async ({ set }) => {
561
+ try {
562
+ return await options.source.listUniverses();
563
+ } catch (error) {
564
+ return fail(set, error);
565
+ }
566
+ }, {
567
+ detail: {
568
+ responses: {
569
+ 200: {
570
+ description: "Active outputs",
571
+ content: {
572
+ "application/json": {
573
+ schema: {
574
+ type: "array",
575
+ items: { $ref: "#/components/schemas/OutputSnapshot" },
576
+ },
577
+ },
578
+ },
579
+ },
580
+ 409: errorResponseDocument("Lifecycle conflict"),
581
+ 503: errorResponseDocument("Persistence unavailable"),
582
+ } as never,
583
+ },
584
+ })
585
+ .get("/engine/telemetry", () => options.source.getTelemetry(), {
586
+ detail: {
587
+ responses: componentResponse(
588
+ "EngineTelemetry",
589
+ "Engine telemetry",
590
+ ) as never,
591
+ },
592
+ })
593
+ .get(
594
+ "/universes/:universe/priorities/:priority",
595
+ async ({ params, set }) => {
596
+ try {
597
+ const output = await universeOf(options.source, params).get();
598
+ if (!output) {
599
+ set.status = 404;
600
+ return { error: "Output not found." };
601
+ }
602
+ return output;
603
+ } catch (error) {
604
+ return fail(set, error);
605
+ }
606
+ },
607
+ {
608
+ detail: { responses: outputLookupResponseDocument as never },
609
+ params: outputParamsSchema,
610
+ },
611
+ )
612
+ .put(
613
+ "/universes/:universe/priorities/:priority",
614
+ async ({ body, params, set }) => {
615
+ try {
616
+ const record = bodyObject(body);
617
+ const frame = frameFrom({
618
+ ...record,
619
+ values:
620
+ record.initialValues ??
621
+ record.values ??
622
+ Array.from({ length: SLOT_COUNT }, () => 0),
623
+ });
624
+ const universe = universeOf(options.source, params, {
625
+ ...(frame.cid === undefined ? {} : { cid: frame.cid }),
626
+ ...(frame.idleFps === undefined ? {} : { idleFps: frame.idleFps }),
627
+ ...(frame.sourceName === undefined
628
+ ? {}
629
+ : { sourceName: frame.sourceName }),
630
+ });
631
+ const existing = await universe.get();
632
+ const supplied = record.initialValues ?? record.values;
633
+ const values =
634
+ Array.isArray(supplied) || supplied instanceof Uint8Array
635
+ ? supplied
636
+ : existing?.target ?? Array.from({ length: SLOT_COUNT }, () => 0);
637
+ return await universe.write(values, {
638
+ ...(frame.durationMs === undefined
639
+ ? {}
640
+ : { durationMs: frame.durationMs }),
641
+ });
642
+ } catch (error) {
643
+ return fail(set, error);
644
+ }
645
+ },
646
+ {
647
+ body: upsertBodySchema,
648
+ detail: { responses: outputResponseDocument as never },
649
+ params: outputParamsSchema,
650
+ },
651
+ )
652
+ .post(
653
+ "/universes/:universe/priorities/:priority/channels",
654
+ async ({ body, params, set }) => {
655
+ try {
656
+ const parsed = channelsFrom(body);
657
+ const universe = universeOf(options.source, params, {
658
+ ...(parsed.cid === undefined ? {} : { cid: parsed.cid }),
659
+ ...(parsed.idleFps === undefined ? {} : { idleFps: parsed.idleFps }),
660
+ ...(parsed.sourceName === undefined
661
+ ? {}
662
+ : { sourceName: parsed.sourceName }),
663
+ });
664
+ return await applyChannels(universe, parsed);
665
+ } catch (error) {
666
+ return fail(set, error);
667
+ }
668
+ },
669
+ {
670
+ body: channelsBodySchema,
671
+ detail: { responses: outputResponseDocument as never },
672
+ params: outputParamsSchema,
673
+ },
674
+ )
675
+ .post(
676
+ "/universes/:universe/priorities/:priority/frame",
677
+ async ({ body, params, set }) => {
678
+ try {
679
+ const frame = frameFrom(body);
680
+ const universe = universeOf(options.source, params, {
681
+ ...(frame.cid === undefined ? {} : { cid: frame.cid }),
682
+ ...(frame.idleFps === undefined ? {} : { idleFps: frame.idleFps }),
683
+ ...(frame.sourceName === undefined
684
+ ? {}
685
+ : { sourceName: frame.sourceName }),
686
+ });
687
+ return await universe.write(frame.values, {
688
+ ...(frame.durationMs === undefined
689
+ ? {}
690
+ : { durationMs: frame.durationMs }),
691
+ });
692
+ } catch (error) {
693
+ return fail(set, error);
694
+ }
695
+ },
696
+ {
697
+ body: frameBodySchema,
698
+ detail: { responses: outputResponseDocument as never },
699
+ params: outputParamsSchema,
700
+ },
701
+ )
702
+ .delete(
703
+ "/universes/:universe/priorities/:priority",
704
+ async ({ params, set }) => {
705
+ try {
706
+ const removed = await universeOf(options.source, params).clear();
707
+ if (!removed) {
708
+ set.status = 404;
709
+ return { error: "Output not found." };
710
+ }
711
+ return { ok: true as const };
712
+ } catch (error) {
713
+ return fail(set, error);
714
+ }
715
+ },
716
+ {
717
+ detail: {
718
+ responses: {
719
+ 200: {
720
+ description: "Output cleared",
721
+ content: {
722
+ "application/json": {
723
+ schema: {
724
+ type: "object",
725
+ required: ["ok"],
726
+ properties: { ok: { type: "boolean", const: true } },
727
+ },
728
+ },
729
+ },
730
+ },
731
+ 400: errorResponseDocument("Invalid request"),
732
+ 404: errorResponseDocument("Output not found"),
733
+ 409: errorResponseDocument("Lifecycle conflict"),
734
+ 503: errorResponseDocument("Transport or persistence unavailable"),
735
+ } as never,
736
+ },
737
+ params: outputParamsSchema,
738
+ },
739
+ );
740
+
741
+ if (options.viewer) {
742
+ app
743
+ .get("/viewer/universes", () => ({
744
+ universes: options.viewer?.getSelectedUniverses() ?? [],
745
+ }), {
746
+ detail: {
747
+ responses: componentResponse(
748
+ "ViewerState",
749
+ "Selected viewer universes",
750
+ ) as never,
751
+ },
752
+ })
753
+ .get("/viewer/telemetry", () => options.viewer?.getTelemetry(), {
754
+ detail: {
755
+ responses: componentResponse(
756
+ "ViewerTelemetry",
757
+ "Viewer telemetry",
758
+ ) as never,
759
+ },
760
+ })
761
+ .put(
762
+ "/viewer/universes",
763
+ async ({ body, set }) => {
764
+ try {
765
+ const record = bodyObject(body);
766
+ if (!Array.isArray(record.universes)) {
767
+ throw new SacnValidationError(
768
+ "Body must contain a universes array.",
769
+ "INVALID_UNIVERSE",
770
+ );
771
+ }
772
+ return {
773
+ universes: await options.viewer?.setSelectedUniverses(
774
+ record.universes as number[],
775
+ ),
776
+ };
777
+ } catch (error) {
778
+ return fail(set, error);
779
+ }
780
+ },
781
+ {
782
+ body: viewerStateSchema,
783
+ detail: {
784
+ responses: componentResponse(
785
+ "ViewerState",
786
+ "Updated viewer universes",
787
+ ) as never,
788
+ },
789
+ },
790
+ )
791
+ .post(
792
+ "/viewer/universes/:universe",
793
+ async ({ params, set }) => {
794
+ try {
795
+ return {
796
+ universes: await options.viewer?.addUniverse(
797
+ integerParam(params.universe, "Universe"),
798
+ ),
799
+ };
800
+ } catch (error) {
801
+ return fail(set, error);
802
+ }
803
+ },
804
+ {
805
+ detail: {
806
+ responses: componentResponse(
807
+ "ViewerState",
808
+ "Updated viewer universes",
809
+ ) as never,
810
+ },
811
+ params: viewerUniverseParamsSchema,
812
+ },
813
+ )
814
+ .delete(
815
+ "/viewer/universes/:universe",
816
+ async ({ params, set }) => {
817
+ try {
818
+ return {
819
+ universes: await options.viewer?.removeUniverse(
820
+ integerParam(params.universe, "Universe"),
821
+ ),
822
+ };
823
+ } catch (error) {
824
+ return fail(set, error);
825
+ }
826
+ },
827
+ {
828
+ detail: {
829
+ responses: componentResponse(
830
+ "ViewerState",
831
+ "Updated viewer universes",
832
+ ) as never,
833
+ },
834
+ params: viewerUniverseParamsSchema,
835
+ },
836
+ )
837
+ .delete(
838
+ "/viewer/universes",
839
+ async ({ set }) => {
840
+ try {
841
+ return {
842
+ universes: await options.viewer?.setSelectedUniverses([]),
843
+ };
844
+ } catch (error) {
845
+ return fail(set, error);
846
+ }
847
+ },
848
+ {
849
+ detail: {
850
+ responses: componentResponse(
851
+ "ViewerState",
852
+ "Cleared viewer universes",
853
+ ) as never,
854
+ },
855
+ },
856
+ )
857
+ .ws("/viewer/ws", {
858
+ open(ws) {
859
+ if (clients.size >= maxWebSocketClients) {
860
+ ws.close();
861
+ return;
862
+ }
863
+ const client: ViewerClient = {
864
+ socket: ws,
865
+ pending: new Map(),
866
+ scheduled: false,
867
+ };
868
+ clients.add(client);
869
+ ws.send({
870
+ type: "viewer-state",
871
+ viewer: {
872
+ universes: options.viewer?.getSelectedUniverses() ?? [],
873
+ },
874
+ });
875
+ },
876
+ close(ws) {
877
+ for (const client of clients) {
878
+ if (client.socket === ws) {
879
+ clients.delete(client);
880
+ client.pending.clear();
881
+ break;
882
+ }
883
+ }
884
+ },
885
+ message() {
886
+ // Viewer sockets are intentionally server-to-client only.
887
+ },
888
+ });
889
+ }
890
+
891
+ const mark = (set: RouteSet, successor: string): void =>
892
+ deprecated(set, `${prefix}${successor}`, sunset);
893
+
894
+ return app
895
+ .get("/outputs", async ({ set }) => {
896
+ mark(set, "/universes");
897
+ try {
898
+ return await options.source.listUniverses();
899
+ } catch (error) {
900
+ return fail(set, error);
901
+ }
902
+ }, {
903
+ detail: {
904
+ deprecated: true,
905
+ responses: {
906
+ 200: {
907
+ description: "Active outputs",
908
+ content: {
909
+ "application/json": {
910
+ schema: {
911
+ type: "array",
912
+ items: { $ref: "#/components/schemas/OutputSnapshot" },
913
+ },
914
+ },
915
+ },
916
+ },
917
+ } as never,
918
+ },
919
+ })
920
+ .get("/telemetry", ({ set }) => {
921
+ mark(set, "/engine/telemetry");
922
+ return options.source.getTelemetry();
923
+ }, {
924
+ detail: {
925
+ deprecated: true,
926
+ responses: componentResponse(
927
+ "EngineTelemetry",
928
+ "Engine telemetry",
929
+ ) as never,
930
+ },
931
+ })
932
+ .get(
933
+ "/outputs/:universe/:priority",
934
+ async ({ params, set }) => {
935
+ mark(
936
+ set,
937
+ `/universes/${params.universe}/priorities/${params.priority}`,
938
+ );
939
+ try {
940
+ const output = await universeOf(options.source, params).get();
941
+ if (!output) {
942
+ set.status = 404;
943
+ return { error: "Output not found." };
944
+ }
945
+ return output;
946
+ } catch (error) {
947
+ return fail(set, error);
948
+ }
949
+ },
950
+ {
951
+ detail: {
952
+ deprecated: true,
953
+ responses: outputLookupResponseDocument as never,
954
+ },
955
+ params: outputParamsSchema,
956
+ },
957
+ )
958
+ .put(
959
+ "/outputs/:universe/:priority",
960
+ async ({ body, params, set }) => {
961
+ mark(
962
+ set,
963
+ `/universes/${params.universe}/priorities/${params.priority}`,
964
+ );
965
+ try {
966
+ const record = bodyObject(body);
967
+ const frame = frameFrom({
968
+ ...record,
969
+ values:
970
+ record.initialValues ??
971
+ record.values ??
972
+ Array.from({ length: SLOT_COUNT }, () => 0),
973
+ });
974
+ const universe = universeOf(options.source, params, {
975
+ ...(frame.cid === undefined ? {} : { cid: frame.cid }),
976
+ ...(frame.idleFps === undefined ? {} : { idleFps: frame.idleFps }),
977
+ ...(frame.sourceName === undefined
978
+ ? {}
979
+ : { sourceName: frame.sourceName }),
980
+ });
981
+ const existing = await universe.get();
982
+ const supplied = record.initialValues ?? record.values;
983
+ const values =
984
+ Array.isArray(supplied) || supplied instanceof Uint8Array
985
+ ? supplied
986
+ : existing?.target ?? Array.from({ length: SLOT_COUNT }, () => 0);
987
+ return await universe.write(values, {
988
+ ...(frame.durationMs === undefined
989
+ ? {}
990
+ : { durationMs: frame.durationMs }),
991
+ });
992
+ } catch (error) {
993
+ return fail(set, error);
994
+ }
995
+ },
996
+ {
997
+ body: upsertBodySchema,
998
+ detail: {
999
+ deprecated: true,
1000
+ responses: outputResponseDocument as never,
1001
+ },
1002
+ params: outputParamsSchema,
1003
+ },
1004
+ )
1005
+ .post(
1006
+ "/outputs/:universe/:priority/channels",
1007
+ async ({ body, params, set }) => {
1008
+ mark(
1009
+ set,
1010
+ `/universes/${params.universe}/priorities/${params.priority}/channels`,
1011
+ );
1012
+ try {
1013
+ const parsed = channelsFrom(body);
1014
+ const universe = universeOf(options.source, params, {
1015
+ ...(parsed.cid === undefined ? {} : { cid: parsed.cid }),
1016
+ ...(parsed.idleFps === undefined ? {} : { idleFps: parsed.idleFps }),
1017
+ ...(parsed.sourceName === undefined
1018
+ ? {}
1019
+ : { sourceName: parsed.sourceName }),
1020
+ });
1021
+ return await applyChannels(universe, parsed);
1022
+ } catch (error) {
1023
+ return fail(set, error);
1024
+ }
1025
+ },
1026
+ {
1027
+ body: channelsBodySchema,
1028
+ detail: {
1029
+ deprecated: true,
1030
+ responses: outputResponseDocument as never,
1031
+ },
1032
+ params: outputParamsSchema,
1033
+ },
1034
+ )
1035
+ .post(
1036
+ "/outputs/:universe/:priority/frame",
1037
+ async ({ body, params, set }) => {
1038
+ mark(
1039
+ set,
1040
+ `/universes/${params.universe}/priorities/${params.priority}/frame`,
1041
+ );
1042
+ try {
1043
+ const frame = frameFrom(body);
1044
+ const universe = universeOf(options.source, params, {
1045
+ ...(frame.cid === undefined ? {} : { cid: frame.cid }),
1046
+ ...(frame.idleFps === undefined ? {} : { idleFps: frame.idleFps }),
1047
+ ...(frame.sourceName === undefined
1048
+ ? {}
1049
+ : { sourceName: frame.sourceName }),
1050
+ });
1051
+ return await universe.write(frame.values, {
1052
+ ...(frame.durationMs === undefined
1053
+ ? {}
1054
+ : { durationMs: frame.durationMs }),
1055
+ });
1056
+ } catch (error) {
1057
+ return fail(set, error);
1058
+ }
1059
+ },
1060
+ {
1061
+ body: frameBodySchema,
1062
+ detail: {
1063
+ deprecated: true,
1064
+ responses: outputResponseDocument as never,
1065
+ },
1066
+ params: outputParamsSchema,
1067
+ },
1068
+ )
1069
+ .delete(
1070
+ "/outputs/:universe/:priority",
1071
+ async ({ params, set }) => {
1072
+ mark(
1073
+ set,
1074
+ `/universes/${params.universe}/priorities/${params.priority}`,
1075
+ );
1076
+ try {
1077
+ const removed = await universeOf(options.source, params).clear();
1078
+ if (!removed) {
1079
+ set.status = 404;
1080
+ return { error: "Output not found." };
1081
+ }
1082
+ return { ok: true as const };
1083
+ } catch (error) {
1084
+ return fail(set, error);
1085
+ }
1086
+ },
1087
+ {
1088
+ detail: {
1089
+ deprecated: true,
1090
+ responses: clearResponseDocument as never,
1091
+ },
1092
+ params: outputParamsSchema,
1093
+ },
1094
+ );
1095
+ };
1096
+
1097
+ /** Connects viewer packets to a host-owned WebSocket or SSE implementation. */
1098
+ export const subscribeViewerPackets = (
1099
+ viewer: ViewerServiceContract,
1100
+ listener: (packet: ViewerPacket) => void,
1101
+ ): (() => void) => viewer.subscribe(listener);