@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.
- package/CHANGELOG.md +44 -0
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +256 -0
- package/README.md +159 -0
- package/SECURITY.md +67 -0
- package/dist/contracts-C4kpAdZq.d.ts +265 -0
- package/dist/contracts-C4kpAdZq.d.ts.map +1 -0
- package/dist/http.d.ts +643 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +670 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +114 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +334 -0
- package/dist/node.js.map +1 -0
- package/dist/redis.d.ts +46 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +174 -0
- package/dist/redis.js.map +1 -0
- package/dist/source-DB1oq2GT.js +884 -0
- package/dist/source-DB1oq2GT.js.map +1 -0
- package/dist/source-D_XNWXS9.d.ts +76 -0
- package/dist/source-D_XNWXS9.d.ts.map +1 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +98 -0
- package/dist/testing.js.map +1 -0
- package/dist/validation-BdsVeyAE.js +151 -0
- package/dist/validation-BdsVeyAE.js.map +1 -0
- package/examples/embedded-elysia-http.ts +30 -0
- package/examples/full-frame-fade.ts +25 -0
- package/examples/graceful-shutdown.ts +19 -0
- package/examples/quickstart.ts +30 -0
- package/examples/redis.ts +30 -0
- package/examples/sparse-channels.ts +23 -0
- package/examples/viewer-subscription.ts +38 -0
- package/examples/viewer-tui.ts +201 -0
- package/package.json +108 -0
- package/src/contracts.ts +302 -0
- package/src/http.ts +1101 -0
- package/src/index.ts +61 -0
- package/src/memory-store.ts +45 -0
- package/src/node.ts +578 -0
- package/src/output-engine.ts +778 -0
- package/src/redis.ts +258 -0
- package/src/source.ts +502 -0
- package/src/testing.ts +139 -0
- package/src/validation.ts +328 -0
- 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
|