@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
package/src/source.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChannelValues,
|
|
3
|
+
type ClearUniverseOptions,
|
|
4
|
+
type EngineTelemetry,
|
|
5
|
+
type FadeChannelsOptions,
|
|
6
|
+
type Logger,
|
|
7
|
+
type OutputOptions,
|
|
8
|
+
type OutputRecord,
|
|
9
|
+
type OutputSnapshot,
|
|
10
|
+
type OutputStore,
|
|
11
|
+
type SacnLifecycleHook,
|
|
12
|
+
type SacnSourceContract,
|
|
13
|
+
type SacnSourceEvent,
|
|
14
|
+
type SacnSourceOptions,
|
|
15
|
+
type TransitionWrite,
|
|
16
|
+
type UniverseContract,
|
|
17
|
+
type UniverseOptions,
|
|
18
|
+
type WriteFrameOptions,
|
|
19
|
+
DEFAULT_PRIORITY,
|
|
20
|
+
} from "./contracts.js";
|
|
21
|
+
import { MemoryOutputStore } from "./memory-store.js";
|
|
22
|
+
import { OutputEngine, SystemClock } from "./output-engine.js";
|
|
23
|
+
import {
|
|
24
|
+
SacnLifecycleError,
|
|
25
|
+
PersistenceError,
|
|
26
|
+
assertFade,
|
|
27
|
+
channelValuesToWrites,
|
|
28
|
+
normalizeAddress,
|
|
29
|
+
toValidatedFrame,
|
|
30
|
+
validateTransitionWrites,
|
|
31
|
+
} from "./validation.js";
|
|
32
|
+
|
|
33
|
+
const noopLogger: Logger = Object.freeze({});
|
|
34
|
+
|
|
35
|
+
const defaultCreateId = (): string => {
|
|
36
|
+
if (typeof globalThis.crypto?.randomUUID !== "function") {
|
|
37
|
+
throw new SacnLifecycleError(
|
|
38
|
+
"No UUID generator is available; inject createId in source options.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return globalThis.crypto.randomUUID();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const toRecord = (snapshot: OutputSnapshot): OutputRecord =>
|
|
45
|
+
Object.freeze({
|
|
46
|
+
universe: snapshot.universe,
|
|
47
|
+
priority: snapshot.priority,
|
|
48
|
+
cid: snapshot.cid,
|
|
49
|
+
sourceName: snapshot.sourceName,
|
|
50
|
+
idleFps: snapshot.idleFps,
|
|
51
|
+
target: Object.freeze([...snapshot.target]),
|
|
52
|
+
updatedAt: snapshot.updatedAt,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const outputOptionsFrom = (
|
|
56
|
+
universe: number,
|
|
57
|
+
priority: number,
|
|
58
|
+
defaults: UniverseOptions,
|
|
59
|
+
): OutputOptions => ({
|
|
60
|
+
universe,
|
|
61
|
+
priority,
|
|
62
|
+
...(defaults.cid === undefined ? {} : { cid: defaults.cid }),
|
|
63
|
+
...(defaults.idleFps === undefined ? {} : { idleFps: defaults.idleFps }),
|
|
64
|
+
...(defaults.sourceName === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { sourceName: defaults.sourceName }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Thin handle for a single `(universe, priority)` output address.
|
|
71
|
+
*/
|
|
72
|
+
export class Universe implements UniverseContract {
|
|
73
|
+
readonly #source: SacnSource;
|
|
74
|
+
readonly #defaults: UniverseOptions;
|
|
75
|
+
readonly universe: number;
|
|
76
|
+
readonly priority: number;
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
source: SacnSource,
|
|
80
|
+
universe: number,
|
|
81
|
+
priority: number,
|
|
82
|
+
defaults: UniverseOptions,
|
|
83
|
+
) {
|
|
84
|
+
this.#source = source;
|
|
85
|
+
this.universe = universe;
|
|
86
|
+
this.priority = priority;
|
|
87
|
+
this.#defaults = defaults;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setChannels(
|
|
91
|
+
values: ChannelValues,
|
|
92
|
+
options: { readonly signal?: AbortSignal } = {},
|
|
93
|
+
): Promise<OutputSnapshot> {
|
|
94
|
+
return this.#source.writeChannels(
|
|
95
|
+
outputOptionsFrom(this.universe, this.priority, this.#defaults),
|
|
96
|
+
() => channelValuesToWrites(values),
|
|
97
|
+
options.signal,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fadeChannels(
|
|
102
|
+
values: ChannelValues,
|
|
103
|
+
options: FadeChannelsOptions,
|
|
104
|
+
): Promise<OutputSnapshot> {
|
|
105
|
+
return this.#source.writeChannels(
|
|
106
|
+
outputOptionsFrom(this.universe, this.priority, this.#defaults),
|
|
107
|
+
() => {
|
|
108
|
+
assertFade(options.durationMs);
|
|
109
|
+
return channelValuesToWrites(values, options.durationMs);
|
|
110
|
+
},
|
|
111
|
+
options.signal,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
transition(
|
|
116
|
+
writes: readonly TransitionWrite[],
|
|
117
|
+
options: { readonly signal?: AbortSignal } = {},
|
|
118
|
+
): Promise<OutputSnapshot> {
|
|
119
|
+
return this.#source.writeChannels(
|
|
120
|
+
outputOptionsFrom(this.universe, this.priority, this.#defaults),
|
|
121
|
+
() => {
|
|
122
|
+
validateTransitionWrites(writes);
|
|
123
|
+
return writes.map((write) => ({
|
|
124
|
+
channel: write.channel,
|
|
125
|
+
value: write.value,
|
|
126
|
+
durationMs: write.durationMs,
|
|
127
|
+
}));
|
|
128
|
+
},
|
|
129
|
+
options.signal,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
write(
|
|
134
|
+
values: readonly number[] | Uint8Array,
|
|
135
|
+
options: WriteFrameOptions = {},
|
|
136
|
+
): Promise<OutputSnapshot> {
|
|
137
|
+
return this.#source.writeFrame(
|
|
138
|
+
outputOptionsFrom(this.universe, this.priority, this.#defaults),
|
|
139
|
+
values,
|
|
140
|
+
options.durationMs ?? 0,
|
|
141
|
+
options.signal,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get(): Promise<OutputSnapshot | null> {
|
|
146
|
+
return this.#source.getUniverse(this.universe, this.priority);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
clear(options: ClearUniverseOptions = {}): Promise<boolean> {
|
|
150
|
+
return this.#source.clearUniverse(
|
|
151
|
+
this.universe,
|
|
152
|
+
this.priority,
|
|
153
|
+
options.signal,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Runtime-neutral sACN output source over scheduling and storage.
|
|
160
|
+
* Mutating requests are serialized so their returned and persisted snapshots
|
|
161
|
+
* represent the same operation order.
|
|
162
|
+
*/
|
|
163
|
+
export class SacnSource implements SacnSourceContract {
|
|
164
|
+
readonly #store: OutputStore;
|
|
165
|
+
readonly #engine: OutputEngine;
|
|
166
|
+
readonly #ownsStore: boolean;
|
|
167
|
+
readonly #logger: Logger;
|
|
168
|
+
readonly #onStart?: SacnLifecycleHook;
|
|
169
|
+
readonly #onStop?: SacnLifecycleHook;
|
|
170
|
+
readonly #onClose?: SacnLifecycleHook;
|
|
171
|
+
readonly #listeners = new Set<(event: SacnSourceEvent) => void>();
|
|
172
|
+
readonly #universes = new Map<string, Universe>();
|
|
173
|
+
#queue: Promise<void> = Promise.resolve();
|
|
174
|
+
#running = false;
|
|
175
|
+
#restored = false;
|
|
176
|
+
#closing = false;
|
|
177
|
+
#closed = false;
|
|
178
|
+
#closePromise: Promise<void> | null = null;
|
|
179
|
+
|
|
180
|
+
constructor(options: SacnSourceOptions) {
|
|
181
|
+
this.#store = options.store ?? new MemoryOutputStore();
|
|
182
|
+
this.#ownsStore = options.ownsStore ?? options.store === undefined;
|
|
183
|
+
this.#logger = options.logger ?? noopLogger;
|
|
184
|
+
if (options.onStart !== undefined) this.#onStart = options.onStart;
|
|
185
|
+
if (options.onStop !== undefined) this.#onStop = options.onStop;
|
|
186
|
+
if (options.onClose !== undefined) this.#onClose = options.onClose;
|
|
187
|
+
this.#engine = new OutputEngine({
|
|
188
|
+
transport: options.transport,
|
|
189
|
+
clock: options.clock ?? new SystemClock(),
|
|
190
|
+
logger: this.#logger,
|
|
191
|
+
createId: options.createId ?? defaultCreateId,
|
|
192
|
+
...(options.activeFps === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { activeFps: options.activeFps }),
|
|
195
|
+
...(options.idleFps === undefined ? {} : { idleFps: options.idleFps }),
|
|
196
|
+
...(options.sendTimeoutMs === undefined
|
|
197
|
+
? {}
|
|
198
|
+
: { sendTimeoutMs: options.sendTimeoutMs }),
|
|
199
|
+
...(options.shutdownTimeoutMs === undefined
|
|
200
|
+
? {}
|
|
201
|
+
: { shutdownTimeoutMs: options.shutdownTimeoutMs }),
|
|
202
|
+
...(options.maxOutputs === undefined
|
|
203
|
+
? {}
|
|
204
|
+
: { maxOutputs: options.maxOutputs }),
|
|
205
|
+
closeTransport: options.ownsTransport ?? false,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
universe(universe: number, options: UniverseOptions = {}): Universe {
|
|
210
|
+
if (this.#closing || this.#closed) {
|
|
211
|
+
throw new SacnLifecycleError("Source is closed.");
|
|
212
|
+
}
|
|
213
|
+
const address = normalizeAddress({
|
|
214
|
+
universe,
|
|
215
|
+
priority: options.priority ?? DEFAULT_PRIORITY,
|
|
216
|
+
});
|
|
217
|
+
const key = `${address.universe}:${address.priority}`;
|
|
218
|
+
const existing = this.#universes.get(key);
|
|
219
|
+
if (existing) return existing;
|
|
220
|
+
const handle = new Universe(this, address.universe, address.priority, {
|
|
221
|
+
...(options.cid === undefined ? {} : { cid: options.cid }),
|
|
222
|
+
...(options.idleFps === undefined ? {} : { idleFps: options.idleFps }),
|
|
223
|
+
...(options.sourceName === undefined
|
|
224
|
+
? {}
|
|
225
|
+
: { sourceName: options.sourceName }),
|
|
226
|
+
});
|
|
227
|
+
this.#universes.set(key, handle);
|
|
228
|
+
return handle;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
start(): Promise<void> {
|
|
232
|
+
return this.#enqueue(async () => {
|
|
233
|
+
await this.#startLocked();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
stop(): Promise<void> {
|
|
238
|
+
return this.#enqueue(async () => {
|
|
239
|
+
if (this.#closed) {
|
|
240
|
+
throw new SacnLifecycleError("Source is closed.");
|
|
241
|
+
}
|
|
242
|
+
if (!this.#running) return;
|
|
243
|
+
await this.#engine.stop();
|
|
244
|
+
this.#running = false;
|
|
245
|
+
await this.#runHook(this.#onStop);
|
|
246
|
+
this.#emit({ type: "stopped" });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @internal Used by Universe handles. */
|
|
251
|
+
writeFrame(
|
|
252
|
+
options: OutputOptions,
|
|
253
|
+
values: readonly number[] | Uint8Array,
|
|
254
|
+
durationMs: number,
|
|
255
|
+
signal?: AbortSignal,
|
|
256
|
+
): Promise<OutputSnapshot> {
|
|
257
|
+
return this.#enqueue(async () => {
|
|
258
|
+
await this.#ensureStartedLocked();
|
|
259
|
+
const frame = toValidatedFrame(values);
|
|
260
|
+
assertFade(durationMs);
|
|
261
|
+
const checkpoint = await this.#engine.beginMutation(options);
|
|
262
|
+
try {
|
|
263
|
+
const snapshot = this.#engine.writeFrame(options, frame, durationMs);
|
|
264
|
+
await this.#persist("save output", () =>
|
|
265
|
+
this.#store.save(toRecord(snapshot)),
|
|
266
|
+
);
|
|
267
|
+
this.#engine.commitMutation(checkpoint);
|
|
268
|
+
this.#emit({ type: "output-updated", output: snapshot });
|
|
269
|
+
return snapshot;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
this.#engine.rollbackMutation(checkpoint);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}, signal);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @internal Used by Universe handles. */
|
|
278
|
+
writeChannels(
|
|
279
|
+
options: OutputOptions,
|
|
280
|
+
resolveChannels: () => ReturnType<typeof channelValuesToWrites>,
|
|
281
|
+
signal?: AbortSignal,
|
|
282
|
+
): Promise<OutputSnapshot> {
|
|
283
|
+
return this.#enqueue(async () => {
|
|
284
|
+
await this.#ensureStartedLocked();
|
|
285
|
+
const channels = resolveChannels();
|
|
286
|
+
const checkpoint = await this.#engine.beginMutation(options);
|
|
287
|
+
try {
|
|
288
|
+
const snapshot = this.#engine.writeChannels(options, channels);
|
|
289
|
+
await this.#persist("save output", () =>
|
|
290
|
+
this.#store.save(toRecord(snapshot)),
|
|
291
|
+
);
|
|
292
|
+
this.#engine.commitMutation(checkpoint);
|
|
293
|
+
this.#emit({ type: "output-updated", output: snapshot });
|
|
294
|
+
return snapshot;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.#engine.rollbackMutation(checkpoint);
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}, signal);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @internal Used by Universe handles. */
|
|
303
|
+
getUniverse(
|
|
304
|
+
universe: number,
|
|
305
|
+
priority: number,
|
|
306
|
+
): Promise<OutputSnapshot | null> {
|
|
307
|
+
return this.#enqueue(() =>
|
|
308
|
+
Promise.resolve(
|
|
309
|
+
this.#engine.getOutput(normalizeAddress({ universe, priority })),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
listUniverses(): Promise<readonly OutputSnapshot[]> {
|
|
315
|
+
return this.#enqueue(() => Promise.resolve(this.#engine.listOutputs()));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @internal Used by Universe handles. */
|
|
319
|
+
clearUniverse(
|
|
320
|
+
universe: number,
|
|
321
|
+
priority: number,
|
|
322
|
+
signal?: AbortSignal,
|
|
323
|
+
): Promise<boolean> {
|
|
324
|
+
return this.#enqueue(async () => {
|
|
325
|
+
await this.#ensureStartedLocked();
|
|
326
|
+
const address = normalizeAddress({ universe, priority });
|
|
327
|
+
const checkpoint = await this.#engine.beginMutation(address);
|
|
328
|
+
const previous = this.#engine.getOutput(address);
|
|
329
|
+
if (!previous) {
|
|
330
|
+
this.#engine.commitMutation(checkpoint);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
let persistenceRemoved = false;
|
|
334
|
+
try {
|
|
335
|
+
await this.#persist("remove output", () => this.#store.remove(address));
|
|
336
|
+
persistenceRemoved = true;
|
|
337
|
+
const removed = await this.#engine.clearOutput(address);
|
|
338
|
+
this.#engine.commitMutation(checkpoint);
|
|
339
|
+
this.#emit({
|
|
340
|
+
type: "output-cleared",
|
|
341
|
+
address: Object.freeze({ ...address }),
|
|
342
|
+
});
|
|
343
|
+
return removed;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.#engine.rollbackMutation(checkpoint);
|
|
346
|
+
if (persistenceRemoved) {
|
|
347
|
+
try {
|
|
348
|
+
await this.#persist("restore output after failed clear", () =>
|
|
349
|
+
this.#store.save(toRecord(previous)),
|
|
350
|
+
);
|
|
351
|
+
} catch (rollbackError) {
|
|
352
|
+
throw new AggregateError(
|
|
353
|
+
[error, rollbackError],
|
|
354
|
+
"Output clear and persistence rollback failed.",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}, signal);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
getTelemetry(): EngineTelemetry {
|
|
364
|
+
return this.#engine.getTelemetry();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
subscribe(listener: (event: SacnSourceEvent) => void): () => void {
|
|
368
|
+
if (this.#closing || this.#closed) {
|
|
369
|
+
throw new SacnLifecycleError("Source is closed.");
|
|
370
|
+
}
|
|
371
|
+
this.#listeners.add(listener);
|
|
372
|
+
return () => this.#listeners.delete(listener);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
close(): Promise<void> {
|
|
376
|
+
if (this.#closePromise) return this.#closePromise;
|
|
377
|
+
this.#closing = true;
|
|
378
|
+
const closing = this.#queue.then(async () => {
|
|
379
|
+
const errors: unknown[] = [];
|
|
380
|
+
try {
|
|
381
|
+
try {
|
|
382
|
+
await this.#engine.close();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
errors.push(error);
|
|
385
|
+
}
|
|
386
|
+
if (this.#ownsStore) {
|
|
387
|
+
try {
|
|
388
|
+
await this.#persist("close output store", async () => {
|
|
389
|
+
await this.#store.close?.();
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
errors.push(error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await this.#runHook(this.#onClose);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
errors.push(error);
|
|
399
|
+
}
|
|
400
|
+
if (errors.length === 1) {
|
|
401
|
+
throw errors[0];
|
|
402
|
+
}
|
|
403
|
+
if (errors.length > 1) {
|
|
404
|
+
throw new AggregateError(errors, "Source shutdown failed.");
|
|
405
|
+
}
|
|
406
|
+
} finally {
|
|
407
|
+
this.#running = false;
|
|
408
|
+
this.#closed = true;
|
|
409
|
+
this.#closing = false;
|
|
410
|
+
this.#universes.clear();
|
|
411
|
+
this.#emit({ type: "closed" });
|
|
412
|
+
this.#listeners.clear();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
this.#queue = closing.then(
|
|
416
|
+
() => undefined,
|
|
417
|
+
() => undefined,
|
|
418
|
+
);
|
|
419
|
+
this.#closePromise = closing;
|
|
420
|
+
return closing;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async #startLocked(): Promise<void> {
|
|
424
|
+
if (this.#closed) {
|
|
425
|
+
throw new SacnLifecycleError("Source is closed.");
|
|
426
|
+
}
|
|
427
|
+
if (this.#running) return;
|
|
428
|
+
if (!this.#restored) {
|
|
429
|
+
const records = await this.#persist("load outputs", () =>
|
|
430
|
+
this.#store.list(),
|
|
431
|
+
);
|
|
432
|
+
const checkpoints = [];
|
|
433
|
+
try {
|
|
434
|
+
for (const record of records) {
|
|
435
|
+
const checkpoint = await this.#engine.beginMutation(record);
|
|
436
|
+
checkpoints.push(checkpoint);
|
|
437
|
+
this.#engine.restore(record);
|
|
438
|
+
this.#engine.commitMutation(checkpoint);
|
|
439
|
+
}
|
|
440
|
+
this.#restored = true;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
for (const checkpoint of checkpoints.reverse()) {
|
|
443
|
+
this.#engine.rollbackMutation(checkpoint);
|
|
444
|
+
}
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
this.#engine.start();
|
|
449
|
+
this.#running = true;
|
|
450
|
+
await this.#runHook(this.#onStart);
|
|
451
|
+
this.#emit({ type: "started" });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async #ensureStartedLocked(): Promise<void> {
|
|
455
|
+
if (!this.#running) {
|
|
456
|
+
await this.#startLocked();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async #runHook(hook: SacnLifecycleHook | undefined): Promise<void> {
|
|
461
|
+
if (!hook) return;
|
|
462
|
+
await hook();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#enqueue<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
466
|
+
if (this.#closing || this.#closed) {
|
|
467
|
+
return Promise.reject(new SacnLifecycleError("Source is closed."));
|
|
468
|
+
}
|
|
469
|
+
const result = this.#queue.then(() => {
|
|
470
|
+
signal?.throwIfAborted();
|
|
471
|
+
return operation();
|
|
472
|
+
});
|
|
473
|
+
this.#queue = result.then(
|
|
474
|
+
() => undefined,
|
|
475
|
+
() => undefined,
|
|
476
|
+
);
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async #persist<T>(action: string, operation: () => Promise<T>): Promise<T> {
|
|
481
|
+
try {
|
|
482
|
+
return await operation();
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (error instanceof PersistenceError) throw error;
|
|
485
|
+
throw new PersistenceError(`Failed to ${action}.`, error);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#emit(event: SacnSourceEvent): void {
|
|
490
|
+
const frozenEvent = Object.freeze(event);
|
|
491
|
+
for (const listener of this.#listeners) {
|
|
492
|
+
try {
|
|
493
|
+
listener(frozenEvent);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
this.#logger.warn?.("Source event listener failed.", { error });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export const createSacnSource = (options: SacnSourceOptions): SacnSource =>
|
|
502
|
+
new SacnSource(options);
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Clock,
|
|
3
|
+
OutputAddress,
|
|
4
|
+
OutputPacket,
|
|
5
|
+
OutputTransport,
|
|
6
|
+
Receiver,
|
|
7
|
+
ReceiverPacket,
|
|
8
|
+
ReceiverPacketListener,
|
|
9
|
+
} from "./contracts.js";
|
|
10
|
+
|
|
11
|
+
interface Sleeper {
|
|
12
|
+
readonly dueAt: number;
|
|
13
|
+
readonly resolve: () => void;
|
|
14
|
+
readonly reject: (reason?: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class FakeClock implements Clock {
|
|
18
|
+
#now: number;
|
|
19
|
+
readonly #sleepers = new Set<Sleeper>();
|
|
20
|
+
|
|
21
|
+
constructor(now = 0) {
|
|
22
|
+
this.#now = now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
now(): number {
|
|
26
|
+
return this.#now;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sleep(delayMs: number, signal: AbortSignal): Promise<void> {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
if (signal.aborted) {
|
|
32
|
+
reject(signal.reason);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const sleeper: Sleeper = {
|
|
36
|
+
dueAt: this.#now + Math.max(0, delayMs),
|
|
37
|
+
resolve,
|
|
38
|
+
reject,
|
|
39
|
+
};
|
|
40
|
+
this.#sleepers.add(sleeper);
|
|
41
|
+
signal.addEventListener(
|
|
42
|
+
"abort",
|
|
43
|
+
() => {
|
|
44
|
+
if (this.#sleepers.delete(sleeper)) reject(signal.reason);
|
|
45
|
+
},
|
|
46
|
+
{ once: true },
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
advanceBy(milliseconds: number): void {
|
|
52
|
+
this.advanceTo(this.#now + milliseconds);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
advanceTo(timestamp: number): void {
|
|
56
|
+
if (!Number.isFinite(timestamp) || timestamp < this.#now) {
|
|
57
|
+
throw new RangeError("Fake clock cannot move backwards.");
|
|
58
|
+
}
|
|
59
|
+
this.#now = timestamp;
|
|
60
|
+
for (const sleeper of [...this.#sleepers]) {
|
|
61
|
+
if (sleeper.dueAt <= timestamp) {
|
|
62
|
+
this.#sleepers.delete(sleeper);
|
|
63
|
+
sleeper.resolve();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class RecordingTransport implements OutputTransport {
|
|
70
|
+
readonly packets: OutputPacket[] = [];
|
|
71
|
+
readonly closedOutputs: Required<OutputAddress>[] = [];
|
|
72
|
+
closeCalls = 0;
|
|
73
|
+
sendImplementation?: (
|
|
74
|
+
packet: OutputPacket,
|
|
75
|
+
signal: AbortSignal,
|
|
76
|
+
) => Promise<void>;
|
|
77
|
+
|
|
78
|
+
async send(packet: OutputPacket, signal: AbortSignal): Promise<void> {
|
|
79
|
+
const copy: OutputPacket = Object.freeze({
|
|
80
|
+
...packet,
|
|
81
|
+
data: packet.data.slice(),
|
|
82
|
+
});
|
|
83
|
+
this.packets.push(copy);
|
|
84
|
+
await this.sendImplementation?.(copy, signal);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async closeOutput(address: Required<OutputAddress>): Promise<void> {
|
|
88
|
+
this.closedOutputs.push(Object.freeze({ ...address }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async close(): Promise<void> {
|
|
92
|
+
this.closeCalls += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class FakeReceiver implements Receiver {
|
|
97
|
+
readonly universes = new Set<number>();
|
|
98
|
+
readonly #listeners = new Set<ReceiverPacketListener>();
|
|
99
|
+
closed = false;
|
|
100
|
+
|
|
101
|
+
addUniverse(universe: number): void {
|
|
102
|
+
this.universes.add(universe);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
removeUniverse(universe: number): void {
|
|
106
|
+
this.universes.delete(universe);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
subscribe(listener: ReceiverPacketListener): () => void {
|
|
110
|
+
if (this.closed) throw new Error("Fake receiver is closed.");
|
|
111
|
+
this.#listeners.add(listener);
|
|
112
|
+
return () => this.#listeners.delete(listener);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
emit(packet: ReceiverPacket): void {
|
|
116
|
+
for (const listener of this.#listeners) listener(packet);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async close(): Promise<void> {
|
|
120
|
+
this.closed = true;
|
|
121
|
+
this.#listeners.clear();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface Deferred<T> {
|
|
126
|
+
readonly promise: Promise<T>;
|
|
127
|
+
readonly resolve: (value: T | PromiseLike<T>) => void;
|
|
128
|
+
readonly reject: (reason?: unknown) => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const createDeferred = <T = void>(): Deferred<T> => {
|
|
132
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
133
|
+
let reject!: (reason?: unknown) => void;
|
|
134
|
+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
135
|
+
resolve = resolvePromise;
|
|
136
|
+
reject = rejectPromise;
|
|
137
|
+
});
|
|
138
|
+
return { promise, resolve, reject };
|
|
139
|
+
};
|