@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,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PRIORITY,
|
|
3
|
+
MAX_PRIORITY,
|
|
4
|
+
MAX_UNIVERSE,
|
|
5
|
+
MIN_PRIORITY,
|
|
6
|
+
MIN_UNIVERSE,
|
|
7
|
+
SLOT_COUNT,
|
|
8
|
+
type ChannelValues,
|
|
9
|
+
type ChannelWrite,
|
|
10
|
+
type OutputAddress,
|
|
11
|
+
type TransitionWrite,
|
|
12
|
+
} from "./contracts.js";
|
|
13
|
+
|
|
14
|
+
export type ValidationCode =
|
|
15
|
+
| "INVALID_CHANNEL"
|
|
16
|
+
| "INVALID_CID"
|
|
17
|
+
| "INVALID_FADE"
|
|
18
|
+
| "INVALID_FPS"
|
|
19
|
+
| "INVALID_FRAME"
|
|
20
|
+
| "INVALID_PRIORITY"
|
|
21
|
+
| "INVALID_PORT"
|
|
22
|
+
| "INVALID_NAMESPACE"
|
|
23
|
+
| "INVALID_SOURCE_NAME"
|
|
24
|
+
| "INVALID_TIMEOUT"
|
|
25
|
+
| "INVALID_UNIVERSE";
|
|
26
|
+
|
|
27
|
+
export class SacnError extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly code: string,
|
|
31
|
+
readonly cause?: unknown,
|
|
32
|
+
) {
|
|
33
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
34
|
+
this.name = new.target.name;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SacnValidationError extends SacnError {
|
|
39
|
+
constructor(message: string, code: ValidationCode) {
|
|
40
|
+
super(message, code);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SacnLifecycleError extends SacnError {
|
|
45
|
+
constructor(message: string) {
|
|
46
|
+
super(message, "INVALID_LIFECYCLE");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class TransportTimeoutError extends SacnError {
|
|
51
|
+
constructor(timeoutMs: number, universe: number, priority: number) {
|
|
52
|
+
super(
|
|
53
|
+
`Transport send timed out after ${timeoutMs}ms for output ${universe}:${priority}.`,
|
|
54
|
+
"TRANSPORT_TIMEOUT",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class TransportError extends SacnError {
|
|
60
|
+
constructor(message: string, cause?: unknown) {
|
|
61
|
+
super(message, "TRANSPORT_ERROR", cause);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PersistenceError extends SacnError {
|
|
66
|
+
constructor(message: string, cause?: unknown) {
|
|
67
|
+
super(message, "PERSISTENCE_ERROR", cause);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class DependencyUnavailableError extends SacnError {
|
|
72
|
+
constructor(message: string, cause?: unknown) {
|
|
73
|
+
super(message, "DEPENDENCY_UNAVAILABLE", cause);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const integerInRange = (
|
|
78
|
+
value: number,
|
|
79
|
+
minimum: number,
|
|
80
|
+
maximum: number,
|
|
81
|
+
label: string,
|
|
82
|
+
code: ValidationCode,
|
|
83
|
+
): void => {
|
|
84
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
|
85
|
+
throw new SacnValidationError(
|
|
86
|
+
`${label} must be an integer between ${minimum} and ${maximum}.`,
|
|
87
|
+
code,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const normalizeAddress = (
|
|
93
|
+
address: OutputAddress,
|
|
94
|
+
): Required<OutputAddress> => {
|
|
95
|
+
if (!address || typeof address !== "object") {
|
|
96
|
+
throw new SacnValidationError(
|
|
97
|
+
"Output address must be an object.",
|
|
98
|
+
"INVALID_UNIVERSE",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
integerInRange(
|
|
102
|
+
address.universe,
|
|
103
|
+
MIN_UNIVERSE,
|
|
104
|
+
MAX_UNIVERSE,
|
|
105
|
+
"Universe",
|
|
106
|
+
"INVALID_UNIVERSE",
|
|
107
|
+
);
|
|
108
|
+
const priority = address.priority ?? DEFAULT_PRIORITY;
|
|
109
|
+
integerInRange(
|
|
110
|
+
priority,
|
|
111
|
+
MIN_PRIORITY,
|
|
112
|
+
MAX_PRIORITY,
|
|
113
|
+
"Priority",
|
|
114
|
+
"INVALID_PRIORITY",
|
|
115
|
+
);
|
|
116
|
+
return { universe: address.universe, priority };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const assertFps = (value: number, label = "FPS"): void => {
|
|
120
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1000) {
|
|
121
|
+
throw new SacnValidationError(
|
|
122
|
+
`${label} must be a finite number greater than 0 and at most 1000.`,
|
|
123
|
+
"INVALID_FPS",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const assertTimeout = (value: number): void => {
|
|
129
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
130
|
+
throw new SacnValidationError(
|
|
131
|
+
"Send timeout must be a finite number greater than 0.",
|
|
132
|
+
"INVALID_TIMEOUT",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const assertPort = (value: number): void => {
|
|
138
|
+
integerInRange(value, 1, 65_535, "UDP port", "INVALID_PORT");
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const assertFade = (value: number): void => {
|
|
142
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
143
|
+
throw new SacnValidationError(
|
|
144
|
+
"Fade duration must be a finite number greater than or equal to 0.",
|
|
145
|
+
"INVALID_FADE",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** @deprecated Prefer assertFade; kept as a clearer alias for durationMs APIs. */
|
|
151
|
+
export const assertDuration = assertFade;
|
|
152
|
+
|
|
153
|
+
export const assertSourceName = (value: string): string => {
|
|
154
|
+
if (typeof value !== "string") {
|
|
155
|
+
throw new SacnValidationError(
|
|
156
|
+
"Source name must be a string.",
|
|
157
|
+
"INVALID_SOURCE_NAME",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const normalized = value.trim();
|
|
161
|
+
if (normalized.length === 0 || normalized.length > 64) {
|
|
162
|
+
throw new SacnValidationError(
|
|
163
|
+
"Source name must contain between 1 and 64 characters.",
|
|
164
|
+
"INVALID_SOURCE_NAME",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return normalized;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const UUID_PATTERN =
|
|
171
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
172
|
+
|
|
173
|
+
export const assertCid = (value: string): string => {
|
|
174
|
+
if (typeof value !== "string" || !UUID_PATTERN.test(value)) {
|
|
175
|
+
throw new SacnValidationError("CID must be a valid UUID.", "INVALID_CID");
|
|
176
|
+
}
|
|
177
|
+
return value.toLowerCase();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const assertChannelWrite = (write: ChannelWrite): void => {
|
|
181
|
+
if (!write || typeof write !== "object") {
|
|
182
|
+
throw new SacnValidationError(
|
|
183
|
+
"Each channel write must be an object.",
|
|
184
|
+
"INVALID_CHANNEL",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
integerInRange(write.channel, 1, SLOT_COUNT, "Channel", "INVALID_CHANNEL");
|
|
188
|
+
integerInRange(write.value, 0, 255, "Channel value", "INVALID_CHANNEL");
|
|
189
|
+
if (write.durationMs !== undefined) assertFade(write.durationMs);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const validateChannelWrites = (
|
|
193
|
+
writes: readonly ChannelWrite[],
|
|
194
|
+
): void => {
|
|
195
|
+
if (!Array.isArray(writes)) {
|
|
196
|
+
throw new SacnValidationError(
|
|
197
|
+
"Channel writes must be an array.",
|
|
198
|
+
"INVALID_CHANNEL",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (writes.length === 0) {
|
|
202
|
+
throw new SacnValidationError(
|
|
203
|
+
"At least one channel write is required.",
|
|
204
|
+
"INVALID_CHANNEL",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const seen = new Set<number>();
|
|
208
|
+
for (const write of writes) {
|
|
209
|
+
assertChannelWrite(write);
|
|
210
|
+
if (seen.has(write.channel)) {
|
|
211
|
+
throw new SacnValidationError(
|
|
212
|
+
`Channel ${write.channel} appears more than once.`,
|
|
213
|
+
"INVALID_CHANNEL",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
seen.add(write.channel);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const assertTransitionWrite = (write: TransitionWrite): void => {
|
|
221
|
+
if (!write || typeof write !== "object") {
|
|
222
|
+
throw new SacnValidationError(
|
|
223
|
+
"Each transition write must be an object.",
|
|
224
|
+
"INVALID_CHANNEL",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
integerInRange(write.channel, 1, SLOT_COUNT, "Channel", "INVALID_CHANNEL");
|
|
228
|
+
integerInRange(write.value, 0, 255, "Channel value", "INVALID_CHANNEL");
|
|
229
|
+
assertFade(write.durationMs);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const validateTransitionWrites = (
|
|
233
|
+
writes: readonly TransitionWrite[],
|
|
234
|
+
): void => {
|
|
235
|
+
if (!Array.isArray(writes)) {
|
|
236
|
+
throw new SacnValidationError(
|
|
237
|
+
"Transition writes must be an array.",
|
|
238
|
+
"INVALID_CHANNEL",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (writes.length === 0) {
|
|
242
|
+
throw new SacnValidationError(
|
|
243
|
+
"At least one transition write is required.",
|
|
244
|
+
"INVALID_CHANNEL",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const seen = new Set<number>();
|
|
248
|
+
for (const write of writes) {
|
|
249
|
+
assertTransitionWrite(write);
|
|
250
|
+
if (seen.has(write.channel)) {
|
|
251
|
+
throw new SacnValidationError(
|
|
252
|
+
`Channel ${write.channel} appears more than once.`,
|
|
253
|
+
"INVALID_CHANNEL",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
seen.add(write.channel);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const channelValuesToWrites = (
|
|
261
|
+
values: ChannelValues,
|
|
262
|
+
durationMs?: number,
|
|
263
|
+
): ChannelWrite[] => {
|
|
264
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
265
|
+
throw new SacnValidationError(
|
|
266
|
+
"Channel values must be an object map.",
|
|
267
|
+
"INVALID_CHANNEL",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (durationMs !== undefined) assertFade(durationMs);
|
|
271
|
+
const writes: ChannelWrite[] = [];
|
|
272
|
+
for (const [rawKey, rawValue] of Object.entries(values)) {
|
|
273
|
+
const channel = Number(rawKey);
|
|
274
|
+
if (!Number.isInteger(channel) || String(channel) !== rawKey) {
|
|
275
|
+
throw new SacnValidationError(
|
|
276
|
+
`Channel key "${rawKey}" must be an integer between 1 and ${SLOT_COUNT}.`,
|
|
277
|
+
"INVALID_CHANNEL",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (typeof rawValue !== "number") {
|
|
281
|
+
throw new SacnValidationError(
|
|
282
|
+
`Channel ${channel} value must be a number.`,
|
|
283
|
+
"INVALID_CHANNEL",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
writes.push({
|
|
287
|
+
channel,
|
|
288
|
+
value: rawValue,
|
|
289
|
+
...(durationMs === undefined ? {} : { durationMs }),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
validateChannelWrites(writes);
|
|
293
|
+
return writes;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
export const toValidatedFrame = (
|
|
297
|
+
values: readonly number[] | Uint8Array,
|
|
298
|
+
): Uint8Array => {
|
|
299
|
+
if (!Array.isArray(values) && !(values instanceof Uint8Array)) {
|
|
300
|
+
throw new SacnValidationError(
|
|
301
|
+
"Frame values must be an array or Uint8Array.",
|
|
302
|
+
"INVALID_FRAME",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (values.length !== SLOT_COUNT) {
|
|
306
|
+
throw new SacnValidationError(
|
|
307
|
+
`Frame must contain exactly ${SLOT_COUNT} values.`,
|
|
308
|
+
"INVALID_FRAME",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const frame = new Uint8Array(SLOT_COUNT);
|
|
312
|
+
for (let index = 0; index < SLOT_COUNT; index += 1) {
|
|
313
|
+
const value = values[index];
|
|
314
|
+
if (
|
|
315
|
+
value === undefined ||
|
|
316
|
+
!Number.isInteger(value) ||
|
|
317
|
+
value < 0 ||
|
|
318
|
+
value > 255
|
|
319
|
+
) {
|
|
320
|
+
throw new SacnValidationError(
|
|
321
|
+
`Frame value at channel ${index + 1} must be an integer between 0 and 255.`,
|
|
322
|
+
"INVALID_FRAME",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
frame[index] = value;
|
|
326
|
+
}
|
|
327
|
+
return frame;
|
|
328
|
+
};
|
package/src/viewer.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_UNIVERSE,
|
|
3
|
+
MIN_UNIVERSE,
|
|
4
|
+
SLOT_COUNT,
|
|
5
|
+
type Logger,
|
|
6
|
+
type ReceiverPacket,
|
|
7
|
+
type ViewerPacket,
|
|
8
|
+
type ViewerPacketStream,
|
|
9
|
+
type ViewerRecord,
|
|
10
|
+
type ViewerServiceContract,
|
|
11
|
+
type ViewerServiceOptions,
|
|
12
|
+
type ViewerStore,
|
|
13
|
+
type ViewerTelemetry,
|
|
14
|
+
} from "./contracts.js";
|
|
15
|
+
import { PersistenceError, SacnLifecycleError, SacnValidationError } from "./validation.js";
|
|
16
|
+
|
|
17
|
+
const normalizeUniverses = (
|
|
18
|
+
universes: readonly number[],
|
|
19
|
+
maximum: number,
|
|
20
|
+
): readonly number[] => {
|
|
21
|
+
const unique = [...new Set(universes)];
|
|
22
|
+
for (const universe of unique) {
|
|
23
|
+
if (
|
|
24
|
+
!Number.isInteger(universe) ||
|
|
25
|
+
universe < MIN_UNIVERSE ||
|
|
26
|
+
universe > MAX_UNIVERSE
|
|
27
|
+
) {
|
|
28
|
+
throw new SacnValidationError(
|
|
29
|
+
`Universe must be an integer between ${MIN_UNIVERSE} and ${MAX_UNIVERSE}.`,
|
|
30
|
+
"INVALID_UNIVERSE",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (unique.length > maximum) {
|
|
35
|
+
throw new SacnValidationError(
|
|
36
|
+
`At most ${maximum} viewer universes may be selected.`,
|
|
37
|
+
"INVALID_UNIVERSE",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze(unique.sort((left, right) => left - right));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const normalizePacket = (packet: ReceiverPacket, receivedAt: number): ViewerPacket => {
|
|
44
|
+
const values = Array.from({ length: SLOT_COUNT }, (_, index) => packet.values[index] ?? 0);
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
universe: packet.universe,
|
|
47
|
+
receivedAt,
|
|
48
|
+
values: Object.freeze(values),
|
|
49
|
+
source: Object.freeze({
|
|
50
|
+
cid: packet.cid,
|
|
51
|
+
priority: packet.priority,
|
|
52
|
+
sequence: packet.sequence,
|
|
53
|
+
sourceAddress: packet.sourceAddress,
|
|
54
|
+
sourceName: packet.sourceName,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
class PacketStream implements ViewerPacketStream {
|
|
60
|
+
readonly #capacity: number;
|
|
61
|
+
readonly #onDrop: () => void;
|
|
62
|
+
readonly #onClose: () => void;
|
|
63
|
+
readonly #queue: ViewerPacket[] = [];
|
|
64
|
+
readonly #waiting: Array<
|
|
65
|
+
(result: IteratorResult<ViewerPacket>) => void
|
|
66
|
+
> = [];
|
|
67
|
+
#closed = false;
|
|
68
|
+
|
|
69
|
+
constructor(capacity: number, onDrop: () => void, onClose: () => void) {
|
|
70
|
+
this.#capacity = capacity;
|
|
71
|
+
this.#onDrop = onDrop;
|
|
72
|
+
this.#onClose = onClose;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
push(packet: ViewerPacket): void {
|
|
76
|
+
if (this.#closed) return;
|
|
77
|
+
const resolve = this.#waiting.shift();
|
|
78
|
+
if (resolve) {
|
|
79
|
+
resolve({ value: packet, done: false });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const existing = this.#queue.findIndex(
|
|
83
|
+
(queued) => queued.universe === packet.universe,
|
|
84
|
+
);
|
|
85
|
+
if (existing >= 0) {
|
|
86
|
+
this.#queue.splice(existing, 1);
|
|
87
|
+
this.#onDrop();
|
|
88
|
+
} else if (this.#queue.length >= this.#capacity) {
|
|
89
|
+
this.#queue.shift();
|
|
90
|
+
this.#onDrop();
|
|
91
|
+
}
|
|
92
|
+
this.#queue.push(packet);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close(): void {
|
|
96
|
+
if (this.#closed) return;
|
|
97
|
+
this.#closed = true;
|
|
98
|
+
this.#queue.length = 0;
|
|
99
|
+
for (const resolve of this.#waiting.splice(0)) {
|
|
100
|
+
resolve({ value: undefined, done: true });
|
|
101
|
+
}
|
|
102
|
+
this.#onClose();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[Symbol.asyncIterator](): AsyncIterator<ViewerPacket> {
|
|
106
|
+
return {
|
|
107
|
+
next: async () => {
|
|
108
|
+
const packet = this.#queue.shift();
|
|
109
|
+
if (packet) return { value: packet, done: false };
|
|
110
|
+
if (this.#closed) return { value: undefined, done: true };
|
|
111
|
+
return await new Promise<IteratorResult<ViewerPacket>>((resolve) => {
|
|
112
|
+
this.#waiting.push(resolve);
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
return: async () => {
|
|
116
|
+
this.close();
|
|
117
|
+
return { value: undefined, done: true };
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class ViewerService implements ViewerServiceContract {
|
|
124
|
+
readonly #receiver: ViewerServiceOptions["receiver"];
|
|
125
|
+
readonly #store: ViewerStore;
|
|
126
|
+
readonly #clock: NonNullable<ViewerServiceOptions["clock"]>;
|
|
127
|
+
readonly #logger: Logger;
|
|
128
|
+
readonly #ownsReceiver: boolean;
|
|
129
|
+
readonly #ownsStore: boolean;
|
|
130
|
+
readonly #streamCapacity: number;
|
|
131
|
+
readonly #maxUniverses: number;
|
|
132
|
+
readonly #maxListeners: number;
|
|
133
|
+
readonly #listeners = new Set<(packet: ViewerPacket) => void>();
|
|
134
|
+
readonly #streams = new Set<PacketStream>();
|
|
135
|
+
readonly #unsubscribe: () => void;
|
|
136
|
+
#selected: readonly number[] = Object.freeze([]);
|
|
137
|
+
#queue: Promise<void> = Promise.resolve();
|
|
138
|
+
#started = false;
|
|
139
|
+
#closed = false;
|
|
140
|
+
#packetsReceived = 0;
|
|
141
|
+
#droppedUpdates = 0;
|
|
142
|
+
|
|
143
|
+
constructor(options: ViewerServiceOptions) {
|
|
144
|
+
this.#receiver = options.receiver;
|
|
145
|
+
this.#store = options.store ?? new MemoryViewerStore();
|
|
146
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
147
|
+
this.#logger = options.logger ?? {};
|
|
148
|
+
this.#ownsReceiver = options.ownsReceiver ?? false;
|
|
149
|
+
this.#ownsStore = options.ownsStore ?? options.store === undefined;
|
|
150
|
+
this.#streamCapacity = options.streamCapacity ?? 32;
|
|
151
|
+
this.#maxUniverses = options.maxUniverses ?? 256;
|
|
152
|
+
this.#maxListeners = options.maxListeners ?? 256;
|
|
153
|
+
if (!Number.isInteger(this.#streamCapacity) || this.#streamCapacity < 1) {
|
|
154
|
+
throw new SacnValidationError("Stream capacity must be a positive integer.", "INVALID_FRAME");
|
|
155
|
+
}
|
|
156
|
+
if (!Number.isInteger(this.#maxUniverses) || this.#maxUniverses < 1) {
|
|
157
|
+
throw new SacnValidationError(
|
|
158
|
+
"Maximum universes must be a positive integer.",
|
|
159
|
+
"INVALID_UNIVERSE",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!Number.isInteger(this.#maxListeners) || this.#maxListeners < 1) {
|
|
163
|
+
throw new SacnValidationError(
|
|
164
|
+
"Maximum listeners must be a positive integer.",
|
|
165
|
+
"INVALID_FRAME",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
this.#unsubscribe = this.#receiver.subscribe((packet) => this.#receive(packet));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async start(): Promise<void> {
|
|
172
|
+
await this.#enqueue(async () => {
|
|
173
|
+
if (this.#started) return;
|
|
174
|
+
const record = await this.#persist("load viewer state", () => this.#store.load());
|
|
175
|
+
if (record) await this.#applyUniverses(record.selectedUniverses, false);
|
|
176
|
+
this.#started = true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getSelectedUniverses(): readonly number[] {
|
|
181
|
+
return this.#selected;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setSelectedUniverses(universes: readonly number[]): Promise<readonly number[]> {
|
|
185
|
+
return this.#enqueue(async () => {
|
|
186
|
+
await this.#applyUniverses(universes, true);
|
|
187
|
+
return this.#selected;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
addUniverse(universe: number): Promise<readonly number[]> {
|
|
192
|
+
return this.setSelectedUniverses([...this.#selected, universe]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
removeUniverse(universe: number): Promise<readonly number[]> {
|
|
196
|
+
return this.setSelectedUniverses(this.#selected.filter((item) => item !== universe));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
subscribe(listener: (packet: ViewerPacket) => void): () => void {
|
|
200
|
+
this.#assertOpen();
|
|
201
|
+
this.#assertListenerCapacity();
|
|
202
|
+
this.#listeners.add(listener);
|
|
203
|
+
return () => this.#listeners.delete(listener);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
packets(capacity = this.#streamCapacity): ViewerPacketStream {
|
|
207
|
+
this.#assertOpen();
|
|
208
|
+
this.#assertListenerCapacity();
|
|
209
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
210
|
+
throw new SacnValidationError("Stream capacity must be a positive integer.", "INVALID_FRAME");
|
|
211
|
+
}
|
|
212
|
+
let stream: PacketStream;
|
|
213
|
+
stream = new PacketStream(
|
|
214
|
+
capacity,
|
|
215
|
+
() => {
|
|
216
|
+
this.#droppedUpdates += 1;
|
|
217
|
+
},
|
|
218
|
+
() => this.#streams.delete(stream),
|
|
219
|
+
);
|
|
220
|
+
this.#streams.add(stream);
|
|
221
|
+
return stream;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
getTelemetry(): ViewerTelemetry {
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
selectedUniverses: this.#selected,
|
|
227
|
+
packetsReceived: this.#packetsReceived,
|
|
228
|
+
droppedUpdates: this.#droppedUpdates,
|
|
229
|
+
streamCount: this.#streams.size,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async close(): Promise<void> {
|
|
234
|
+
if (this.#closed) return;
|
|
235
|
+
this.#closed = true;
|
|
236
|
+
await this.#queue;
|
|
237
|
+
this.#unsubscribe();
|
|
238
|
+
for (const stream of [...this.#streams]) stream.close();
|
|
239
|
+
this.#listeners.clear();
|
|
240
|
+
const errors: unknown[] = [];
|
|
241
|
+
if (this.#ownsReceiver) {
|
|
242
|
+
try {
|
|
243
|
+
await this.#receiver.close();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
errors.push(error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (this.#ownsStore) {
|
|
249
|
+
try {
|
|
250
|
+
await this.#persist("close viewer store", async () => {
|
|
251
|
+
await this.#store.close?.();
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
errors.push(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (errors.length === 1) throw errors[0];
|
|
258
|
+
if (errors.length > 1) {
|
|
259
|
+
throw new AggregateError(errors, "Viewer shutdown failed.");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #applyUniverses(universes: readonly number[], save: boolean): Promise<void> {
|
|
264
|
+
const next = normalizeUniverses(universes, this.#maxUniverses);
|
|
265
|
+
const previousSelection = this.#selected;
|
|
266
|
+
const previous = new Set(previousSelection);
|
|
267
|
+
const desired = new Set(next);
|
|
268
|
+
try {
|
|
269
|
+
for (const universe of previousSelection) {
|
|
270
|
+
if (!desired.has(universe)) await this.#receiver.removeUniverse(universe);
|
|
271
|
+
}
|
|
272
|
+
for (const universe of next) {
|
|
273
|
+
if (!previous.has(universe)) await this.#receiver.addUniverse(universe);
|
|
274
|
+
}
|
|
275
|
+
this.#selected = next;
|
|
276
|
+
if (save) {
|
|
277
|
+
await this.#persist("save viewer state", () =>
|
|
278
|
+
this.#store.save({
|
|
279
|
+
selectedUniverses: next,
|
|
280
|
+
updatedAt: this.#clock.now(),
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
} catch (error) {
|
|
285
|
+
try {
|
|
286
|
+
for (const universe of next) {
|
|
287
|
+
if (!previous.has(universe)) {
|
|
288
|
+
await this.#receiver.removeUniverse(universe);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const universe of previousSelection) {
|
|
292
|
+
if (!desired.has(universe)) {
|
|
293
|
+
await this.#receiver.addUniverse(universe);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.#selected = previousSelection;
|
|
297
|
+
} catch (rollbackError) {
|
|
298
|
+
throw new AggregateError(
|
|
299
|
+
[error, rollbackError],
|
|
300
|
+
"Viewer universe update and rollback failed.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#receive(packet: ReceiverPacket): void {
|
|
308
|
+
if (this.#closed || !this.#selected.includes(packet.universe)) return;
|
|
309
|
+
const normalized = normalizePacket(packet, this.#clock.now());
|
|
310
|
+
this.#packetsReceived += 1;
|
|
311
|
+
for (const listener of this.#listeners) {
|
|
312
|
+
try {
|
|
313
|
+
listener(normalized);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
this.#logger.warn?.("Viewer packet listener failed.", { error });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const stream of this.#streams) stream.push(normalized);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
322
|
+
if (this.#closed) {
|
|
323
|
+
return Promise.reject(new SacnLifecycleError("Viewer service is closed."));
|
|
324
|
+
}
|
|
325
|
+
const result = this.#queue.then(operation);
|
|
326
|
+
this.#queue = result.then(() => undefined, () => undefined);
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async #persist<T>(action: string, operation: () => Promise<T>): Promise<T> {
|
|
331
|
+
try {
|
|
332
|
+
return await operation();
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error instanceof PersistenceError) throw error;
|
|
335
|
+
throw new PersistenceError(`Failed to ${action}.`, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#assertOpen(): void {
|
|
340
|
+
if (this.#closed) throw new SacnLifecycleError("Viewer service is closed.");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#assertListenerCapacity(): void {
|
|
344
|
+
if (this.#listeners.size + this.#streams.size >= this.#maxListeners) {
|
|
345
|
+
throw new SacnLifecycleError(
|
|
346
|
+
`Viewer subscriber limit of ${this.#maxListeners} has been reached.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const cloneRecord = (record: ViewerRecord): ViewerRecord =>
|
|
353
|
+
Object.freeze({
|
|
354
|
+
selectedUniverses: Object.freeze([...record.selectedUniverses]),
|
|
355
|
+
updatedAt: record.updatedAt,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
export class MemoryViewerStore implements ViewerStore {
|
|
359
|
+
#record: ViewerRecord | null = null;
|
|
360
|
+
|
|
361
|
+
async load(): Promise<ViewerRecord | null> {
|
|
362
|
+
return this.#record ? cloneRecord(this.#record) : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async save(record: ViewerRecord): Promise<void> {
|
|
366
|
+
this.#record = cloneRecord(record);
|
|
367
|
+
}
|
|
368
|
+
}
|