@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
@@ -0,0 +1,778 @@
1
+ import {
2
+ DEFAULT_ACTIVE_FPS,
3
+ DEFAULT_IDLE_FPS,
4
+ SLOT_COUNT,
5
+ type ChannelWrite,
6
+ type Clock,
7
+ type EngineTelemetry,
8
+ type FrameMode,
9
+ type Logger,
10
+ type OutputAddress,
11
+ type OutputOptions,
12
+ type OutputPacket,
13
+ type OutputRecord,
14
+ type OutputSnapshot,
15
+ type OutputTransport,
16
+ } from "./contracts.js";
17
+ import {
18
+ SacnLifecycleError,
19
+ TransportError,
20
+ TransportTimeoutError,
21
+ assertCid,
22
+ assertFade,
23
+ assertFps,
24
+ assertSourceName,
25
+ assertTimeout,
26
+ normalizeAddress,
27
+ toValidatedFrame,
28
+ validateChannelWrites,
29
+ } from "./validation.js";
30
+
31
+ interface Transition {
32
+ readonly from: number;
33
+ readonly to: number;
34
+ readonly startedAt: number;
35
+ readonly durationMs: number;
36
+ }
37
+
38
+ interface OutputState {
39
+ readonly address: Required<OutputAddress>;
40
+ cid: string;
41
+ idleFps: number;
42
+ sourceName: string | null;
43
+ readonly current: Uint8Array;
44
+ readonly target: Uint8Array;
45
+ readonly lastSent: Uint8Array;
46
+ readonly transitions: Map<number, Transition>;
47
+ sequence: number;
48
+ lastSentAt: number | null;
49
+ nextDueAt: number;
50
+ dirty: boolean;
51
+ lastError: string | null;
52
+ consecutiveFailures: number;
53
+ updatedAt: number;
54
+ revision: number;
55
+ sending: Promise<boolean> | null;
56
+ transportPending: Promise<void> | null;
57
+ }
58
+
59
+ export interface OutputMutationCheckpoint {
60
+ readonly key: string;
61
+ readonly state: OutputState | null;
62
+ }
63
+
64
+ interface OutputEngineOptions {
65
+ readonly transport: OutputTransport;
66
+ readonly clock: Clock;
67
+ readonly logger: Logger;
68
+ readonly activeFps?: number;
69
+ readonly idleFps?: number;
70
+ readonly sendTimeoutMs?: number;
71
+ readonly shutdownTimeoutMs?: number;
72
+ readonly maxOutputs?: number;
73
+ readonly closeTransport?: boolean;
74
+ readonly createId: () => string;
75
+ }
76
+
77
+ const framesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
78
+ for (let index = 0; index < SLOT_COUNT; index += 1) {
79
+ if (left[index] !== right[index]) return false;
80
+ }
81
+ return true;
82
+ };
83
+
84
+ const frozenFrame = (frame: Uint8Array): readonly number[] =>
85
+ Object.freeze(Array.from(frame));
86
+
87
+ const errorMessage = (error: unknown): string =>
88
+ error instanceof Error ? error.message : String(error);
89
+
90
+ export class SystemClock implements Clock {
91
+ now(): number {
92
+ return performance.now();
93
+ }
94
+
95
+ wallNow(): number {
96
+ return Date.now();
97
+ }
98
+
99
+ sleep(delayMs: number, signal: AbortSignal): Promise<void> {
100
+ return new Promise((resolve, reject) => {
101
+ if (signal.aborted) {
102
+ reject(signal.reason ?? new Error("Operation aborted."));
103
+ return;
104
+ }
105
+ const handle = setTimeout(resolve, delayMs);
106
+ signal.addEventListener(
107
+ "abort",
108
+ () => {
109
+ clearTimeout(handle);
110
+ reject(signal.reason ?? new Error("Operation aborted."));
111
+ },
112
+ { once: true },
113
+ );
114
+ });
115
+ }
116
+ }
117
+
118
+ export class OutputEngine {
119
+ readonly #outputs = new Map<string, OutputState>();
120
+ readonly #pendingMutations = new Set<string>();
121
+ readonly #transport: OutputTransport;
122
+ readonly #clock: Clock;
123
+ readonly #logger: Logger;
124
+ readonly #activeIntervalMs: number;
125
+ readonly #defaultIdleFps: number;
126
+ readonly #sendTimeoutMs: number;
127
+ readonly #shutdownTimeoutMs: number;
128
+ readonly #maxOutputs: number;
129
+ readonly #closeTransport: boolean;
130
+ readonly #createId: () => string;
131
+ readonly #lifecycle = new AbortController();
132
+ #running = false;
133
+ #closed = false;
134
+ #loopPromise: Promise<void> | null = null;
135
+ #wake: (() => void) | null = null;
136
+ #loopIterations = 0;
137
+ #lastLoopStartedAt: number | null = null;
138
+ #lastLoopCompletedAt: number | null = null;
139
+ readonly #transportTelemetry = {
140
+ sendAttempts: 0,
141
+ sendFailures: 0,
142
+ sendRetries: 0,
143
+ sendSuccesses: 0,
144
+ sendTimeouts: 0,
145
+ };
146
+
147
+ constructor(options: OutputEngineOptions) {
148
+ const activeFps = options.activeFps ?? DEFAULT_ACTIVE_FPS;
149
+ const idleFps = options.idleFps ?? DEFAULT_IDLE_FPS;
150
+ const sendTimeoutMs = options.sendTimeoutMs ?? 1000;
151
+ const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 2000;
152
+ const maxOutputs = options.maxOutputs ?? 1024;
153
+ assertFps(activeFps, "Active FPS");
154
+ assertFps(idleFps, "Idle FPS");
155
+ assertTimeout(sendTimeoutMs);
156
+ assertTimeout(shutdownTimeoutMs);
157
+ if (!Number.isInteger(maxOutputs) || maxOutputs < 1) {
158
+ throw new SacnLifecycleError(
159
+ "Maximum outputs must be a positive integer.",
160
+ );
161
+ }
162
+ this.#transport = options.transport;
163
+ this.#clock = options.clock;
164
+ this.#logger = options.logger;
165
+ this.#activeIntervalMs = 1000 / activeFps;
166
+ this.#defaultIdleFps = idleFps;
167
+ this.#sendTimeoutMs = sendTimeoutMs;
168
+ this.#shutdownTimeoutMs = shutdownTimeoutMs;
169
+ this.#maxOutputs = maxOutputs;
170
+ this.#closeTransport = options.closeTransport ?? true;
171
+ this.#createId = options.createId;
172
+ }
173
+
174
+ restore(record: OutputRecord): void {
175
+ this.#assertOpen();
176
+ const address = normalizeAddress(record);
177
+ assertFps(record.idleFps, "Idle FPS");
178
+ if (this.#outputs.has(this.#key(address))) return;
179
+ if (this.#outputs.size >= this.#maxOutputs) {
180
+ throw new SacnLifecycleError(
181
+ `Output limit of ${this.#maxOutputs} has been reached while restoring state.`,
182
+ );
183
+ }
184
+ const state = this.#createState(record, toValidatedFrame(record.target));
185
+ this.#outputs.set(this.#key(address), state);
186
+ }
187
+
188
+ async beginMutation(
189
+ addressInput: OutputAddress,
190
+ ): Promise<OutputMutationCheckpoint> {
191
+ this.#assertOpen();
192
+ const address = normalizeAddress(addressInput);
193
+ const key = this.#key(address);
194
+ if (this.#pendingMutations.has(key)) {
195
+ throw new SacnLifecycleError(`Output ${key} already has a pending mutation.`);
196
+ }
197
+ const output = this.#outputs.get(key);
198
+ if (output?.sending) await output.sending;
199
+ if (output?.transportPending) await output.transportPending;
200
+ this.#pendingMutations.add(key);
201
+ return {
202
+ key,
203
+ state: output ? this.#cloneState(output) : null,
204
+ };
205
+ }
206
+
207
+ commitMutation(checkpoint: OutputMutationCheckpoint): void {
208
+ this.#pendingMutations.delete(checkpoint.key);
209
+ this.#wakeLoop();
210
+ }
211
+
212
+ rollbackMutation(checkpoint: OutputMutationCheckpoint): void {
213
+ if (checkpoint.state) {
214
+ this.#outputs.set(checkpoint.key, checkpoint.state);
215
+ } else {
216
+ this.#outputs.delete(checkpoint.key);
217
+ }
218
+ this.#pendingMutations.delete(checkpoint.key);
219
+ this.#wakeLoop();
220
+ }
221
+
222
+ start(): void {
223
+ this.#assertOpen();
224
+ if (this.#running) return;
225
+ this.#running = true;
226
+ this.#loopPromise = this.#loop();
227
+ this.#logger.info?.("Output engine started.", {
228
+ activeFrameIntervalMs: this.#activeIntervalMs,
229
+ idleFps: this.#defaultIdleFps,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Pauses the scheduler without tearing down outputs or the transport.
235
+ * `start()` can resume the loop afterward.
236
+ */
237
+ async stop(): Promise<void> {
238
+ this.#assertOpen();
239
+ if (!this.#running) return;
240
+ this.#running = false;
241
+ this.#wakeLoop();
242
+ if (this.#loopPromise) {
243
+ await this.#loopPromise;
244
+ this.#loopPromise = null;
245
+ }
246
+ this.#logger.info?.("Output engine stopped.");
247
+ }
248
+
249
+ writeFrame(
250
+ options: OutputOptions,
251
+ frame: Uint8Array,
252
+ durationMs: number,
253
+ ): OutputSnapshot {
254
+ this.#assertOpen();
255
+ assertFade(durationMs);
256
+ const output = this.#getOrCreate(options);
257
+ const now = this.#clock.now();
258
+ this.#updateTransitions(output, now);
259
+ if (durationMs === 0) {
260
+ output.current.set(frame);
261
+ output.target.set(frame);
262
+ output.transitions.clear();
263
+ } else {
264
+ for (let index = 0; index < SLOT_COUNT; index += 1) {
265
+ const target = frame[index] ?? 0;
266
+ if (target === output.current[index]) {
267
+ output.target[index] = target;
268
+ output.transitions.delete(index);
269
+ } else {
270
+ output.target[index] = target;
271
+ output.transitions.set(index, {
272
+ from: output.current[index] ?? 0,
273
+ to: target,
274
+ startedAt: now,
275
+ durationMs,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ this.#markChanged(output, now);
281
+ return this.#snapshot(output);
282
+ }
283
+
284
+ writeChannels(
285
+ options: OutputOptions,
286
+ writes: readonly ChannelWrite[],
287
+ ): OutputSnapshot {
288
+ this.#assertOpen();
289
+ validateChannelWrites(writes);
290
+ const output = this.#getOrCreate(options);
291
+ const now = this.#clock.now();
292
+ this.#updateTransitions(output, now);
293
+ for (const write of writes) {
294
+ const index = write.channel - 1;
295
+ const durationMs = write.durationMs ?? 0;
296
+ const from = output.current[index] ?? 0;
297
+ output.target[index] = write.value;
298
+ if (durationMs === 0 || from === write.value) {
299
+ output.current[index] = write.value;
300
+ output.transitions.delete(index);
301
+ } else {
302
+ output.transitions.set(index, {
303
+ from,
304
+ to: write.value,
305
+ startedAt: now,
306
+ durationMs,
307
+ });
308
+ }
309
+ }
310
+ this.#markChanged(output, now);
311
+ return this.#snapshot(output);
312
+ }
313
+
314
+ getOutput(addressInput: OutputAddress): OutputSnapshot | null {
315
+ this.#assertOpen();
316
+ const address = normalizeAddress(addressInput);
317
+ const output = this.#outputs.get(this.#key(address));
318
+ if (!output) return null;
319
+ this.#updateTransitions(output, this.#clock.now());
320
+ return this.#snapshot(output);
321
+ }
322
+
323
+ listOutputs(): readonly OutputSnapshot[] {
324
+ this.#assertOpen();
325
+ const now = this.#clock.now();
326
+ return Object.freeze(
327
+ [...this.#outputs.values()]
328
+ .sort(
329
+ (left, right) =>
330
+ left.address.universe - right.address.universe ||
331
+ left.address.priority - right.address.priority,
332
+ )
333
+ .map((output) => {
334
+ this.#updateTransitions(output, now);
335
+ return this.#snapshot(output);
336
+ }),
337
+ );
338
+ }
339
+
340
+ async clearOutput(addressInput: OutputAddress): Promise<boolean> {
341
+ this.#assertOpen();
342
+ const address = normalizeAddress(addressInput);
343
+ const output = this.#outputs.get(this.#key(address));
344
+ if (!output) return false;
345
+ if (output.sending) await output.sending;
346
+ if (output.transportPending) await output.transportPending;
347
+ const now = this.#clock.now();
348
+ output.current.fill(0);
349
+ output.target.fill(0);
350
+ output.transitions.clear();
351
+ this.#markChanged(output, now);
352
+ const sent = await this.#beginSend(output, now);
353
+ if (!sent) {
354
+ throw new TransportError(
355
+ `Failed to send blackout frame for ${this.#key(address)}.`,
356
+ );
357
+ }
358
+ this.#outputs.delete(this.#key(address));
359
+ try {
360
+ await this.#transport.closeOutput?.(address);
361
+ } catch (error) {
362
+ throw new TransportError(`Failed to close output ${this.#key(address)}.`, error);
363
+ }
364
+ this.#wakeLoop();
365
+ return true;
366
+ }
367
+
368
+ getTelemetry(): EngineTelemetry {
369
+ const outputs = this.#closed
370
+ ? Object.freeze([] as OutputSnapshot[])
371
+ : this.listOutputs();
372
+ return Object.freeze({
373
+ closed: this.#closed,
374
+ running: this.#running,
375
+ outputCount: outputs.length,
376
+ outputs,
377
+ loopIterations: this.#loopIterations,
378
+ lastLoopStartedAt: this.#lastLoopStartedAt,
379
+ lastLoopCompletedAt: this.#lastLoopCompletedAt,
380
+ lastLoopDurationMs:
381
+ this.#lastLoopStartedAt !== null &&
382
+ this.#lastLoopCompletedAt !== null
383
+ ? Math.max(
384
+ 0,
385
+ this.#lastLoopCompletedAt - this.#lastLoopStartedAt,
386
+ )
387
+ : null,
388
+ transport: Object.freeze({ ...this.#transportTelemetry }),
389
+ });
390
+ }
391
+
392
+ async close(): Promise<void> {
393
+ if (this.#closed) return;
394
+ this.#closed = true;
395
+ this.#running = false;
396
+ this.#lifecycle.abort(new SacnLifecycleError("Output engine closed."));
397
+ this.#wakeLoop();
398
+ if (this.#loopPromise) {
399
+ await this.#loopPromise;
400
+ this.#loopPromise = null;
401
+ }
402
+ await Promise.allSettled(
403
+ [...this.#outputs.values()]
404
+ .map((output) => output.sending)
405
+ .filter((send): send is Promise<boolean> => send !== null),
406
+ );
407
+ if (this.#closeTransport) {
408
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
409
+ try {
410
+ await Promise.race([
411
+ this.#transport.close(),
412
+ new Promise<never>((_resolve, reject) => {
413
+ timeoutHandle = setTimeout(
414
+ () =>
415
+ reject(
416
+ new TransportError(
417
+ `Transport close timed out after ${this.#shutdownTimeoutMs}ms.`,
418
+ ),
419
+ ),
420
+ this.#shutdownTimeoutMs,
421
+ );
422
+ }),
423
+ ]);
424
+ } finally {
425
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
426
+ }
427
+ }
428
+ this.#outputs.clear();
429
+ this.#logger.info?.("Output engine closed.");
430
+ }
431
+
432
+ #getOrCreate(options: OutputOptions): OutputState {
433
+ const address = normalizeAddress(options);
434
+ const key = this.#key(address);
435
+ const existing = this.#outputs.get(key);
436
+ if (existing) {
437
+ this.#applyOptions(existing, options);
438
+ return existing;
439
+ }
440
+ if (this.#outputs.size >= this.#maxOutputs) {
441
+ throw new SacnLifecycleError(
442
+ `Output limit of ${this.#maxOutputs} has been reached.`,
443
+ );
444
+ }
445
+ const now = this.#clock.now();
446
+ const state = this.#createState(
447
+ {
448
+ ...address,
449
+ cid: options.cid ?? this.#createId(),
450
+ idleFps: options.idleFps ?? this.#defaultIdleFps,
451
+ sourceName: options.sourceName ?? null,
452
+ target: Array.from({ length: SLOT_COUNT }, () => 0),
453
+ updatedAt: this.#wallNow(),
454
+ },
455
+ new Uint8Array(SLOT_COUNT),
456
+ );
457
+ this.#applyOptions(state, options);
458
+ this.#outputs.set(key, state);
459
+ return state;
460
+ }
461
+
462
+ #createState(record: OutputRecord, frame: Uint8Array): OutputState {
463
+ if (frame.length !== SLOT_COUNT) {
464
+ throw new Error(`Stored frame must contain exactly ${SLOT_COUNT} values.`);
465
+ }
466
+ return {
467
+ address: normalizeAddress(record),
468
+ cid: assertCid(record.cid),
469
+ idleFps: record.idleFps,
470
+ sourceName:
471
+ record.sourceName === null ? null : assertSourceName(record.sourceName),
472
+ current: frame.slice(),
473
+ target: frame.slice(),
474
+ lastSent: new Uint8Array(SLOT_COUNT),
475
+ transitions: new Map(),
476
+ sequence: 0,
477
+ lastSentAt: null,
478
+ nextDueAt: this.#clock.now(),
479
+ dirty: true,
480
+ lastError: null,
481
+ consecutiveFailures: 0,
482
+ updatedAt: record.updatedAt,
483
+ revision: 0,
484
+ sending: null,
485
+ transportPending: null,
486
+ };
487
+ }
488
+
489
+ #applyOptions(output: OutputState, options: OutputOptions): void {
490
+ const cid =
491
+ options.cid === undefined ? output.cid : assertCid(options.cid);
492
+ const idleFps = options.idleFps ?? output.idleFps;
493
+ if (options.idleFps !== undefined) {
494
+ assertFps(options.idleFps, "Idle FPS");
495
+ }
496
+ const sourceName =
497
+ options.sourceName === undefined
498
+ ? output.sourceName
499
+ : assertSourceName(options.sourceName);
500
+ output.cid = cid;
501
+ output.idleFps = idleFps;
502
+ output.sourceName = sourceName;
503
+ }
504
+
505
+ #markChanged(output: OutputState, now: number): void {
506
+ output.dirty = true;
507
+ output.lastError = null;
508
+ output.nextDueAt = now;
509
+ output.updatedAt = this.#wallNow();
510
+ output.revision += 1;
511
+ this.#wakeLoop();
512
+ }
513
+
514
+ #updateTransitions(output: OutputState, now: number): boolean {
515
+ let active = false;
516
+ for (const [index, transition] of output.transitions) {
517
+ const elapsed = now - transition.startedAt;
518
+ if (elapsed >= transition.durationMs) {
519
+ output.current[index] = transition.to;
520
+ output.transitions.delete(index);
521
+ continue;
522
+ }
523
+ const progress = Math.max(0, elapsed / transition.durationMs);
524
+ output.current[index] = Math.round(
525
+ transition.from + (transition.to - transition.from) * progress,
526
+ );
527
+ active = true;
528
+ }
529
+ return active;
530
+ }
531
+
532
+ #mode(output: OutputState): FrameMode {
533
+ return output.dirty ||
534
+ output.transitions.size > 0 ||
535
+ !framesEqual(output.current, output.lastSent)
536
+ ? "active"
537
+ : "idle";
538
+ }
539
+
540
+ #interval(output: OutputState): number {
541
+ return this.#mode(output) === "active"
542
+ ? this.#activeIntervalMs
543
+ : 1000 / output.idleFps;
544
+ }
545
+
546
+ #snapshot(output: OutputState): OutputSnapshot {
547
+ return Object.freeze({
548
+ universe: output.address.universe,
549
+ priority: output.address.priority,
550
+ cid: output.cid,
551
+ sourceName: output.sourceName,
552
+ idleFps: output.idleFps,
553
+ current: frozenFrame(output.current),
554
+ target: frozenFrame(output.target),
555
+ lastSent: frozenFrame(output.lastSent),
556
+ activeTransitions: output.transitions.size,
557
+ frameMode: this.#mode(output),
558
+ dirty: output.dirty,
559
+ sequence: output.sequence,
560
+ lastSentAt: output.lastSentAt,
561
+ nextDueAt: output.nextDueAt,
562
+ lastError: output.lastError,
563
+ updatedAt: output.updatedAt,
564
+ });
565
+ }
566
+
567
+ async #loop(): Promise<void> {
568
+ while (this.#running && !this.#lifecycle.signal.aborted) {
569
+ const now = this.#clock.now();
570
+ this.#lastLoopStartedAt = now;
571
+ this.#loopIterations += 1;
572
+ const sends: Promise<boolean>[] = [];
573
+ for (const output of this.#outputs.values()) {
574
+ if (this.#pendingMutations.has(this.#key(output.address))) continue;
575
+ this.#updateTransitions(output, now);
576
+ if (
577
+ !output.sending &&
578
+ !output.transportPending &&
579
+ (output.dirty || now >= output.nextDueAt)
580
+ ) {
581
+ sends.push(this.#beginSend(output, now));
582
+ }
583
+ }
584
+ await Promise.allSettled(sends);
585
+ this.#lastLoopCompletedAt = this.#clock.now();
586
+ if (!this.#running) break;
587
+ let nextDueAt = Number.POSITIVE_INFINITY;
588
+ for (const output of this.#outputs.values()) {
589
+ if (this.#pendingMutations.has(this.#key(output.address))) continue;
590
+ nextDueAt = Math.min(nextDueAt, output.nextDueAt);
591
+ }
592
+ const delay = Number.isFinite(nextDueAt)
593
+ ? Math.max(1, nextDueAt - this.#clock.now())
594
+ : 1000;
595
+ await this.#wait(delay);
596
+ }
597
+ }
598
+
599
+ #beginSend(output: OutputState, now: number): Promise<boolean> {
600
+ const send = this.#send(output, now);
601
+ output.sending = send;
602
+ void send.finally(() => {
603
+ if (output.sending === send) output.sending = null;
604
+ });
605
+ return send;
606
+ }
607
+
608
+ async #send(output: OutputState, now: number): Promise<boolean> {
609
+ const revision = output.revision;
610
+ const wasTransitioning = output.transitions.size > 0;
611
+ const sentFrame = output.current.slice();
612
+ const packet: OutputPacket = Object.freeze({
613
+ cid: output.cid,
614
+ universe: output.address.universe,
615
+ priority: output.address.priority,
616
+ sequence: output.sequence,
617
+ sourceName: output.sourceName,
618
+ data: sentFrame.slice(),
619
+ });
620
+ if (output.consecutiveFailures > 0) {
621
+ this.#transportTelemetry.sendRetries += 1;
622
+ }
623
+ this.#transportTelemetry.sendAttempts += 1;
624
+ try {
625
+ await this.#sendWithTimeout(packet, output);
626
+ output.lastSent.set(sentFrame);
627
+ output.lastSentAt = now;
628
+ output.sequence = (output.sequence + 1) & 0xff;
629
+ output.dirty = output.revision !== revision;
630
+ output.lastError = null;
631
+ output.consecutiveFailures = 0;
632
+ output.nextDueAt =
633
+ now +
634
+ (wasTransitioning ? this.#activeIntervalMs : this.#interval(output));
635
+ this.#transportTelemetry.sendSuccesses += 1;
636
+ return true;
637
+ } catch (error) {
638
+ if (this.#closed) return false;
639
+ output.lastError = errorMessage(error);
640
+ output.dirty = true;
641
+ output.consecutiveFailures += 1;
642
+ const retryDelayMs = Math.min(
643
+ 5000,
644
+ this.#activeIntervalMs * 2 ** Math.min(output.consecutiveFailures - 1, 8),
645
+ );
646
+ output.nextDueAt = now + retryDelayMs;
647
+ this.#transportTelemetry.sendFailures += 1;
648
+ if (error instanceof TransportTimeoutError) {
649
+ this.#transportTelemetry.sendTimeouts += 1;
650
+ }
651
+ this.#logger.error?.("Output transport send failed.", {
652
+ universe: output.address.universe,
653
+ priority: output.address.priority,
654
+ error: output.lastError,
655
+ });
656
+ return false;
657
+ }
658
+ }
659
+
660
+ async #sendWithTimeout(
661
+ packet: OutputPacket,
662
+ output: OutputState,
663
+ ): Promise<void> {
664
+ const sendController = new AbortController();
665
+ const timeoutController = new AbortController();
666
+ let rejectOnClose: ((reason: unknown) => void) | undefined;
667
+ const closed = new Promise<never>((_resolve, reject) => {
668
+ rejectOnClose = reject;
669
+ });
670
+ const onClose = (): void => {
671
+ const reason =
672
+ this.#lifecycle.signal.reason ??
673
+ new SacnLifecycleError("Output engine closed.");
674
+ sendController.abort(reason);
675
+ rejectOnClose?.(reason);
676
+ };
677
+ this.#lifecycle.signal.addEventListener("abort", onClose, { once: true });
678
+ const timeoutError = new TransportTimeoutError(
679
+ this.#sendTimeoutMs,
680
+ packet.universe,
681
+ packet.priority,
682
+ );
683
+ const timeout = this.#clock
684
+ .sleep(this.#sendTimeoutMs, timeoutController.signal)
685
+ .then(() => {
686
+ sendController.abort(timeoutError);
687
+ throw timeoutError;
688
+ });
689
+ const transportSend = Promise.resolve()
690
+ .then(() => this.#transport.send(packet, sendController.signal))
691
+ .catch((error: unknown) => {
692
+ if (error instanceof TransportTimeoutError) throw error;
693
+ if (this.#lifecycle.signal.aborted) throw error;
694
+ throw new TransportError(
695
+ `Transport send failed for output ${packet.universe}:${packet.priority}.`,
696
+ error,
697
+ );
698
+ });
699
+ const settlement = transportSend.then(
700
+ () => undefined,
701
+ () => undefined,
702
+ );
703
+ output.transportPending = settlement;
704
+ void settlement.finally(() => {
705
+ if (output.transportPending === settlement) {
706
+ output.transportPending = null;
707
+ this.#wakeLoop();
708
+ }
709
+ });
710
+ try {
711
+ await Promise.race([
712
+ transportSend,
713
+ timeout,
714
+ closed,
715
+ ]);
716
+ } finally {
717
+ timeoutController.abort();
718
+ this.#lifecycle.signal.removeEventListener("abort", onClose);
719
+ }
720
+ }
721
+
722
+ async #wait(delayMs: number): Promise<void> {
723
+ const controller = new AbortController();
724
+ const onClose = (): void => controller.abort(this.#lifecycle.signal.reason);
725
+ this.#lifecycle.signal.addEventListener("abort", onClose, { once: true });
726
+ const wake = new Promise<void>((resolve) => {
727
+ this.#wake = resolve;
728
+ });
729
+ try {
730
+ await Promise.race([
731
+ this.#clock.sleep(delayMs, controller.signal),
732
+ wake,
733
+ ]);
734
+ } catch {
735
+ // Closing the engine aborts the scheduler wait.
736
+ } finally {
737
+ this.#wake = null;
738
+ controller.abort();
739
+ this.#lifecycle.signal.removeEventListener("abort", onClose);
740
+ }
741
+ }
742
+
743
+ #wakeLoop(): void {
744
+ this.#wake?.();
745
+ }
746
+
747
+ #key(address: Required<OutputAddress>): string {
748
+ return `${address.universe}:${address.priority}`;
749
+ }
750
+
751
+ #cloneState(output: OutputState): OutputState {
752
+ return {
753
+ ...output,
754
+ address: Object.freeze({ ...output.address }),
755
+ current: output.current.slice(),
756
+ target: output.target.slice(),
757
+ lastSent: output.lastSent.slice(),
758
+ transitions: new Map(
759
+ [...output.transitions].map(([index, transition]) => [
760
+ index,
761
+ { ...transition },
762
+ ]),
763
+ ),
764
+ sending: null,
765
+ transportPending: null,
766
+ };
767
+ }
768
+
769
+ #wallNow(): number {
770
+ return this.#clock.wallNow?.() ?? Date.now();
771
+ }
772
+
773
+ #assertOpen(): void {
774
+ if (this.#closed) {
775
+ throw new SacnLifecycleError("Output engine is closed.");
776
+ }
777
+ }
778
+ }