@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/dist/http.js ADDED
@@ -0,0 +1,670 @@
1
+ import { a as SacnValidationError, i as SacnLifecycleError, n as PersistenceError, o as TransportError, t as DependencyUnavailableError } from "./validation-BdsVeyAE.js";
2
+ import { Elysia, t } from "elysia";
3
+ import { openapi } from "@elysiajs/openapi";
4
+ import { node } from "@elysiajs/node";
5
+ //#region src/http.ts
6
+ const outputParamsSchema = t.Object({
7
+ universe: t.String({ pattern: "^-?\\d+$" }),
8
+ priority: t.String({ pattern: "^-?\\d+$" })
9
+ });
10
+ const frameValuesSchema = t.Array(t.Integer({
11
+ minimum: 0,
12
+ maximum: 255
13
+ }), {
14
+ minItems: 512,
15
+ maxItems: 512
16
+ });
17
+ const outputOptionsSchema = {
18
+ cid: t.Optional(t.String()),
19
+ idleFps: t.Optional(t.Number({
20
+ exclusiveMinimum: 0,
21
+ maximum: 1e3
22
+ })),
23
+ sourceName: t.Optional(t.String({
24
+ minLength: 1,
25
+ maxLength: 64
26
+ }))
27
+ };
28
+ const frameBodySchema = t.Object({
29
+ values: frameValuesSchema,
30
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
31
+ ...outputOptionsSchema
32
+ });
33
+ const upsertBodySchema = t.Object({
34
+ initialValues: t.Optional(frameValuesSchema),
35
+ values: t.Optional(frameValuesSchema),
36
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
37
+ ...outputOptionsSchema
38
+ });
39
+ const channelWritesSchema = t.Array(t.Object({
40
+ channel: t.Integer({
41
+ minimum: 1,
42
+ maximum: 512
43
+ }),
44
+ value: t.Integer({
45
+ minimum: 0,
46
+ maximum: 255
47
+ }),
48
+ durationMs: t.Optional(t.Number({ minimum: 0 }))
49
+ }), { minItems: 1 });
50
+ const channelsBodySchema = t.Union([t.Object({
51
+ channels: t.Union([channelWritesSchema, t.Record(t.String(), t.Integer({
52
+ minimum: 0,
53
+ maximum: 255
54
+ }))]),
55
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
56
+ ...outputOptionsSchema
57
+ }), t.Object({
58
+ changes: channelWritesSchema,
59
+ durationMs: t.Optional(t.Number({ minimum: 0 })),
60
+ ...outputOptionsSchema
61
+ })]);
62
+ const outputSnapshotSchema = t.Object({
63
+ activeTransitions: t.Integer({ minimum: 0 }),
64
+ cid: t.String(),
65
+ current: frameValuesSchema,
66
+ dirty: t.Boolean(),
67
+ frameMode: t.Union([t.Literal("active"), t.Literal("idle")]),
68
+ idleFps: t.Number(),
69
+ lastError: t.Union([t.String(), t.Null()]),
70
+ lastSent: frameValuesSchema,
71
+ lastSentAt: t.Union([t.Number(), t.Null()]),
72
+ nextDueAt: t.Number(),
73
+ priority: t.Integer(),
74
+ sequence: t.Integer(),
75
+ sourceName: t.Union([t.String(), t.Null()]),
76
+ target: frameValuesSchema,
77
+ universe: t.Integer(),
78
+ updatedAt: t.Number()
79
+ });
80
+ const errorSchema = t.Object({ error: t.String() });
81
+ const viewerStateSchema = t.Object({ universes: t.Array(t.Integer({
82
+ minimum: 1,
83
+ maximum: 63999
84
+ })) });
85
+ const engineTelemetrySchema = t.Object({
86
+ closed: t.Boolean(),
87
+ running: t.Boolean(),
88
+ outputCount: t.Integer({ minimum: 0 }),
89
+ outputs: t.Array(outputSnapshotSchema),
90
+ loopIterations: t.Integer({ minimum: 0 }),
91
+ lastLoopStartedAt: t.Union([t.Number(), t.Null()]),
92
+ lastLoopCompletedAt: t.Union([t.Number(), t.Null()]),
93
+ lastLoopDurationMs: t.Union([t.Number(), t.Null()]),
94
+ transport: t.Object({
95
+ sendAttempts: t.Integer({ minimum: 0 }),
96
+ sendFailures: t.Integer({ minimum: 0 }),
97
+ sendRetries: t.Integer({ minimum: 0 }),
98
+ sendSuccesses: t.Integer({ minimum: 0 }),
99
+ sendTimeouts: t.Integer({ minimum: 0 })
100
+ })
101
+ });
102
+ const viewerTelemetrySchema = t.Object({
103
+ droppedUpdates: t.Integer({ minimum: 0 }),
104
+ packetsReceived: t.Integer({ minimum: 0 }),
105
+ selectedUniverses: t.Array(t.Integer()),
106
+ streamCount: t.Integer({ minimum: 0 })
107
+ });
108
+ const viewerUniverseParamsSchema = t.Object({ universe: t.String({ pattern: "^-?\\d+$" }) });
109
+ const errorResponseDocument = (description) => ({
110
+ description,
111
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }
112
+ });
113
+ const outputResponseDocument = {
114
+ 200: {
115
+ description: "Output snapshot",
116
+ content: { "application/json": { schema: { $ref: "#/components/schemas/OutputSnapshot" } } }
117
+ },
118
+ 400: errorResponseDocument("Invalid request"),
119
+ 409: errorResponseDocument("Lifecycle conflict"),
120
+ 503: errorResponseDocument("Transport or persistence unavailable")
121
+ };
122
+ const outputLookupResponseDocument = {
123
+ ...outputResponseDocument,
124
+ 404: errorResponseDocument("Output not found")
125
+ };
126
+ const clearResponseDocument = {
127
+ 200: {
128
+ description: "Output cleared",
129
+ content: { "application/json": { schema: {
130
+ type: "object",
131
+ required: ["ok"],
132
+ properties: { ok: {
133
+ type: "boolean",
134
+ const: true
135
+ } }
136
+ } } }
137
+ },
138
+ 400: errorResponseDocument("Invalid request"),
139
+ 404: errorResponseDocument("Output not found"),
140
+ 409: errorResponseDocument("Lifecycle conflict"),
141
+ 503: errorResponseDocument("Transport or persistence unavailable")
142
+ };
143
+ const componentResponse = (schema, description) => ({
144
+ 200: {
145
+ description,
146
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${schema}` } } }
147
+ },
148
+ 400: errorResponseDocument("Invalid request"),
149
+ 409: errorResponseDocument("Lifecycle conflict"),
150
+ 503: errorResponseDocument("Dependency unavailable")
151
+ });
152
+ const integerParam = (value, label) => {
153
+ if (!/^-?\d+$/.test(value)) throw new SacnValidationError(`${label} must be an integer.`, label === "Universe" ? "INVALID_UNIVERSE" : "INVALID_PRIORITY");
154
+ return Number(value);
155
+ };
156
+ const addressOf = (params) => ({
157
+ universe: integerParam(params.universe, "Universe"),
158
+ priority: integerParam(params.priority, "Priority")
159
+ });
160
+ const bodyObject = (body) => {
161
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new SacnValidationError("Request body must be an object.", "INVALID_FRAME");
162
+ return body;
163
+ };
164
+ const messageOf = (error) => error instanceof Error ? error.message : "Internal server error.";
165
+ const statusOf = (error) => {
166
+ if (error instanceof SacnValidationError) return 400;
167
+ if (error instanceof SacnLifecycleError) return 409;
168
+ if (error instanceof DependencyUnavailableError || error instanceof PersistenceError || error instanceof TransportError) return 503;
169
+ return 500;
170
+ };
171
+ const fail = (set, error) => {
172
+ set.status = statusOf(error);
173
+ return { error: messageOf(error) };
174
+ };
175
+ const deprecated = (set, successor, sunset) => {
176
+ set.headers.deprecation = "true";
177
+ set.headers.link = `<${successor}>; rel="successor-version"`;
178
+ set.headers.sunset = sunset;
179
+ };
180
+ const channelsFrom = (body) => {
181
+ const record = bodyObject(body);
182
+ const sharedDuration = typeof record.durationMs === "number" ? record.durationMs : void 0;
183
+ const value = record.channels ?? record.changes;
184
+ const options = {
185
+ ...sharedDuration === void 0 ? {} : { durationMs: sharedDuration },
186
+ ...record.idleFps === void 0 ? {} : { idleFps: record.idleFps },
187
+ ...record.sourceName === void 0 ? {} : { sourceName: record.sourceName },
188
+ ...record.cid === void 0 ? {} : { cid: record.cid }
189
+ };
190
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
191
+ const values = {};
192
+ for (const [key, channelValue] of Object.entries(value)) values[Number(key)] = channelValue;
193
+ if (sharedDuration !== void 0 && sharedDuration > 0) return {
194
+ mode: "fade",
195
+ values,
196
+ ...options
197
+ };
198
+ return {
199
+ mode: "set",
200
+ values,
201
+ ...options
202
+ };
203
+ }
204
+ if (!Array.isArray(value)) throw new SacnValidationError("Body must contain a channels array or map.", "INVALID_CHANNEL");
205
+ const writes = value.map((item) => {
206
+ const change = bodyObject(item);
207
+ const durationMs = typeof change.durationMs === "number" ? change.durationMs : void 0;
208
+ return {
209
+ channel: change.channel,
210
+ value: change.value,
211
+ ...durationMs === void 0 ? {} : { durationMs }
212
+ };
213
+ });
214
+ if (writes.some((write) => write.durationMs !== void 0 && write.durationMs > 0)) return {
215
+ mode: "transition",
216
+ writes: writes.map((write) => ({
217
+ channel: write.channel,
218
+ value: write.value,
219
+ durationMs: write.durationMs ?? sharedDuration ?? 0
220
+ })),
221
+ ...options
222
+ };
223
+ const values = {};
224
+ for (const write of writes) values[write.channel] = write.value;
225
+ if (sharedDuration !== void 0 && sharedDuration > 0) return {
226
+ mode: "fade",
227
+ values,
228
+ ...options
229
+ };
230
+ return {
231
+ mode: "set",
232
+ values,
233
+ ...options
234
+ };
235
+ };
236
+ const frameFrom = (body) => {
237
+ const record = bodyObject(body);
238
+ const values = record.values ?? record.initialValues;
239
+ if (!Array.isArray(values) && !(values instanceof Uint8Array)) throw new SacnValidationError("Body must contain a values array.", "INVALID_FRAME");
240
+ const durationMs = record.durationMs;
241
+ return {
242
+ values,
243
+ ...durationMs === void 0 ? {} : { durationMs },
244
+ ...record.idleFps === void 0 ? {} : { idleFps: record.idleFps },
245
+ ...record.sourceName === void 0 ? {} : { sourceName: record.sourceName },
246
+ ...record.cid === void 0 ? {} : { cid: record.cid }
247
+ };
248
+ };
249
+ const universeOf = (source, params, extras = {}) => {
250
+ const address = addressOf(params);
251
+ return source.universe(address.universe, {
252
+ priority: address.priority,
253
+ ...extras
254
+ });
255
+ };
256
+ const applyChannels = async (universe, parsed) => {
257
+ if (parsed.mode === "fade" && parsed.values) return universe.fadeChannels(parsed.values, { durationMs: parsed.durationMs ?? 0 });
258
+ if (parsed.mode === "transition" && parsed.writes) return universe.transition(parsed.writes);
259
+ if (parsed.values) return universe.setChannels(parsed.values);
260
+ throw new SacnValidationError("Body must contain channel values.", "INVALID_CHANNEL");
261
+ };
262
+ /**
263
+ * Creates an unbound Elysia route plugin. CORS and server binding deliberately
264
+ * remain the host application's responsibility.
265
+ */
266
+ const createSacnHttpAdapter = (options) => {
267
+ const prefix = options.prefix ?? "/sacn";
268
+ const sunset = options.sunset ?? "Wed, 01 Jul 2027 00:00:00 GMT";
269
+ const maxWebSocketClients = options.maxWebSocketClients ?? 64;
270
+ const webSocketQueueCapacity = options.webSocketQueueCapacity ?? 32;
271
+ if (!Number.isInteger(maxWebSocketClients) || maxWebSocketClients < 1) throw new SacnValidationError("Maximum WebSocket clients must be a positive integer.", "INVALID_FRAME");
272
+ if (!Number.isInteger(webSocketQueueCapacity) || webSocketQueueCapacity < 1) throw new SacnValidationError("WebSocket queue capacity must be a positive integer.", "INVALID_FRAME");
273
+ const clients = /* @__PURE__ */ new Set();
274
+ const closeClient = (client) => {
275
+ clients.delete(client);
276
+ client.pending.clear();
277
+ try {
278
+ client.socket.close();
279
+ } catch {}
280
+ };
281
+ const flushClient = (client) => {
282
+ client.scheduled = false;
283
+ const packets = [...client.pending.values()];
284
+ client.pending.clear();
285
+ for (const packet of packets) try {
286
+ const status = client.socket.send({
287
+ packet,
288
+ type: "viewer-packet"
289
+ });
290
+ if (typeof status === "number" && status <= 0) {
291
+ closeClient(client);
292
+ return;
293
+ }
294
+ } catch {
295
+ closeClient(client);
296
+ return;
297
+ }
298
+ };
299
+ const dispatchViewerPacket = (packet) => {
300
+ try {
301
+ options.onViewerPacket?.(packet);
302
+ } catch {}
303
+ for (const client of clients) {
304
+ if (client.pending.has(packet.universe)) client.pending.delete(packet.universe);
305
+ else if (client.pending.size >= webSocketQueueCapacity) {
306
+ const oldestUniverse = client.pending.keys().next().value;
307
+ if (oldestUniverse !== void 0) client.pending.delete(oldestUniverse);
308
+ }
309
+ client.pending.set(packet.universe, packet);
310
+ if (!client.scheduled) {
311
+ client.scheduled = true;
312
+ queueMicrotask(() => flushClient(client));
313
+ }
314
+ }
315
+ };
316
+ const unsubscribePackets = options.viewer ? options.viewer.subscribe(dispatchViewerPacket) : void 0;
317
+ const app = new Elysia({
318
+ adapter: node(),
319
+ prefix
320
+ }).use(openapi({
321
+ path: options.openapiPath ?? "/openapi",
322
+ documentation: {
323
+ components: { schemas: {
324
+ Error: errorSchema,
325
+ EngineTelemetry: engineTelemetrySchema,
326
+ OutputSnapshot: outputSnapshotSchema,
327
+ ViewerState: viewerStateSchema,
328
+ ViewerTelemetry: viewerTelemetrySchema
329
+ } },
330
+ info: {
331
+ title: "@helioslx/core sACN API",
332
+ version: "0.1.0"
333
+ }
334
+ }
335
+ })).onBeforeHandle(async ({ request, set }) => {
336
+ try {
337
+ const result = await options.auth?.({ request });
338
+ if (result instanceof Response) return result;
339
+ if (result === false) {
340
+ set.status = 401;
341
+ return { error: "Unauthorized." };
342
+ }
343
+ } catch (error) {
344
+ return fail(set, error);
345
+ }
346
+ }).onError(({ code, error, set }) => {
347
+ if (code === "VALIDATION") {
348
+ set.status = 400;
349
+ return { error: error.message };
350
+ }
351
+ }).onStop(() => {
352
+ unsubscribePackets?.();
353
+ clients.clear();
354
+ }).get("/health/live", () => ({ status: "ok" }), { response: { 200: t.Object({ status: t.Literal("ok") }) } }).get("/health/ready", ({ set }) => {
355
+ const telemetry = options.source.getTelemetry();
356
+ if (!telemetry.running || telemetry.closed) {
357
+ set.status = 503;
358
+ return { status: "not-ready" };
359
+ }
360
+ return { status: "ready" };
361
+ }, { response: {
362
+ 200: t.Object({ status: t.Literal("ready") }),
363
+ 503: t.Object({ status: t.Literal("not-ready") })
364
+ } }).get("/universes", async ({ set }) => {
365
+ try {
366
+ return await options.source.listUniverses();
367
+ } catch (error) {
368
+ return fail(set, error);
369
+ }
370
+ }, { detail: { responses: {
371
+ 200: {
372
+ description: "Active outputs",
373
+ content: { "application/json": { schema: {
374
+ type: "array",
375
+ items: { $ref: "#/components/schemas/OutputSnapshot" }
376
+ } } }
377
+ },
378
+ 409: errorResponseDocument("Lifecycle conflict"),
379
+ 503: errorResponseDocument("Persistence unavailable")
380
+ } } }).get("/engine/telemetry", () => options.source.getTelemetry(), { detail: { responses: componentResponse("EngineTelemetry", "Engine telemetry") } }).get("/universes/:universe/priorities/:priority", async ({ params, set }) => {
381
+ try {
382
+ const output = await universeOf(options.source, params).get();
383
+ if (!output) {
384
+ set.status = 404;
385
+ return { error: "Output not found." };
386
+ }
387
+ return output;
388
+ } catch (error) {
389
+ return fail(set, error);
390
+ }
391
+ }, {
392
+ detail: { responses: outputLookupResponseDocument },
393
+ params: outputParamsSchema
394
+ }).put("/universes/:universe/priorities/:priority", async ({ body, params, set }) => {
395
+ try {
396
+ const record = bodyObject(body);
397
+ const frame = frameFrom({
398
+ ...record,
399
+ values: record.initialValues ?? record.values ?? Array.from({ length: 512 }, () => 0)
400
+ });
401
+ const universe = universeOf(options.source, params, {
402
+ ...frame.cid === void 0 ? {} : { cid: frame.cid },
403
+ ...frame.idleFps === void 0 ? {} : { idleFps: frame.idleFps },
404
+ ...frame.sourceName === void 0 ? {} : { sourceName: frame.sourceName }
405
+ });
406
+ const existing = await universe.get();
407
+ const supplied = record.initialValues ?? record.values;
408
+ const values = Array.isArray(supplied) || supplied instanceof Uint8Array ? supplied : existing?.target ?? Array.from({ length: 512 }, () => 0);
409
+ return await universe.write(values, { ...frame.durationMs === void 0 ? {} : { durationMs: frame.durationMs } });
410
+ } catch (error) {
411
+ return fail(set, error);
412
+ }
413
+ }, {
414
+ body: upsertBodySchema,
415
+ detail: { responses: outputResponseDocument },
416
+ params: outputParamsSchema
417
+ }).post("/universes/:universe/priorities/:priority/channels", async ({ body, params, set }) => {
418
+ try {
419
+ const parsed = channelsFrom(body);
420
+ const universe = universeOf(options.source, params, {
421
+ ...parsed.cid === void 0 ? {} : { cid: parsed.cid },
422
+ ...parsed.idleFps === void 0 ? {} : { idleFps: parsed.idleFps },
423
+ ...parsed.sourceName === void 0 ? {} : { sourceName: parsed.sourceName }
424
+ });
425
+ return await applyChannels(universe, parsed);
426
+ } catch (error) {
427
+ return fail(set, error);
428
+ }
429
+ }, {
430
+ body: channelsBodySchema,
431
+ detail: { responses: outputResponseDocument },
432
+ params: outputParamsSchema
433
+ }).post("/universes/:universe/priorities/:priority/frame", async ({ body, params, set }) => {
434
+ try {
435
+ const frame = frameFrom(body);
436
+ return await universeOf(options.source, params, {
437
+ ...frame.cid === void 0 ? {} : { cid: frame.cid },
438
+ ...frame.idleFps === void 0 ? {} : { idleFps: frame.idleFps },
439
+ ...frame.sourceName === void 0 ? {} : { sourceName: frame.sourceName }
440
+ }).write(frame.values, { ...frame.durationMs === void 0 ? {} : { durationMs: frame.durationMs } });
441
+ } catch (error) {
442
+ return fail(set, error);
443
+ }
444
+ }, {
445
+ body: frameBodySchema,
446
+ detail: { responses: outputResponseDocument },
447
+ params: outputParamsSchema
448
+ }).delete("/universes/:universe/priorities/:priority", async ({ params, set }) => {
449
+ try {
450
+ if (!await universeOf(options.source, params).clear()) {
451
+ set.status = 404;
452
+ return { error: "Output not found." };
453
+ }
454
+ return { ok: true };
455
+ } catch (error) {
456
+ return fail(set, error);
457
+ }
458
+ }, {
459
+ detail: { responses: {
460
+ 200: {
461
+ description: "Output cleared",
462
+ content: { "application/json": { schema: {
463
+ type: "object",
464
+ required: ["ok"],
465
+ properties: { ok: {
466
+ type: "boolean",
467
+ const: true
468
+ } }
469
+ } } }
470
+ },
471
+ 400: errorResponseDocument("Invalid request"),
472
+ 404: errorResponseDocument("Output not found"),
473
+ 409: errorResponseDocument("Lifecycle conflict"),
474
+ 503: errorResponseDocument("Transport or persistence unavailable")
475
+ } },
476
+ params: outputParamsSchema
477
+ });
478
+ if (options.viewer) app.get("/viewer/universes", () => ({ universes: options.viewer?.getSelectedUniverses() ?? [] }), { detail: { responses: componentResponse("ViewerState", "Selected viewer universes") } }).get("/viewer/telemetry", () => options.viewer?.getTelemetry(), { detail: { responses: componentResponse("ViewerTelemetry", "Viewer telemetry") } }).put("/viewer/universes", async ({ body, set }) => {
479
+ try {
480
+ const record = bodyObject(body);
481
+ if (!Array.isArray(record.universes)) throw new SacnValidationError("Body must contain a universes array.", "INVALID_UNIVERSE");
482
+ return { universes: await options.viewer?.setSelectedUniverses(record.universes) };
483
+ } catch (error) {
484
+ return fail(set, error);
485
+ }
486
+ }, {
487
+ body: viewerStateSchema,
488
+ detail: { responses: componentResponse("ViewerState", "Updated viewer universes") }
489
+ }).post("/viewer/universes/:universe", async ({ params, set }) => {
490
+ try {
491
+ return { universes: await options.viewer?.addUniverse(integerParam(params.universe, "Universe")) };
492
+ } catch (error) {
493
+ return fail(set, error);
494
+ }
495
+ }, {
496
+ detail: { responses: componentResponse("ViewerState", "Updated viewer universes") },
497
+ params: viewerUniverseParamsSchema
498
+ }).delete("/viewer/universes/:universe", async ({ params, set }) => {
499
+ try {
500
+ return { universes: await options.viewer?.removeUniverse(integerParam(params.universe, "Universe")) };
501
+ } catch (error) {
502
+ return fail(set, error);
503
+ }
504
+ }, {
505
+ detail: { responses: componentResponse("ViewerState", "Updated viewer universes") },
506
+ params: viewerUniverseParamsSchema
507
+ }).delete("/viewer/universes", async ({ set }) => {
508
+ try {
509
+ return { universes: await options.viewer?.setSelectedUniverses([]) };
510
+ } catch (error) {
511
+ return fail(set, error);
512
+ }
513
+ }, { detail: { responses: componentResponse("ViewerState", "Cleared viewer universes") } }).ws("/viewer/ws", {
514
+ open(ws) {
515
+ if (clients.size >= maxWebSocketClients) {
516
+ ws.close();
517
+ return;
518
+ }
519
+ const client = {
520
+ socket: ws,
521
+ pending: /* @__PURE__ */ new Map(),
522
+ scheduled: false
523
+ };
524
+ clients.add(client);
525
+ ws.send({
526
+ type: "viewer-state",
527
+ viewer: { universes: options.viewer?.getSelectedUniverses() ?? [] }
528
+ });
529
+ },
530
+ close(ws) {
531
+ for (const client of clients) if (client.socket === ws) {
532
+ clients.delete(client);
533
+ client.pending.clear();
534
+ break;
535
+ }
536
+ },
537
+ message() {}
538
+ });
539
+ const mark = (set, successor) => deprecated(set, `${prefix}${successor}`, sunset);
540
+ return app.get("/outputs", async ({ set }) => {
541
+ mark(set, "/universes");
542
+ try {
543
+ return await options.source.listUniverses();
544
+ } catch (error) {
545
+ return fail(set, error);
546
+ }
547
+ }, { detail: {
548
+ deprecated: true,
549
+ responses: { 200: {
550
+ description: "Active outputs",
551
+ content: { "application/json": { schema: {
552
+ type: "array",
553
+ items: { $ref: "#/components/schemas/OutputSnapshot" }
554
+ } } }
555
+ } }
556
+ } }).get("/telemetry", ({ set }) => {
557
+ mark(set, "/engine/telemetry");
558
+ return options.source.getTelemetry();
559
+ }, { detail: {
560
+ deprecated: true,
561
+ responses: componentResponse("EngineTelemetry", "Engine telemetry")
562
+ } }).get("/outputs/:universe/:priority", async ({ params, set }) => {
563
+ mark(set, `/universes/${params.universe}/priorities/${params.priority}`);
564
+ try {
565
+ const output = await universeOf(options.source, params).get();
566
+ if (!output) {
567
+ set.status = 404;
568
+ return { error: "Output not found." };
569
+ }
570
+ return output;
571
+ } catch (error) {
572
+ return fail(set, error);
573
+ }
574
+ }, {
575
+ detail: {
576
+ deprecated: true,
577
+ responses: outputLookupResponseDocument
578
+ },
579
+ params: outputParamsSchema
580
+ }).put("/outputs/:universe/:priority", async ({ body, params, set }) => {
581
+ mark(set, `/universes/${params.universe}/priorities/${params.priority}`);
582
+ try {
583
+ const record = bodyObject(body);
584
+ const frame = frameFrom({
585
+ ...record,
586
+ values: record.initialValues ?? record.values ?? Array.from({ length: 512 }, () => 0)
587
+ });
588
+ const universe = universeOf(options.source, params, {
589
+ ...frame.cid === void 0 ? {} : { cid: frame.cid },
590
+ ...frame.idleFps === void 0 ? {} : { idleFps: frame.idleFps },
591
+ ...frame.sourceName === void 0 ? {} : { sourceName: frame.sourceName }
592
+ });
593
+ const existing = await universe.get();
594
+ const supplied = record.initialValues ?? record.values;
595
+ const values = Array.isArray(supplied) || supplied instanceof Uint8Array ? supplied : existing?.target ?? Array.from({ length: 512 }, () => 0);
596
+ return await universe.write(values, { ...frame.durationMs === void 0 ? {} : { durationMs: frame.durationMs } });
597
+ } catch (error) {
598
+ return fail(set, error);
599
+ }
600
+ }, {
601
+ body: upsertBodySchema,
602
+ detail: {
603
+ deprecated: true,
604
+ responses: outputResponseDocument
605
+ },
606
+ params: outputParamsSchema
607
+ }).post("/outputs/:universe/:priority/channels", async ({ body, params, set }) => {
608
+ mark(set, `/universes/${params.universe}/priorities/${params.priority}/channels`);
609
+ try {
610
+ const parsed = channelsFrom(body);
611
+ const universe = universeOf(options.source, params, {
612
+ ...parsed.cid === void 0 ? {} : { cid: parsed.cid },
613
+ ...parsed.idleFps === void 0 ? {} : { idleFps: parsed.idleFps },
614
+ ...parsed.sourceName === void 0 ? {} : { sourceName: parsed.sourceName }
615
+ });
616
+ return await applyChannels(universe, parsed);
617
+ } catch (error) {
618
+ return fail(set, error);
619
+ }
620
+ }, {
621
+ body: channelsBodySchema,
622
+ detail: {
623
+ deprecated: true,
624
+ responses: outputResponseDocument
625
+ },
626
+ params: outputParamsSchema
627
+ }).post("/outputs/:universe/:priority/frame", async ({ body, params, set }) => {
628
+ mark(set, `/universes/${params.universe}/priorities/${params.priority}/frame`);
629
+ try {
630
+ const frame = frameFrom(body);
631
+ return await universeOf(options.source, params, {
632
+ ...frame.cid === void 0 ? {} : { cid: frame.cid },
633
+ ...frame.idleFps === void 0 ? {} : { idleFps: frame.idleFps },
634
+ ...frame.sourceName === void 0 ? {} : { sourceName: frame.sourceName }
635
+ }).write(frame.values, { ...frame.durationMs === void 0 ? {} : { durationMs: frame.durationMs } });
636
+ } catch (error) {
637
+ return fail(set, error);
638
+ }
639
+ }, {
640
+ body: frameBodySchema,
641
+ detail: {
642
+ deprecated: true,
643
+ responses: outputResponseDocument
644
+ },
645
+ params: outputParamsSchema
646
+ }).delete("/outputs/:universe/:priority", async ({ params, set }) => {
647
+ mark(set, `/universes/${params.universe}/priorities/${params.priority}`);
648
+ try {
649
+ if (!await universeOf(options.source, params).clear()) {
650
+ set.status = 404;
651
+ return { error: "Output not found." };
652
+ }
653
+ return { ok: true };
654
+ } catch (error) {
655
+ return fail(set, error);
656
+ }
657
+ }, {
658
+ detail: {
659
+ deprecated: true,
660
+ responses: clearResponseDocument
661
+ },
662
+ params: outputParamsSchema
663
+ });
664
+ };
665
+ /** Connects viewer packets to a host-owned WebSocket or SSE implementation. */
666
+ const subscribeViewerPackets = (viewer, listener) => viewer.subscribe(listener);
667
+ //#endregion
668
+ export { createSacnHttpAdapter, subscribeViewerPackets };
669
+
670
+ //# sourceMappingURL=http.js.map