@helioslx/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CONTRIBUTING.md +83 -0
  3. package/LICENSE +256 -0
  4. package/README.md +159 -0
  5. package/SECURITY.md +67 -0
  6. package/dist/contracts-C4kpAdZq.d.ts +265 -0
  7. package/dist/contracts-C4kpAdZq.d.ts.map +1 -0
  8. package/dist/http.d.ts +643 -0
  9. package/dist/http.d.ts.map +1 -0
  10. package/dist/http.js +670 -0
  11. package/dist/http.js.map +1 -0
  12. package/dist/index.d.ts +40 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +270 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/node.d.ts +114 -0
  17. package/dist/node.d.ts.map +1 -0
  18. package/dist/node.js +334 -0
  19. package/dist/node.js.map +1 -0
  20. package/dist/redis.d.ts +46 -0
  21. package/dist/redis.d.ts.map +1 -0
  22. package/dist/redis.js +174 -0
  23. package/dist/redis.js.map +1 -0
  24. package/dist/source-DB1oq2GT.js +884 -0
  25. package/dist/source-DB1oq2GT.js.map +1 -0
  26. package/dist/source-D_XNWXS9.d.ts +76 -0
  27. package/dist/source-D_XNWXS9.d.ts.map +1 -0
  28. package/dist/testing.d.ts +38 -0
  29. package/dist/testing.d.ts.map +1 -0
  30. package/dist/testing.js +98 -0
  31. package/dist/testing.js.map +1 -0
  32. package/dist/validation-BdsVeyAE.js +151 -0
  33. package/dist/validation-BdsVeyAE.js.map +1 -0
  34. package/examples/embedded-elysia-http.ts +30 -0
  35. package/examples/full-frame-fade.ts +25 -0
  36. package/examples/graceful-shutdown.ts +19 -0
  37. package/examples/quickstart.ts +30 -0
  38. package/examples/redis.ts +30 -0
  39. package/examples/sparse-channels.ts +23 -0
  40. package/examples/viewer-subscription.ts +38 -0
  41. package/examples/viewer-tui.ts +201 -0
  42. package/package.json +108 -0
  43. package/src/contracts.ts +302 -0
  44. package/src/http.ts +1101 -0
  45. package/src/index.ts +61 -0
  46. package/src/memory-store.ts +45 -0
  47. package/src/node.ts +578 -0
  48. package/src/output-engine.ts +778 -0
  49. package/src/redis.ts +258 -0
  50. package/src/source.ts +502 -0
  51. package/src/testing.ts +139 -0
  52. package/src/validation.ts +328 -0
  53. package/src/viewer.ts +368 -0
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ export {
2
+ DEFAULT_ACTIVE_FPS,
3
+ DEFAULT_IDLE_FPS,
4
+ DEFAULT_PRIORITY,
5
+ MAX_PRIORITY,
6
+ MAX_UNIVERSE,
7
+ MIN_PRIORITY,
8
+ MIN_UNIVERSE,
9
+ SLOT_COUNT,
10
+ } from "./contracts.js";
11
+ export type {
12
+ ChannelValues,
13
+ ChannelWrite,
14
+ ClearUniverseOptions,
15
+ Clock,
16
+ EngineTelemetry,
17
+ FadeChannelsOptions,
18
+ FrameMode,
19
+ Logger,
20
+ OutputAddress,
21
+ OutputOptions,
22
+ OutputPacket,
23
+ OutputRecord,
24
+ OutputSnapshot,
25
+ OutputStore,
26
+ OutputTransport,
27
+ SacnLifecycleHook,
28
+ SacnSourceContract,
29
+ SacnSourceEvent,
30
+ SacnSourceOptions,
31
+ TransitionWrite,
32
+ TransportTelemetry,
33
+ UniverseContract,
34
+ UniverseOptions,
35
+ WriteFrameOptions,
36
+ Receiver,
37
+ ReceiverPacket,
38
+ ReceiverPacketListener,
39
+ ViewerPacket,
40
+ ViewerPacketStream,
41
+ ViewerRecord,
42
+ ViewerServiceOptions,
43
+ ViewerServiceContract,
44
+ ViewerSourceMetadata,
45
+ ViewerStore,
46
+ ViewerTelemetry,
47
+ } from "./contracts.js";
48
+ export { SacnSource, Universe, createSacnSource } from "./source.js";
49
+ export { MemoryOutputStore } from "./memory-store.js";
50
+ export { MemoryViewerStore, ViewerService } from "./viewer.js";
51
+ export { SystemClock } from "./output-engine.js";
52
+ export {
53
+ SacnError,
54
+ SacnLifecycleError,
55
+ SacnValidationError,
56
+ DependencyUnavailableError,
57
+ PersistenceError,
58
+ TransportError,
59
+ TransportTimeoutError,
60
+ type ValidationCode,
61
+ } from "./validation.js";
@@ -0,0 +1,45 @@
1
+ import type {
2
+ OutputAddress,
3
+ OutputRecord,
4
+ OutputStore,
5
+ } from "./contracts.js";
6
+
7
+ const keyOf = ({ universe, priority }: Required<OutputAddress>): string =>
8
+ `${universe}:${priority}`;
9
+
10
+ const cloneRecord = (record: OutputRecord): OutputRecord =>
11
+ Object.freeze({
12
+ ...record,
13
+ target: Object.freeze([...record.target]),
14
+ });
15
+
16
+ export class MemoryOutputStore implements OutputStore {
17
+ readonly #records = new Map<string, OutputRecord>();
18
+
19
+ async get(address: Required<OutputAddress>): Promise<OutputRecord | null> {
20
+ const record = this.#records.get(keyOf(address));
21
+ return record ? cloneRecord(record) : null;
22
+ }
23
+
24
+ async list(): Promise<readonly OutputRecord[]> {
25
+ return Object.freeze(
26
+ [...this.#records.values()]
27
+ .sort(
28
+ (left, right) =>
29
+ left.universe - right.universe || left.priority - right.priority,
30
+ )
31
+ .map(cloneRecord),
32
+ );
33
+ }
34
+
35
+ async remove(address: Required<OutputAddress>): Promise<void> {
36
+ this.#records.delete(keyOf(address));
37
+ }
38
+
39
+ async save(record: OutputRecord): Promise<void> {
40
+ this.#records.set(
41
+ keyOf({ universe: record.universe, priority: record.priority }),
42
+ cloneRecord(record),
43
+ );
44
+ }
45
+ }
package/src/node.ts ADDED
@@ -0,0 +1,578 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { createSocket, type Socket } from "node:dgram";
3
+ import { Packet, Receiver as SacnReceiver } from "sacn";
4
+ import {
5
+ type Logger,
6
+ type OutputAddress,
7
+ type OutputPacket,
8
+ type OutputTransport,
9
+ type Receiver,
10
+ type ReceiverPacket,
11
+ type ReceiverPacketListener,
12
+ type SacnSourceOptions,
13
+ SLOT_COUNT,
14
+ } from "./contracts.js";
15
+ import { createSacnSource as createCoreSacnSource, type SacnSource } from "./source.js";
16
+ import {
17
+ assertCid,
18
+ assertPort,
19
+ assertSourceName,
20
+ normalizeAddress,
21
+ SacnValidationError,
22
+ TransportError,
23
+ toValidatedFrame,
24
+ } from "./validation.js";
25
+
26
+ const activeSources = new Set<SacnSource>();
27
+ let processHandlersInstalled = false;
28
+ let shuttingDown = false;
29
+
30
+ const unregisterSource = (source: SacnSource): void => {
31
+ activeSources.delete(source);
32
+ };
33
+
34
+ const closeRegisteredSources = async (): Promise<void> => {
35
+ const sources = [...activeSources];
36
+ activeSources.clear();
37
+ await Promise.allSettled(sources.map((source) => source.close()));
38
+ };
39
+
40
+ const onProcessShutdown = (signal: NodeJS.Signals | "beforeExit"): void => {
41
+ if (shuttingDown) return;
42
+ shuttingDown = true;
43
+ void closeRegisteredSources().finally(() => {
44
+ if (signal === "beforeExit") return;
45
+ // Allow default signal behavior after teardown when no other listeners remain.
46
+ if (process.listenerCount(signal) === 0) {
47
+ process.exit(signal === "SIGINT" ? 130 : 143);
48
+ }
49
+ });
50
+ };
51
+
52
+ const installProcessHandlers = (): void => {
53
+ if (processHandlersInstalled) return;
54
+ processHandlersInstalled = true;
55
+ process.once("SIGINT", () => onProcessShutdown("SIGINT"));
56
+ process.once("SIGTERM", () => onProcessShutdown("SIGTERM"));
57
+ process.once("beforeExit", () => onProcessShutdown("beforeExit"));
58
+ };
59
+
60
+ /** Test helper: close and clear the Node active-source registry. */
61
+ export const flushActiveSacnSourcesForTests = async (): Promise<void> => {
62
+ await closeRegisteredSources();
63
+ shuttingDown = false;
64
+ };
65
+
66
+ /** Test helper: whether process signal handlers are installed. */
67
+ export const areProcessHandlersInstalledForTests = (): boolean =>
68
+ processHandlersInstalled;
69
+
70
+ export interface NodeRuntimeTelemetry {
71
+ readonly cpuUsage: {
72
+ readonly systemMicroseconds: number;
73
+ readonly userMicroseconds: number;
74
+ };
75
+ readonly memoryUsage: {
76
+ readonly arrayBuffersBytes: number;
77
+ readonly externalBytes: number;
78
+ readonly heapTotalBytes: number;
79
+ readonly heapUsedBytes: number;
80
+ readonly rssBytes: number;
81
+ };
82
+ readonly pid: number;
83
+ readonly uptimeSeconds: number;
84
+ }
85
+
86
+ export const getNodeRuntimeTelemetry = (): NodeRuntimeTelemetry => {
87
+ const cpu = process.cpuUsage();
88
+ const memory = process.memoryUsage();
89
+ return Object.freeze({
90
+ cpuUsage: Object.freeze({
91
+ systemMicroseconds: cpu.system,
92
+ userMicroseconds: cpu.user,
93
+ }),
94
+ memoryUsage: Object.freeze({
95
+ arrayBuffersBytes: memory.arrayBuffers,
96
+ externalBytes: memory.external,
97
+ heapTotalBytes: memory.heapTotal,
98
+ heapUsedBytes: memory.heapUsed,
99
+ rssBytes: memory.rss,
100
+ }),
101
+ pid: process.pid,
102
+ uptimeSeconds: process.uptime(),
103
+ });
104
+ };
105
+
106
+ export interface NodeSender {
107
+ send(packet: {
108
+ cid: Buffer;
109
+ payload: Record<number, number>;
110
+ priority: number;
111
+ sequence: number;
112
+ sourceName: string;
113
+ useRawDmxValues: true;
114
+ }): Promise<void>;
115
+ close(): unknown;
116
+ on?(event: "error", listener: (error: Error) => void): unknown;
117
+ }
118
+
119
+ export interface NodeSenderOptions {
120
+ readonly universe: number;
121
+ readonly port: number;
122
+ readonly iface?: string;
123
+ readonly useUnicastDestination?: string;
124
+ }
125
+
126
+ export interface NodeSacnTransportOptions {
127
+ readonly iface?: string;
128
+ readonly port?: number;
129
+ readonly sourceName?: string;
130
+ /** Optional unicast destination for testing or networks that require it. */
131
+ readonly unicastDestination?: string;
132
+ readonly senderFactory?: (options: NodeSenderOptions) => NodeSender;
133
+ readonly logger?: Logger;
134
+ }
135
+
136
+ export interface CreateSacnSourceOptions
137
+ extends Omit<SacnSourceOptions, "ownsTransport" | "transport"> {
138
+ readonly name?: string;
139
+ readonly transportOptions?: NodeSacnTransportOptions;
140
+ /**
141
+ * Install process SIGINT/SIGTERM/beforeExit handlers that close all
142
+ * registered Node sources. Defaults to true.
143
+ */
144
+ readonly installProcessHandlers?: boolean;
145
+ }
146
+
147
+ export const createSacnSource = (
148
+ options: CreateSacnSourceOptions = {},
149
+ ): SacnSource => {
150
+ const {
151
+ name,
152
+ transportOptions,
153
+ installProcessHandlers: shouldInstallHandlers = true,
154
+ ...sourceOptions
155
+ } = options;
156
+ const transport = new NodeSacnTransport({
157
+ ...transportOptions,
158
+ ...(name === undefined
159
+ ? {}
160
+ : { sourceName: transportOptions?.sourceName ?? name }),
161
+ });
162
+ const source = createCoreSacnSource({
163
+ ...sourceOptions,
164
+ transport,
165
+ ownsTransport: true,
166
+ });
167
+ activeSources.add(source);
168
+ source.subscribe((event) => {
169
+ if (event.type === "closed") unregisterSource(source);
170
+ });
171
+ if (shouldInstallHandlers) installProcessHandlers();
172
+ return source;
173
+ };
174
+
175
+ const multicastGroup = (universe: number): string =>
176
+ `239.255.${(universe >> 8) & 0xff}.${universe & 0xff}`;
177
+
178
+ class DefaultNodeSender implements NodeSender {
179
+ readonly #socket: Socket;
180
+ readonly #ready: Promise<void>;
181
+ readonly #universe: number;
182
+ readonly #port: number;
183
+ readonly #destination: string;
184
+ #closed = false;
185
+
186
+ constructor(options: NodeSenderOptions) {
187
+ this.#universe = options.universe;
188
+ this.#port = options.port;
189
+ this.#destination =
190
+ options.useUnicastDestination ?? multicastGroup(options.universe);
191
+ this.#socket = createSocket({ type: "udp4", reuseAddr: true });
192
+ this.#ready = new Promise((resolve, reject) => {
193
+ const onError = (error: Error): void => {
194
+ this.#socket.off("listening", onListening);
195
+ reject(error);
196
+ };
197
+ const onListening = (): void => {
198
+ this.#socket.off("error", onError);
199
+ if (options.iface && !options.useUnicastDestination) {
200
+ this.#socket.setMulticastInterface(options.iface);
201
+ }
202
+ resolve();
203
+ };
204
+ this.#socket.once("error", onError);
205
+ this.#socket.once("listening", onListening);
206
+ this.#socket.bind(0);
207
+ });
208
+ }
209
+
210
+ on(event: "error", listener: (error: Error) => void): unknown {
211
+ this.#socket.on(event, listener);
212
+ return this;
213
+ }
214
+
215
+ async send(packet: {
216
+ cid: Buffer;
217
+ payload: Record<number, number>;
218
+ priority: number;
219
+ sequence: number;
220
+ sourceName: string;
221
+ useRawDmxValues: true;
222
+ }): Promise<void> {
223
+ await this.#ready;
224
+ if (this.#closed) throw new Error("sACN sender is closed.");
225
+ const wire = new Packet({
226
+ cid: packet.cid,
227
+ payload: packet.payload,
228
+ priority: packet.priority || 1,
229
+ sequence: packet.sequence,
230
+ sourceName: packet.sourceName,
231
+ universe: this.#universe,
232
+ useRawDmxValues: true,
233
+ }).buffer;
234
+ // sacn 4.x applies `priority || 100`; restore standards-valid priority 0.
235
+ wire[108] = packet.priority;
236
+ // Preserve the engine's sequence even if the encoder changes its policy.
237
+ wire[111] = packet.sequence;
238
+ await new Promise<void>((resolve, reject) => {
239
+ this.#socket.send(wire, this.#port, this.#destination, (error) =>
240
+ error ? reject(error) : resolve(),
241
+ );
242
+ });
243
+ }
244
+
245
+ close(): void {
246
+ if (this.#closed) return;
247
+ this.#closed = true;
248
+ this.#socket.close();
249
+ }
250
+ }
251
+
252
+ const uuidBuffer = (cid: string): Buffer =>
253
+ Buffer.from(cid.replaceAll("-", ""), "hex");
254
+
255
+ const truncateUtf8 = (value: string, maximumBytes: number): string => {
256
+ let result = value;
257
+ while (Buffer.byteLength(result, "utf8") > maximumBytes) {
258
+ result = result.slice(0, -1);
259
+ }
260
+ return result;
261
+ };
262
+
263
+ const sourceNameFor = (
264
+ base: string,
265
+ suffix: string | null,
266
+ universe: number,
267
+ priority: number,
268
+ ): string =>
269
+ truncateUtf8(
270
+ [base, suffix, `${universe}/${priority}`]
271
+ .filter(Boolean)
272
+ .join(" ")
273
+ .replace(/[^\x20-\x7e]/g, "?"),
274
+ 64,
275
+ );
276
+
277
+ interface SenderContext {
278
+ readonly sender: NodeSender;
279
+ readonly cid: string;
280
+ readonly sourceName: string;
281
+ readonly payload: Record<number, number>;
282
+ }
283
+
284
+ const createPayload = (): Record<number, number> => {
285
+ const payload: Record<number, number> = {};
286
+ for (let channel = 1; channel <= SLOT_COUNT; channel += 1) {
287
+ payload[channel] = 0;
288
+ }
289
+ return payload;
290
+ };
291
+
292
+ const updatePayload = (
293
+ payload: Record<number, number>,
294
+ data: Uint8Array,
295
+ ): void => {
296
+ for (let channel = 1; channel <= SLOT_COUNT; channel += 1) {
297
+ payload[channel] = data[channel - 1] ?? 0;
298
+ }
299
+ };
300
+
301
+ export class NodeSacnTransport implements OutputTransport {
302
+ readonly #options: NodeSacnTransportOptions;
303
+ readonly #senders = new Map<string, SenderContext>();
304
+ #closed = false;
305
+
306
+ constructor(options: NodeSacnTransportOptions = {}) {
307
+ assertPort(options.port ?? 5568);
308
+ if (options.sourceName !== undefined) assertSourceName(options.sourceName);
309
+ this.#options = options;
310
+ }
311
+
312
+ async send(packet: OutputPacket, signal: AbortSignal): Promise<void> {
313
+ signal.throwIfAborted();
314
+ if (this.#closed)
315
+ throw new TransportError("Node sACN transport is closed.");
316
+ normalizeAddress(packet);
317
+ assertCid(packet.cid);
318
+ if (packet.sourceName !== null) assertSourceName(packet.sourceName);
319
+ if (
320
+ !Number.isInteger(packet.sequence) ||
321
+ packet.sequence < 0 ||
322
+ packet.sequence > 255
323
+ ) {
324
+ throw new SacnValidationError(
325
+ "Sequence must be an integer between 0 and 255.",
326
+ "INVALID_FRAME",
327
+ );
328
+ }
329
+ const frame = toValidatedFrame(packet.data);
330
+ const key = `${packet.universe}:${packet.priority}`;
331
+ const sourceName = sourceNameFor(
332
+ this.#options.sourceName ?? "@helioslx/core",
333
+ packet.sourceName,
334
+ packet.universe,
335
+ packet.priority,
336
+ );
337
+ let context = this.#senders.get(key);
338
+ if (
339
+ context &&
340
+ (context.cid !== packet.cid || context.sourceName !== sourceName)
341
+ ) {
342
+ context.sender.close();
343
+ this.#senders.delete(key);
344
+ context = undefined;
345
+ }
346
+ if (!context) {
347
+ const senderOptions: NodeSenderOptions = {
348
+ universe: packet.universe,
349
+ port: this.#options.port ?? 5568,
350
+ ...(this.#options.iface === undefined
351
+ ? {}
352
+ : { iface: this.#options.iface }),
353
+ ...(this.#options.unicastDestination === undefined
354
+ ? {}
355
+ : { useUnicastDestination: this.#options.unicastDestination }),
356
+ };
357
+ const sender =
358
+ this.#options.senderFactory?.(senderOptions) ??
359
+ new DefaultNodeSender(senderOptions);
360
+ sender.on?.("error", (error) => {
361
+ this.#options.logger?.error?.("sACN sender error.", {
362
+ error,
363
+ universe: packet.universe,
364
+ priority: packet.priority,
365
+ });
366
+ });
367
+ context = {
368
+ sender,
369
+ cid: packet.cid,
370
+ sourceName,
371
+ payload: createPayload(),
372
+ };
373
+ this.#senders.set(key, context);
374
+ }
375
+ updatePayload(context.payload, frame);
376
+ try {
377
+ await context.sender.send({
378
+ payload: context.payload,
379
+ cid: uuidBuffer(packet.cid),
380
+ priority: packet.priority,
381
+ sequence: packet.sequence,
382
+ sourceName,
383
+ useRawDmxValues: true,
384
+ });
385
+ signal.throwIfAborted();
386
+ } catch (error) {
387
+ if (signal.aborted) throw signal.reason;
388
+ this.#senders.delete(key);
389
+ try {
390
+ context.sender.close();
391
+ } catch {
392
+ // A failed sender is discarded even when its close also fails.
393
+ }
394
+ throw new TransportError(
395
+ `Node sACN sender failed for universe ${packet.universe}.`,
396
+ error,
397
+ );
398
+ }
399
+ }
400
+
401
+ async closeOutput(address: Required<OutputAddress>): Promise<void> {
402
+ const key = `${address.universe}:${address.priority}`;
403
+ const context = this.#senders.get(key);
404
+ if (!context) return;
405
+ this.#senders.delete(key);
406
+ context.sender.close();
407
+ }
408
+
409
+ async close(): Promise<void> {
410
+ if (this.#closed) return;
411
+ this.#closed = true;
412
+ for (const context of this.#senders.values()) context.sender.close();
413
+ this.#senders.clear();
414
+ }
415
+ }
416
+
417
+ interface SacnPacketLike {
418
+ readonly cid: Buffer;
419
+ readonly payload: Readonly<Record<number, number>>;
420
+ readonly payloadAsBuffer?: Buffer | null;
421
+ readonly priority: number;
422
+ readonly sequence: number;
423
+ readonly sourceAddress?: string;
424
+ readonly sourceName: string;
425
+ readonly universe: number;
426
+ }
427
+
428
+ export interface NodeReceiverInstance {
429
+ addUniverse(universe: number): Promise<unknown> | unknown;
430
+ removeUniverse(universe: number): Promise<unknown> | unknown;
431
+ on(event: "packet", listener: (packet: SacnPacketLike) => void): unknown;
432
+ on(
433
+ event: "error" | "PacketCorruption" | "PacketOutOfOrder",
434
+ listener: (error: Error) => void,
435
+ ): unknown;
436
+ off?(event: "packet", listener: (packet: SacnPacketLike) => void): unknown;
437
+ off?(
438
+ event: "error" | "PacketCorruption" | "PacketOutOfOrder",
439
+ listener: (error: Error) => void,
440
+ ): unknown;
441
+ close(callback?: () => void): unknown;
442
+ readonly ready?: Promise<void>;
443
+ readonly socket?: {
444
+ address(): unknown;
445
+ once(event: "listening", listener: () => void): unknown;
446
+ };
447
+ }
448
+
449
+ export interface NodeSacnReceiverOptions {
450
+ readonly universes?: readonly number[];
451
+ readonly iface?: string;
452
+ readonly port?: number;
453
+ readonly receiverFactory?: (options: {
454
+ universes: number[];
455
+ iface?: string;
456
+ port: number;
457
+ }) => NodeReceiverInstance;
458
+ readonly logger?: Logger;
459
+ }
460
+
461
+ const cidString = (cid: Buffer): string => {
462
+ const hex = cid.toString("hex").padEnd(32, "0").slice(0, 32);
463
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
464
+ };
465
+
466
+ export class NodeSacnReceiver implements Receiver {
467
+ readonly #receiver: NodeReceiverInstance;
468
+ readonly #listeners = new Set<ReceiverPacketListener>();
469
+ readonly #onPacket: (packet: SacnPacketLike) => void;
470
+ readonly #onError: (error: Error) => void;
471
+ readonly #onPacketWarning: (error: Error) => void;
472
+ readonly #ready: Promise<void>;
473
+ #closed = false;
474
+
475
+ constructor(options: NodeSacnReceiverOptions = {}) {
476
+ assertPort(options.port ?? 5568);
477
+ const universes = (options.universes ?? []).map(
478
+ (universe) => normalizeAddress({ universe }).universe,
479
+ );
480
+ const receiverOptions = {
481
+ universes,
482
+ port: options.port ?? 5568,
483
+ ...(options.iface === undefined ? {} : { iface: options.iface }),
484
+ };
485
+ this.#receiver =
486
+ options.receiverFactory?.(receiverOptions) ??
487
+ (new SacnReceiver({
488
+ ...receiverOptions,
489
+ reuseAddr: true,
490
+ }) as unknown as NodeReceiverInstance);
491
+ const ready =
492
+ this.#receiver.ready ??
493
+ (this.#receiver.socket
494
+ ? new Promise<void>((resolve, reject) => {
495
+ const onReadyError = (error: Error): void => {
496
+ this.#receiver.off?.("error", onReadyError);
497
+ reject(error);
498
+ };
499
+ const onListening = (): void => {
500
+ this.#receiver.off?.("error", onReadyError);
501
+ resolve();
502
+ };
503
+ this.#receiver.on("error", onReadyError);
504
+ try {
505
+ this.#receiver.socket?.address();
506
+ onListening();
507
+ } catch {
508
+ this.#receiver.socket?.once("listening", onListening);
509
+ }
510
+ })
511
+ : Promise.resolve());
512
+ void ready.catch(() => undefined);
513
+ this.#ready = ready;
514
+ this.#onPacket = (packet) => {
515
+ const values = new Uint8Array(SLOT_COUNT);
516
+ if (packet.payloadAsBuffer) {
517
+ values.set(packet.payloadAsBuffer.subarray(0, SLOT_COUNT));
518
+ } else {
519
+ for (let channel = 1; channel <= SLOT_COUNT; channel += 1) {
520
+ values[channel - 1] = packet.payload[channel] ?? 0;
521
+ }
522
+ }
523
+ const normalized: ReceiverPacket = Object.freeze({
524
+ universe: packet.universe,
525
+ priority: packet.priority,
526
+ sequence: packet.sequence,
527
+ cid: cidString(packet.cid),
528
+ sourceName: packet.sourceName || null,
529
+ sourceAddress: packet.sourceAddress ?? null,
530
+ values,
531
+ });
532
+ for (const listener of this.#listeners) listener(normalized);
533
+ };
534
+ this.#onError = (error) => {
535
+ options.logger?.error?.("sACN receiver error.", { error });
536
+ };
537
+ this.#onPacketWarning = (error) => {
538
+ options.logger?.warn?.("sACN receiver dropped or reordered a packet.", {
539
+ error,
540
+ });
541
+ };
542
+ this.#receiver.on("packet", this.#onPacket);
543
+ this.#receiver.on("error", this.#onError);
544
+ this.#receiver.on("PacketCorruption", this.#onPacketWarning);
545
+ this.#receiver.on("PacketOutOfOrder", this.#onPacketWarning);
546
+ }
547
+
548
+ async addUniverse(universe: number): Promise<void> {
549
+ await this.#ready;
550
+ await this.#receiver.addUniverse(normalizeAddress({ universe }).universe);
551
+ }
552
+
553
+ async removeUniverse(universe: number): Promise<void> {
554
+ await this.#ready;
555
+ await this.#receiver.removeUniverse(
556
+ normalizeAddress({ universe }).universe,
557
+ );
558
+ }
559
+
560
+ subscribe(listener: ReceiverPacketListener): () => void {
561
+ if (this.#closed) throw new TransportError("Node sACN receiver is closed.");
562
+ this.#listeners.add(listener);
563
+ return () => this.#listeners.delete(listener);
564
+ }
565
+
566
+ async close(): Promise<void> {
567
+ if (this.#closed) return;
568
+ this.#closed = true;
569
+ this.#receiver.off?.("packet", this.#onPacket);
570
+ this.#receiver.off?.("error", this.#onError);
571
+ this.#receiver.off?.("PacketCorruption", this.#onPacketWarning);
572
+ this.#receiver.off?.("PacketOutOfOrder", this.#onPacketWarning);
573
+ this.#listeners.clear();
574
+ await new Promise<void>((resolve) => {
575
+ this.#receiver.close(resolve);
576
+ });
577
+ }
578
+ }