@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,884 @@
1
+ import { _ as validateChannelWrites, c as assertCid, f as assertSourceName, g as toValidatedFrame, h as normalizeAddress, i as SacnLifecycleError, l as assertFade, m as channelValuesToWrites, n as PersistenceError, o as TransportError, p as assertTimeout, s as TransportTimeoutError, u as assertFps, v as validateTransitionWrites } from "./validation-BdsVeyAE.js";
2
+ //#region src/memory-store.ts
3
+ const keyOf = ({ universe, priority }) => `${universe}:${priority}`;
4
+ const cloneRecord = (record) => Object.freeze({
5
+ ...record,
6
+ target: Object.freeze([...record.target])
7
+ });
8
+ var MemoryOutputStore = class {
9
+ #records = /* @__PURE__ */ new Map();
10
+ async get(address) {
11
+ const record = this.#records.get(keyOf(address));
12
+ return record ? cloneRecord(record) : null;
13
+ }
14
+ async list() {
15
+ return Object.freeze([...this.#records.values()].sort((left, right) => left.universe - right.universe || left.priority - right.priority).map(cloneRecord));
16
+ }
17
+ async remove(address) {
18
+ this.#records.delete(keyOf(address));
19
+ }
20
+ async save(record) {
21
+ this.#records.set(keyOf({
22
+ universe: record.universe,
23
+ priority: record.priority
24
+ }), cloneRecord(record));
25
+ }
26
+ };
27
+ //#endregion
28
+ //#region src/output-engine.ts
29
+ const framesEqual = (left, right) => {
30
+ for (let index = 0; index < 512; index += 1) if (left[index] !== right[index]) return false;
31
+ return true;
32
+ };
33
+ const frozenFrame = (frame) => Object.freeze(Array.from(frame));
34
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
35
+ var SystemClock = class {
36
+ now() {
37
+ return performance.now();
38
+ }
39
+ wallNow() {
40
+ return Date.now();
41
+ }
42
+ sleep(delayMs, signal) {
43
+ return new Promise((resolve, reject) => {
44
+ if (signal.aborted) {
45
+ reject(signal.reason ?? /* @__PURE__ */ new Error("Operation aborted."));
46
+ return;
47
+ }
48
+ const handle = setTimeout(resolve, delayMs);
49
+ signal.addEventListener("abort", () => {
50
+ clearTimeout(handle);
51
+ reject(signal.reason ?? /* @__PURE__ */ new Error("Operation aborted."));
52
+ }, { once: true });
53
+ });
54
+ }
55
+ };
56
+ var OutputEngine = class {
57
+ #outputs = /* @__PURE__ */ new Map();
58
+ #pendingMutations = /* @__PURE__ */ new Set();
59
+ #transport;
60
+ #clock;
61
+ #logger;
62
+ #activeIntervalMs;
63
+ #defaultIdleFps;
64
+ #sendTimeoutMs;
65
+ #shutdownTimeoutMs;
66
+ #maxOutputs;
67
+ #closeTransport;
68
+ #createId;
69
+ #lifecycle = new AbortController();
70
+ #running = false;
71
+ #closed = false;
72
+ #loopPromise = null;
73
+ #wake = null;
74
+ #loopIterations = 0;
75
+ #lastLoopStartedAt = null;
76
+ #lastLoopCompletedAt = null;
77
+ #transportTelemetry = {
78
+ sendAttempts: 0,
79
+ sendFailures: 0,
80
+ sendRetries: 0,
81
+ sendSuccesses: 0,
82
+ sendTimeouts: 0
83
+ };
84
+ constructor(options) {
85
+ const activeFps = options.activeFps ?? 44;
86
+ const idleFps = options.idleFps ?? 2;
87
+ const sendTimeoutMs = options.sendTimeoutMs ?? 1e3;
88
+ const shutdownTimeoutMs = options.shutdownTimeoutMs ?? 2e3;
89
+ const maxOutputs = options.maxOutputs ?? 1024;
90
+ assertFps(activeFps, "Active FPS");
91
+ assertFps(idleFps, "Idle FPS");
92
+ assertTimeout(sendTimeoutMs);
93
+ assertTimeout(shutdownTimeoutMs);
94
+ if (!Number.isInteger(maxOutputs) || maxOutputs < 1) throw new SacnLifecycleError("Maximum outputs must be a positive integer.");
95
+ this.#transport = options.transport;
96
+ this.#clock = options.clock;
97
+ this.#logger = options.logger;
98
+ this.#activeIntervalMs = 1e3 / activeFps;
99
+ this.#defaultIdleFps = idleFps;
100
+ this.#sendTimeoutMs = sendTimeoutMs;
101
+ this.#shutdownTimeoutMs = shutdownTimeoutMs;
102
+ this.#maxOutputs = maxOutputs;
103
+ this.#closeTransport = options.closeTransport ?? true;
104
+ this.#createId = options.createId;
105
+ }
106
+ restore(record) {
107
+ this.#assertOpen();
108
+ const address = normalizeAddress(record);
109
+ assertFps(record.idleFps, "Idle FPS");
110
+ if (this.#outputs.has(this.#key(address))) return;
111
+ if (this.#outputs.size >= this.#maxOutputs) throw new SacnLifecycleError(`Output limit of ${this.#maxOutputs} has been reached while restoring state.`);
112
+ const state = this.#createState(record, toValidatedFrame(record.target));
113
+ this.#outputs.set(this.#key(address), state);
114
+ }
115
+ async beginMutation(addressInput) {
116
+ this.#assertOpen();
117
+ const address = normalizeAddress(addressInput);
118
+ const key = this.#key(address);
119
+ if (this.#pendingMutations.has(key)) throw new SacnLifecycleError(`Output ${key} already has a pending mutation.`);
120
+ const output = this.#outputs.get(key);
121
+ if (output?.sending) await output.sending;
122
+ if (output?.transportPending) await output.transportPending;
123
+ this.#pendingMutations.add(key);
124
+ return {
125
+ key,
126
+ state: output ? this.#cloneState(output) : null
127
+ };
128
+ }
129
+ commitMutation(checkpoint) {
130
+ this.#pendingMutations.delete(checkpoint.key);
131
+ this.#wakeLoop();
132
+ }
133
+ rollbackMutation(checkpoint) {
134
+ if (checkpoint.state) this.#outputs.set(checkpoint.key, checkpoint.state);
135
+ else this.#outputs.delete(checkpoint.key);
136
+ this.#pendingMutations.delete(checkpoint.key);
137
+ this.#wakeLoop();
138
+ }
139
+ start() {
140
+ this.#assertOpen();
141
+ if (this.#running) return;
142
+ this.#running = true;
143
+ this.#loopPromise = this.#loop();
144
+ this.#logger.info?.("Output engine started.", {
145
+ activeFrameIntervalMs: this.#activeIntervalMs,
146
+ idleFps: this.#defaultIdleFps
147
+ });
148
+ }
149
+ /**
150
+ * Pauses the scheduler without tearing down outputs or the transport.
151
+ * `start()` can resume the loop afterward.
152
+ */
153
+ async stop() {
154
+ this.#assertOpen();
155
+ if (!this.#running) return;
156
+ this.#running = false;
157
+ this.#wakeLoop();
158
+ if (this.#loopPromise) {
159
+ await this.#loopPromise;
160
+ this.#loopPromise = null;
161
+ }
162
+ this.#logger.info?.("Output engine stopped.");
163
+ }
164
+ writeFrame(options, frame, durationMs) {
165
+ this.#assertOpen();
166
+ assertFade(durationMs);
167
+ const output = this.#getOrCreate(options);
168
+ const now = this.#clock.now();
169
+ this.#updateTransitions(output, now);
170
+ if (durationMs === 0) {
171
+ output.current.set(frame);
172
+ output.target.set(frame);
173
+ output.transitions.clear();
174
+ } else for (let index = 0; index < 512; index += 1) {
175
+ const target = frame[index] ?? 0;
176
+ if (target === output.current[index]) {
177
+ output.target[index] = target;
178
+ output.transitions.delete(index);
179
+ } else {
180
+ output.target[index] = target;
181
+ output.transitions.set(index, {
182
+ from: output.current[index] ?? 0,
183
+ to: target,
184
+ startedAt: now,
185
+ durationMs
186
+ });
187
+ }
188
+ }
189
+ this.#markChanged(output, now);
190
+ return this.#snapshot(output);
191
+ }
192
+ writeChannels(options, writes) {
193
+ this.#assertOpen();
194
+ validateChannelWrites(writes);
195
+ const output = this.#getOrCreate(options);
196
+ const now = this.#clock.now();
197
+ this.#updateTransitions(output, now);
198
+ for (const write of writes) {
199
+ const index = write.channel - 1;
200
+ const durationMs = write.durationMs ?? 0;
201
+ const from = output.current[index] ?? 0;
202
+ output.target[index] = write.value;
203
+ if (durationMs === 0 || from === write.value) {
204
+ output.current[index] = write.value;
205
+ output.transitions.delete(index);
206
+ } else output.transitions.set(index, {
207
+ from,
208
+ to: write.value,
209
+ startedAt: now,
210
+ durationMs
211
+ });
212
+ }
213
+ this.#markChanged(output, now);
214
+ return this.#snapshot(output);
215
+ }
216
+ getOutput(addressInput) {
217
+ this.#assertOpen();
218
+ const address = normalizeAddress(addressInput);
219
+ const output = this.#outputs.get(this.#key(address));
220
+ if (!output) return null;
221
+ this.#updateTransitions(output, this.#clock.now());
222
+ return this.#snapshot(output);
223
+ }
224
+ listOutputs() {
225
+ this.#assertOpen();
226
+ const now = this.#clock.now();
227
+ return Object.freeze([...this.#outputs.values()].sort((left, right) => left.address.universe - right.address.universe || left.address.priority - right.address.priority).map((output) => {
228
+ this.#updateTransitions(output, now);
229
+ return this.#snapshot(output);
230
+ }));
231
+ }
232
+ async clearOutput(addressInput) {
233
+ this.#assertOpen();
234
+ const address = normalizeAddress(addressInput);
235
+ const output = this.#outputs.get(this.#key(address));
236
+ if (!output) return false;
237
+ if (output.sending) await output.sending;
238
+ if (output.transportPending) await output.transportPending;
239
+ const now = this.#clock.now();
240
+ output.current.fill(0);
241
+ output.target.fill(0);
242
+ output.transitions.clear();
243
+ this.#markChanged(output, now);
244
+ if (!await this.#beginSend(output, now)) throw new TransportError(`Failed to send blackout frame for ${this.#key(address)}.`);
245
+ this.#outputs.delete(this.#key(address));
246
+ try {
247
+ await this.#transport.closeOutput?.(address);
248
+ } catch (error) {
249
+ throw new TransportError(`Failed to close output ${this.#key(address)}.`, error);
250
+ }
251
+ this.#wakeLoop();
252
+ return true;
253
+ }
254
+ getTelemetry() {
255
+ const outputs = this.#closed ? Object.freeze([]) : this.listOutputs();
256
+ return Object.freeze({
257
+ closed: this.#closed,
258
+ running: this.#running,
259
+ outputCount: outputs.length,
260
+ outputs,
261
+ loopIterations: this.#loopIterations,
262
+ lastLoopStartedAt: this.#lastLoopStartedAt,
263
+ lastLoopCompletedAt: this.#lastLoopCompletedAt,
264
+ lastLoopDurationMs: this.#lastLoopStartedAt !== null && this.#lastLoopCompletedAt !== null ? Math.max(0, this.#lastLoopCompletedAt - this.#lastLoopStartedAt) : null,
265
+ transport: Object.freeze({ ...this.#transportTelemetry })
266
+ });
267
+ }
268
+ async close() {
269
+ if (this.#closed) return;
270
+ this.#closed = true;
271
+ this.#running = false;
272
+ this.#lifecycle.abort(new SacnLifecycleError("Output engine closed."));
273
+ this.#wakeLoop();
274
+ if (this.#loopPromise) {
275
+ await this.#loopPromise;
276
+ this.#loopPromise = null;
277
+ }
278
+ await Promise.allSettled([...this.#outputs.values()].map((output) => output.sending).filter((send) => send !== null));
279
+ if (this.#closeTransport) {
280
+ let timeoutHandle;
281
+ try {
282
+ await Promise.race([this.#transport.close(), new Promise((_resolve, reject) => {
283
+ timeoutHandle = setTimeout(() => reject(new TransportError(`Transport close timed out after ${this.#shutdownTimeoutMs}ms.`)), this.#shutdownTimeoutMs);
284
+ })]);
285
+ } finally {
286
+ if (timeoutHandle !== void 0) clearTimeout(timeoutHandle);
287
+ }
288
+ }
289
+ this.#outputs.clear();
290
+ this.#logger.info?.("Output engine closed.");
291
+ }
292
+ #getOrCreate(options) {
293
+ const address = normalizeAddress(options);
294
+ const key = this.#key(address);
295
+ const existing = this.#outputs.get(key);
296
+ if (existing) {
297
+ this.#applyOptions(existing, options);
298
+ return existing;
299
+ }
300
+ if (this.#outputs.size >= this.#maxOutputs) throw new SacnLifecycleError(`Output limit of ${this.#maxOutputs} has been reached.`);
301
+ this.#clock.now();
302
+ const state = this.#createState({
303
+ ...address,
304
+ cid: options.cid ?? this.#createId(),
305
+ idleFps: options.idleFps ?? this.#defaultIdleFps,
306
+ sourceName: options.sourceName ?? null,
307
+ target: Array.from({ length: 512 }, () => 0),
308
+ updatedAt: this.#wallNow()
309
+ }, /* @__PURE__ */ new Uint8Array(512));
310
+ this.#applyOptions(state, options);
311
+ this.#outputs.set(key, state);
312
+ return state;
313
+ }
314
+ #createState(record, frame) {
315
+ if (frame.length !== 512) throw new Error(`Stored frame must contain exactly 512 values.`);
316
+ return {
317
+ address: normalizeAddress(record),
318
+ cid: assertCid(record.cid),
319
+ idleFps: record.idleFps,
320
+ sourceName: record.sourceName === null ? null : assertSourceName(record.sourceName),
321
+ current: frame.slice(),
322
+ target: frame.slice(),
323
+ lastSent: /* @__PURE__ */ new Uint8Array(512),
324
+ transitions: /* @__PURE__ */ new Map(),
325
+ sequence: 0,
326
+ lastSentAt: null,
327
+ nextDueAt: this.#clock.now(),
328
+ dirty: true,
329
+ lastError: null,
330
+ consecutiveFailures: 0,
331
+ updatedAt: record.updatedAt,
332
+ revision: 0,
333
+ sending: null,
334
+ transportPending: null
335
+ };
336
+ }
337
+ #applyOptions(output, options) {
338
+ const cid = options.cid === void 0 ? output.cid : assertCid(options.cid);
339
+ const idleFps = options.idleFps ?? output.idleFps;
340
+ if (options.idleFps !== void 0) assertFps(options.idleFps, "Idle FPS");
341
+ const sourceName = options.sourceName === void 0 ? output.sourceName : assertSourceName(options.sourceName);
342
+ output.cid = cid;
343
+ output.idleFps = idleFps;
344
+ output.sourceName = sourceName;
345
+ }
346
+ #markChanged(output, now) {
347
+ output.dirty = true;
348
+ output.lastError = null;
349
+ output.nextDueAt = now;
350
+ output.updatedAt = this.#wallNow();
351
+ output.revision += 1;
352
+ this.#wakeLoop();
353
+ }
354
+ #updateTransitions(output, now) {
355
+ let active = false;
356
+ for (const [index, transition] of output.transitions) {
357
+ const elapsed = now - transition.startedAt;
358
+ if (elapsed >= transition.durationMs) {
359
+ output.current[index] = transition.to;
360
+ output.transitions.delete(index);
361
+ continue;
362
+ }
363
+ const progress = Math.max(0, elapsed / transition.durationMs);
364
+ output.current[index] = Math.round(transition.from + (transition.to - transition.from) * progress);
365
+ active = true;
366
+ }
367
+ return active;
368
+ }
369
+ #mode(output) {
370
+ return output.dirty || output.transitions.size > 0 || !framesEqual(output.current, output.lastSent) ? "active" : "idle";
371
+ }
372
+ #interval(output) {
373
+ return this.#mode(output) === "active" ? this.#activeIntervalMs : 1e3 / output.idleFps;
374
+ }
375
+ #snapshot(output) {
376
+ return Object.freeze({
377
+ universe: output.address.universe,
378
+ priority: output.address.priority,
379
+ cid: output.cid,
380
+ sourceName: output.sourceName,
381
+ idleFps: output.idleFps,
382
+ current: frozenFrame(output.current),
383
+ target: frozenFrame(output.target),
384
+ lastSent: frozenFrame(output.lastSent),
385
+ activeTransitions: output.transitions.size,
386
+ frameMode: this.#mode(output),
387
+ dirty: output.dirty,
388
+ sequence: output.sequence,
389
+ lastSentAt: output.lastSentAt,
390
+ nextDueAt: output.nextDueAt,
391
+ lastError: output.lastError,
392
+ updatedAt: output.updatedAt
393
+ });
394
+ }
395
+ async #loop() {
396
+ while (this.#running && !this.#lifecycle.signal.aborted) {
397
+ const now = this.#clock.now();
398
+ this.#lastLoopStartedAt = now;
399
+ this.#loopIterations += 1;
400
+ const sends = [];
401
+ for (const output of this.#outputs.values()) {
402
+ if (this.#pendingMutations.has(this.#key(output.address))) continue;
403
+ this.#updateTransitions(output, now);
404
+ if (!output.sending && !output.transportPending && (output.dirty || now >= output.nextDueAt)) sends.push(this.#beginSend(output, now));
405
+ }
406
+ await Promise.allSettled(sends);
407
+ this.#lastLoopCompletedAt = this.#clock.now();
408
+ if (!this.#running) break;
409
+ let nextDueAt = Number.POSITIVE_INFINITY;
410
+ for (const output of this.#outputs.values()) {
411
+ if (this.#pendingMutations.has(this.#key(output.address))) continue;
412
+ nextDueAt = Math.min(nextDueAt, output.nextDueAt);
413
+ }
414
+ const delay = Number.isFinite(nextDueAt) ? Math.max(1, nextDueAt - this.#clock.now()) : 1e3;
415
+ await this.#wait(delay);
416
+ }
417
+ }
418
+ #beginSend(output, now) {
419
+ const send = this.#send(output, now);
420
+ output.sending = send;
421
+ send.finally(() => {
422
+ if (output.sending === send) output.sending = null;
423
+ });
424
+ return send;
425
+ }
426
+ async #send(output, now) {
427
+ const revision = output.revision;
428
+ const wasTransitioning = output.transitions.size > 0;
429
+ const sentFrame = output.current.slice();
430
+ const packet = Object.freeze({
431
+ cid: output.cid,
432
+ universe: output.address.universe,
433
+ priority: output.address.priority,
434
+ sequence: output.sequence,
435
+ sourceName: output.sourceName,
436
+ data: sentFrame.slice()
437
+ });
438
+ if (output.consecutiveFailures > 0) this.#transportTelemetry.sendRetries += 1;
439
+ this.#transportTelemetry.sendAttempts += 1;
440
+ try {
441
+ await this.#sendWithTimeout(packet, output);
442
+ output.lastSent.set(sentFrame);
443
+ output.lastSentAt = now;
444
+ output.sequence = output.sequence + 1 & 255;
445
+ output.dirty = output.revision !== revision;
446
+ output.lastError = null;
447
+ output.consecutiveFailures = 0;
448
+ output.nextDueAt = now + (wasTransitioning ? this.#activeIntervalMs : this.#interval(output));
449
+ this.#transportTelemetry.sendSuccesses += 1;
450
+ return true;
451
+ } catch (error) {
452
+ if (this.#closed) return false;
453
+ output.lastError = errorMessage(error);
454
+ output.dirty = true;
455
+ output.consecutiveFailures += 1;
456
+ output.nextDueAt = now + Math.min(5e3, this.#activeIntervalMs * 2 ** Math.min(output.consecutiveFailures - 1, 8));
457
+ this.#transportTelemetry.sendFailures += 1;
458
+ if (error instanceof TransportTimeoutError) this.#transportTelemetry.sendTimeouts += 1;
459
+ this.#logger.error?.("Output transport send failed.", {
460
+ universe: output.address.universe,
461
+ priority: output.address.priority,
462
+ error: output.lastError
463
+ });
464
+ return false;
465
+ }
466
+ }
467
+ async #sendWithTimeout(packet, output) {
468
+ const sendController = new AbortController();
469
+ const timeoutController = new AbortController();
470
+ let rejectOnClose;
471
+ const closed = new Promise((_resolve, reject) => {
472
+ rejectOnClose = reject;
473
+ });
474
+ const onClose = () => {
475
+ const reason = this.#lifecycle.signal.reason ?? new SacnLifecycleError("Output engine closed.");
476
+ sendController.abort(reason);
477
+ rejectOnClose?.(reason);
478
+ };
479
+ this.#lifecycle.signal.addEventListener("abort", onClose, { once: true });
480
+ const timeoutError = new TransportTimeoutError(this.#sendTimeoutMs, packet.universe, packet.priority);
481
+ const timeout = this.#clock.sleep(this.#sendTimeoutMs, timeoutController.signal).then(() => {
482
+ sendController.abort(timeoutError);
483
+ throw timeoutError;
484
+ });
485
+ const transportSend = Promise.resolve().then(() => this.#transport.send(packet, sendController.signal)).catch((error) => {
486
+ if (error instanceof TransportTimeoutError) throw error;
487
+ if (this.#lifecycle.signal.aborted) throw error;
488
+ throw new TransportError(`Transport send failed for output ${packet.universe}:${packet.priority}.`, error);
489
+ });
490
+ const settlement = transportSend.then(() => void 0, () => void 0);
491
+ output.transportPending = settlement;
492
+ settlement.finally(() => {
493
+ if (output.transportPending === settlement) {
494
+ output.transportPending = null;
495
+ this.#wakeLoop();
496
+ }
497
+ });
498
+ try {
499
+ await Promise.race([
500
+ transportSend,
501
+ timeout,
502
+ closed
503
+ ]);
504
+ } finally {
505
+ timeoutController.abort();
506
+ this.#lifecycle.signal.removeEventListener("abort", onClose);
507
+ }
508
+ }
509
+ async #wait(delayMs) {
510
+ const controller = new AbortController();
511
+ const onClose = () => controller.abort(this.#lifecycle.signal.reason);
512
+ this.#lifecycle.signal.addEventListener("abort", onClose, { once: true });
513
+ const wake = new Promise((resolve) => {
514
+ this.#wake = resolve;
515
+ });
516
+ try {
517
+ await Promise.race([this.#clock.sleep(delayMs, controller.signal), wake]);
518
+ } catch {} finally {
519
+ this.#wake = null;
520
+ controller.abort();
521
+ this.#lifecycle.signal.removeEventListener("abort", onClose);
522
+ }
523
+ }
524
+ #wakeLoop() {
525
+ this.#wake?.();
526
+ }
527
+ #key(address) {
528
+ return `${address.universe}:${address.priority}`;
529
+ }
530
+ #cloneState(output) {
531
+ return {
532
+ ...output,
533
+ address: Object.freeze({ ...output.address }),
534
+ current: output.current.slice(),
535
+ target: output.target.slice(),
536
+ lastSent: output.lastSent.slice(),
537
+ transitions: new Map([...output.transitions].map(([index, transition]) => [index, { ...transition }])),
538
+ sending: null,
539
+ transportPending: null
540
+ };
541
+ }
542
+ #wallNow() {
543
+ return this.#clock.wallNow?.() ?? Date.now();
544
+ }
545
+ #assertOpen() {
546
+ if (this.#closed) throw new SacnLifecycleError("Output engine is closed.");
547
+ }
548
+ };
549
+ //#endregion
550
+ //#region src/source.ts
551
+ const noopLogger = Object.freeze({});
552
+ const defaultCreateId = () => {
553
+ if (typeof globalThis.crypto?.randomUUID !== "function") throw new SacnLifecycleError("No UUID generator is available; inject createId in source options.");
554
+ return globalThis.crypto.randomUUID();
555
+ };
556
+ const toRecord = (snapshot) => Object.freeze({
557
+ universe: snapshot.universe,
558
+ priority: snapshot.priority,
559
+ cid: snapshot.cid,
560
+ sourceName: snapshot.sourceName,
561
+ idleFps: snapshot.idleFps,
562
+ target: Object.freeze([...snapshot.target]),
563
+ updatedAt: snapshot.updatedAt
564
+ });
565
+ const outputOptionsFrom = (universe, priority, defaults) => ({
566
+ universe,
567
+ priority,
568
+ ...defaults.cid === void 0 ? {} : { cid: defaults.cid },
569
+ ...defaults.idleFps === void 0 ? {} : { idleFps: defaults.idleFps },
570
+ ...defaults.sourceName === void 0 ? {} : { sourceName: defaults.sourceName }
571
+ });
572
+ /**
573
+ * Thin handle for a single `(universe, priority)` output address.
574
+ */
575
+ var Universe = class {
576
+ #source;
577
+ #defaults;
578
+ universe;
579
+ priority;
580
+ constructor(source, universe, priority, defaults) {
581
+ this.#source = source;
582
+ this.universe = universe;
583
+ this.priority = priority;
584
+ this.#defaults = defaults;
585
+ }
586
+ setChannels(values, options = {}) {
587
+ return this.#source.writeChannels(outputOptionsFrom(this.universe, this.priority, this.#defaults), () => channelValuesToWrites(values), options.signal);
588
+ }
589
+ fadeChannels(values, options) {
590
+ return this.#source.writeChannels(outputOptionsFrom(this.universe, this.priority, this.#defaults), () => {
591
+ assertFade(options.durationMs);
592
+ return channelValuesToWrites(values, options.durationMs);
593
+ }, options.signal);
594
+ }
595
+ transition(writes, options = {}) {
596
+ return this.#source.writeChannels(outputOptionsFrom(this.universe, this.priority, this.#defaults), () => {
597
+ validateTransitionWrites(writes);
598
+ return writes.map((write) => ({
599
+ channel: write.channel,
600
+ value: write.value,
601
+ durationMs: write.durationMs
602
+ }));
603
+ }, options.signal);
604
+ }
605
+ write(values, options = {}) {
606
+ return this.#source.writeFrame(outputOptionsFrom(this.universe, this.priority, this.#defaults), values, options.durationMs ?? 0, options.signal);
607
+ }
608
+ get() {
609
+ return this.#source.getUniverse(this.universe, this.priority);
610
+ }
611
+ clear(options = {}) {
612
+ return this.#source.clearUniverse(this.universe, this.priority, options.signal);
613
+ }
614
+ };
615
+ /**
616
+ * Runtime-neutral sACN output source over scheduling and storage.
617
+ * Mutating requests are serialized so their returned and persisted snapshots
618
+ * represent the same operation order.
619
+ */
620
+ var SacnSource = class {
621
+ #store;
622
+ #engine;
623
+ #ownsStore;
624
+ #logger;
625
+ #onStart;
626
+ #onStop;
627
+ #onClose;
628
+ #listeners = /* @__PURE__ */ new Set();
629
+ #universes = /* @__PURE__ */ new Map();
630
+ #queue = Promise.resolve();
631
+ #running = false;
632
+ #restored = false;
633
+ #closing = false;
634
+ #closed = false;
635
+ #closePromise = null;
636
+ constructor(options) {
637
+ this.#store = options.store ?? new MemoryOutputStore();
638
+ this.#ownsStore = options.ownsStore ?? options.store === void 0;
639
+ this.#logger = options.logger ?? noopLogger;
640
+ if (options.onStart !== void 0) this.#onStart = options.onStart;
641
+ if (options.onStop !== void 0) this.#onStop = options.onStop;
642
+ if (options.onClose !== void 0) this.#onClose = options.onClose;
643
+ this.#engine = new OutputEngine({
644
+ transport: options.transport,
645
+ clock: options.clock ?? new SystemClock(),
646
+ logger: this.#logger,
647
+ createId: options.createId ?? defaultCreateId,
648
+ ...options.activeFps === void 0 ? {} : { activeFps: options.activeFps },
649
+ ...options.idleFps === void 0 ? {} : { idleFps: options.idleFps },
650
+ ...options.sendTimeoutMs === void 0 ? {} : { sendTimeoutMs: options.sendTimeoutMs },
651
+ ...options.shutdownTimeoutMs === void 0 ? {} : { shutdownTimeoutMs: options.shutdownTimeoutMs },
652
+ ...options.maxOutputs === void 0 ? {} : { maxOutputs: options.maxOutputs },
653
+ closeTransport: options.ownsTransport ?? false
654
+ });
655
+ }
656
+ universe(universe, options = {}) {
657
+ if (this.#closing || this.#closed) throw new SacnLifecycleError("Source is closed.");
658
+ const address = normalizeAddress({
659
+ universe,
660
+ priority: options.priority ?? 100
661
+ });
662
+ const key = `${address.universe}:${address.priority}`;
663
+ const existing = this.#universes.get(key);
664
+ if (existing) return existing;
665
+ const handle = new Universe(this, address.universe, address.priority, {
666
+ ...options.cid === void 0 ? {} : { cid: options.cid },
667
+ ...options.idleFps === void 0 ? {} : { idleFps: options.idleFps },
668
+ ...options.sourceName === void 0 ? {} : { sourceName: options.sourceName }
669
+ });
670
+ this.#universes.set(key, handle);
671
+ return handle;
672
+ }
673
+ start() {
674
+ return this.#enqueue(async () => {
675
+ await this.#startLocked();
676
+ });
677
+ }
678
+ stop() {
679
+ return this.#enqueue(async () => {
680
+ if (this.#closed) throw new SacnLifecycleError("Source is closed.");
681
+ if (!this.#running) return;
682
+ await this.#engine.stop();
683
+ this.#running = false;
684
+ await this.#runHook(this.#onStop);
685
+ this.#emit({ type: "stopped" });
686
+ });
687
+ }
688
+ /** @internal Used by Universe handles. */
689
+ writeFrame(options, values, durationMs, signal) {
690
+ return this.#enqueue(async () => {
691
+ await this.#ensureStartedLocked();
692
+ const frame = toValidatedFrame(values);
693
+ assertFade(durationMs);
694
+ const checkpoint = await this.#engine.beginMutation(options);
695
+ try {
696
+ const snapshot = this.#engine.writeFrame(options, frame, durationMs);
697
+ await this.#persist("save output", () => this.#store.save(toRecord(snapshot)));
698
+ this.#engine.commitMutation(checkpoint);
699
+ this.#emit({
700
+ type: "output-updated",
701
+ output: snapshot
702
+ });
703
+ return snapshot;
704
+ } catch (error) {
705
+ this.#engine.rollbackMutation(checkpoint);
706
+ throw error;
707
+ }
708
+ }, signal);
709
+ }
710
+ /** @internal Used by Universe handles. */
711
+ writeChannels(options, resolveChannels, signal) {
712
+ return this.#enqueue(async () => {
713
+ await this.#ensureStartedLocked();
714
+ const channels = resolveChannels();
715
+ const checkpoint = await this.#engine.beginMutation(options);
716
+ try {
717
+ const snapshot = this.#engine.writeChannels(options, channels);
718
+ await this.#persist("save output", () => this.#store.save(toRecord(snapshot)));
719
+ this.#engine.commitMutation(checkpoint);
720
+ this.#emit({
721
+ type: "output-updated",
722
+ output: snapshot
723
+ });
724
+ return snapshot;
725
+ } catch (error) {
726
+ this.#engine.rollbackMutation(checkpoint);
727
+ throw error;
728
+ }
729
+ }, signal);
730
+ }
731
+ /** @internal Used by Universe handles. */
732
+ getUniverse(universe, priority) {
733
+ return this.#enqueue(() => Promise.resolve(this.#engine.getOutput(normalizeAddress({
734
+ universe,
735
+ priority
736
+ }))));
737
+ }
738
+ listUniverses() {
739
+ return this.#enqueue(() => Promise.resolve(this.#engine.listOutputs()));
740
+ }
741
+ /** @internal Used by Universe handles. */
742
+ clearUniverse(universe, priority, signal) {
743
+ return this.#enqueue(async () => {
744
+ await this.#ensureStartedLocked();
745
+ const address = normalizeAddress({
746
+ universe,
747
+ priority
748
+ });
749
+ const checkpoint = await this.#engine.beginMutation(address);
750
+ const previous = this.#engine.getOutput(address);
751
+ if (!previous) {
752
+ this.#engine.commitMutation(checkpoint);
753
+ return false;
754
+ }
755
+ let persistenceRemoved = false;
756
+ try {
757
+ await this.#persist("remove output", () => this.#store.remove(address));
758
+ persistenceRemoved = true;
759
+ const removed = await this.#engine.clearOutput(address);
760
+ this.#engine.commitMutation(checkpoint);
761
+ this.#emit({
762
+ type: "output-cleared",
763
+ address: Object.freeze({ ...address })
764
+ });
765
+ return removed;
766
+ } catch (error) {
767
+ this.#engine.rollbackMutation(checkpoint);
768
+ if (persistenceRemoved) try {
769
+ await this.#persist("restore output after failed clear", () => this.#store.save(toRecord(previous)));
770
+ } catch (rollbackError) {
771
+ throw new AggregateError([error, rollbackError], "Output clear and persistence rollback failed.");
772
+ }
773
+ throw error;
774
+ }
775
+ }, signal);
776
+ }
777
+ getTelemetry() {
778
+ return this.#engine.getTelemetry();
779
+ }
780
+ subscribe(listener) {
781
+ if (this.#closing || this.#closed) throw new SacnLifecycleError("Source is closed.");
782
+ this.#listeners.add(listener);
783
+ return () => this.#listeners.delete(listener);
784
+ }
785
+ close() {
786
+ if (this.#closePromise) return this.#closePromise;
787
+ this.#closing = true;
788
+ const closing = this.#queue.then(async () => {
789
+ const errors = [];
790
+ try {
791
+ try {
792
+ await this.#engine.close();
793
+ } catch (error) {
794
+ errors.push(error);
795
+ }
796
+ if (this.#ownsStore) try {
797
+ await this.#persist("close output store", async () => {
798
+ await this.#store.close?.();
799
+ });
800
+ } catch (error) {
801
+ errors.push(error);
802
+ }
803
+ try {
804
+ await this.#runHook(this.#onClose);
805
+ } catch (error) {
806
+ errors.push(error);
807
+ }
808
+ if (errors.length === 1) throw errors[0];
809
+ if (errors.length > 1) throw new AggregateError(errors, "Source shutdown failed.");
810
+ } finally {
811
+ this.#running = false;
812
+ this.#closed = true;
813
+ this.#closing = false;
814
+ this.#universes.clear();
815
+ this.#emit({ type: "closed" });
816
+ this.#listeners.clear();
817
+ }
818
+ });
819
+ this.#queue = closing.then(() => void 0, () => void 0);
820
+ this.#closePromise = closing;
821
+ return closing;
822
+ }
823
+ async #startLocked() {
824
+ if (this.#closed) throw new SacnLifecycleError("Source is closed.");
825
+ if (this.#running) return;
826
+ if (!this.#restored) {
827
+ const records = await this.#persist("load outputs", () => this.#store.list());
828
+ const checkpoints = [];
829
+ try {
830
+ for (const record of records) {
831
+ const checkpoint = await this.#engine.beginMutation(record);
832
+ checkpoints.push(checkpoint);
833
+ this.#engine.restore(record);
834
+ this.#engine.commitMutation(checkpoint);
835
+ }
836
+ this.#restored = true;
837
+ } catch (error) {
838
+ for (const checkpoint of checkpoints.reverse()) this.#engine.rollbackMutation(checkpoint);
839
+ throw error;
840
+ }
841
+ }
842
+ this.#engine.start();
843
+ this.#running = true;
844
+ await this.#runHook(this.#onStart);
845
+ this.#emit({ type: "started" });
846
+ }
847
+ async #ensureStartedLocked() {
848
+ if (!this.#running) await this.#startLocked();
849
+ }
850
+ async #runHook(hook) {
851
+ if (!hook) return;
852
+ await hook();
853
+ }
854
+ #enqueue(operation, signal) {
855
+ if (this.#closing || this.#closed) return Promise.reject(new SacnLifecycleError("Source is closed."));
856
+ const result = this.#queue.then(() => {
857
+ signal?.throwIfAborted();
858
+ return operation();
859
+ });
860
+ this.#queue = result.then(() => void 0, () => void 0);
861
+ return result;
862
+ }
863
+ async #persist(action, operation) {
864
+ try {
865
+ return await operation();
866
+ } catch (error) {
867
+ if (error instanceof PersistenceError) throw error;
868
+ throw new PersistenceError(`Failed to ${action}.`, error);
869
+ }
870
+ }
871
+ #emit(event) {
872
+ const frozenEvent = Object.freeze(event);
873
+ for (const listener of this.#listeners) try {
874
+ listener(frozenEvent);
875
+ } catch (error) {
876
+ this.#logger.warn?.("Source event listener failed.", { error });
877
+ }
878
+ }
879
+ };
880
+ const createSacnSource = (options) => new SacnSource(options);
881
+ //#endregion
882
+ export { MemoryOutputStore as a, SystemClock as i, Universe as n, createSacnSource as r, SacnSource as t };
883
+
884
+ //# sourceMappingURL=source-DB1oq2GT.js.map