@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/redis.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { createClient } from "redis";
2
+ import {
3
+ type OutputAddress,
4
+ type OutputRecord,
5
+ type OutputStore,
6
+ type ViewerRecord,
7
+ type ViewerStore,
8
+ } from "./contracts.js";
9
+ import {
10
+ PersistenceError,
11
+ SacnValidationError,
12
+ assertCid,
13
+ assertFps,
14
+ assertSourceName,
15
+ normalizeAddress,
16
+ toValidatedFrame,
17
+ } from "./validation.js";
18
+
19
+ export interface RedisClientCompatible {
20
+ get(key: string): Promise<string | null>;
21
+ set(key: string, value: string): Promise<unknown>;
22
+ del(key: string): Promise<unknown>;
23
+ scanIterator(options: {
24
+ MATCH: string;
25
+ COUNT?: number;
26
+ }): AsyncIterable<string | string[]>;
27
+ connect?(): Promise<unknown>;
28
+ quit?(): Promise<unknown>;
29
+ disconnect?(): unknown;
30
+ }
31
+
32
+ export interface RedisStoreOptions {
33
+ readonly client?: RedisClientCompatible;
34
+ readonly url?: string;
35
+ readonly namespace?: string;
36
+ readonly version?: string | number;
37
+ /** Defaults to true for an internally-created client and false for an injected client. */
38
+ readonly closeClient?: boolean;
39
+ }
40
+
41
+ abstract class RedisStoreBase {
42
+ readonly prefix: string;
43
+ readonly #client: Promise<RedisClientCompatible>;
44
+ readonly #closeClient: boolean;
45
+ #queue: Promise<void> = Promise.resolve();
46
+ #closing = false;
47
+ #closed = false;
48
+ #closePromise: Promise<void> | null = null;
49
+
50
+ constructor(options: RedisStoreOptions) {
51
+ const namespace = options.namespace ?? "helioslx";
52
+ const version = String(options.version ?? 1);
53
+ if (
54
+ !/^[A-Za-z0-9:_-]{1,128}$/.test(namespace) ||
55
+ !/^[A-Za-z0-9._-]{1,32}$/.test(version)
56
+ ) {
57
+ throw new SacnValidationError(
58
+ "Redis namespace or schema version contains unsupported characters.",
59
+ "INVALID_NAMESPACE",
60
+ );
61
+ }
62
+ this.prefix = `${namespace}:v${version}`;
63
+ this.#closeClient = options.closeClient ?? options.client === undefined;
64
+ if (options.client) {
65
+ this.#client = Promise.resolve(options.client);
66
+ } else {
67
+ const client = createClient(options.url ? { url: options.url } : {});
68
+ this.#client = client.connect().then(() => client as RedisClientCompatible);
69
+ }
70
+ }
71
+
72
+ protected ordered<T>(action: string, operation: (client: RedisClientCompatible) => Promise<T>): Promise<T> {
73
+ if (this.#closing || this.#closed) {
74
+ return Promise.reject(new PersistenceError("Redis store is closed."));
75
+ }
76
+ const result = this.#queue.then(async () => {
77
+ try {
78
+ return await operation(await this.#client);
79
+ } catch (error) {
80
+ if (error instanceof PersistenceError) throw error;
81
+ throw new PersistenceError(`Redis failed to ${action}.`, error);
82
+ }
83
+ });
84
+ this.#queue = result.then(() => undefined, () => undefined);
85
+ return result;
86
+ }
87
+
88
+ async close(): Promise<void> {
89
+ if (this.#closePromise) return this.#closePromise;
90
+ this.#closing = true;
91
+ this.#closePromise = this.#queue.then(async () => {
92
+ try {
93
+ if (this.#closeClient) {
94
+ const client = await this.#client;
95
+ if (client.quit) await client.quit();
96
+ else client.disconnect?.();
97
+ }
98
+ } catch (error) {
99
+ throw new PersistenceError("Redis failed to close client.", error);
100
+ } finally {
101
+ this.#closed = true;
102
+ this.#closing = false;
103
+ }
104
+ });
105
+ return this.#closePromise;
106
+ }
107
+ }
108
+
109
+ const outputRecord = (value: unknown): OutputRecord => {
110
+ if (!value || typeof value !== "object") {
111
+ throw new PersistenceError("Stored Redis output record is not an object.");
112
+ }
113
+ const record = value as Record<string, unknown>;
114
+ const address = normalizeAddress({
115
+ universe: record.universe as number,
116
+ priority: record.priority as number,
117
+ });
118
+ const cid = assertCid(record.cid as string);
119
+ assertFps(record.idleFps as number, "Idle FPS");
120
+ const sourceName =
121
+ record.sourceName === null ? null : assertSourceName(record.sourceName as string);
122
+ const target = toValidatedFrame(record.target as number[]);
123
+ if (typeof record.updatedAt !== "number" || !Number.isFinite(record.updatedAt)) {
124
+ throw new PersistenceError("Stored Redis output updatedAt is invalid.");
125
+ }
126
+ return Object.freeze({
127
+ ...address,
128
+ cid,
129
+ idleFps: record.idleFps as number,
130
+ sourceName,
131
+ target: Object.freeze(Array.from(target)),
132
+ updatedAt: record.updatedAt,
133
+ });
134
+ };
135
+
136
+ const validateOutputRecord = (value: unknown): OutputRecord => {
137
+ try {
138
+ return outputRecord(value);
139
+ } catch (error) {
140
+ if (error instanceof PersistenceError) throw error;
141
+ throw new PersistenceError("Redis output record is invalid.", error);
142
+ }
143
+ };
144
+
145
+ export class RedisOutputStore extends RedisStoreBase implements OutputStore {
146
+ constructor(options: RedisStoreOptions = {}) {
147
+ super(options);
148
+ }
149
+
150
+ #key(address: Required<OutputAddress>): string {
151
+ return `${this.prefix}:output:${address.universe}:${address.priority}`;
152
+ }
153
+
154
+ get(address: Required<OutputAddress>): Promise<OutputRecord | null> {
155
+ return this.ordered("get output", async (client) => {
156
+ const value = await client.get(this.#key(address));
157
+ return value === null ? null : validateOutputRecord(JSON.parse(value));
158
+ });
159
+ }
160
+
161
+ list(): Promise<readonly OutputRecord[]> {
162
+ return this.ordered("list outputs", async (client) => {
163
+ const pattern = `${this.prefix}:output:*`;
164
+ const keys: string[] = [];
165
+ for await (const entry of client.scanIterator({
166
+ MATCH: pattern,
167
+ COUNT: 100,
168
+ })) {
169
+ if (Array.isArray(entry)) keys.push(...entry);
170
+ else keys.push(entry);
171
+ }
172
+ const records = await Promise.all(
173
+ keys.sort().map(async (key) => {
174
+ const value = await client.get(key);
175
+ if (value === null) {
176
+ throw new PersistenceError(`Redis output key disappeared: ${key}.`);
177
+ }
178
+ return validateOutputRecord(JSON.parse(value));
179
+ }),
180
+ );
181
+ return Object.freeze(
182
+ records.sort(
183
+ (left, right) =>
184
+ left.universe - right.universe || left.priority - right.priority,
185
+ ),
186
+ );
187
+ });
188
+ }
189
+
190
+ remove(address: Required<OutputAddress>): Promise<void> {
191
+ return this.ordered("remove output", async (client) => {
192
+ await client.del(this.#key(address));
193
+ });
194
+ }
195
+
196
+ save(record: OutputRecord): Promise<void> {
197
+ const validated = validateOutputRecord(record);
198
+ return this.ordered("save output", async (client) => {
199
+ await client.set(
200
+ this.#key({ universe: validated.universe, priority: validated.priority }),
201
+ JSON.stringify(validated),
202
+ );
203
+ });
204
+ }
205
+ }
206
+
207
+ const viewerRecord = (value: unknown): ViewerRecord => {
208
+ if (!value || typeof value !== "object") {
209
+ throw new PersistenceError("Stored Redis viewer record is not an object.");
210
+ }
211
+ const record = value as Record<string, unknown>;
212
+ if (!Array.isArray(record.selectedUniverses)) {
213
+ throw new PersistenceError("Stored Redis viewer universes are invalid.");
214
+ }
215
+ const universes = [...new Set(record.selectedUniverses)].map((universe) => {
216
+ const normalized = normalizeAddress({ universe: universe as number });
217
+ return normalized.universe;
218
+ });
219
+ if (typeof record.updatedAt !== "number" || !Number.isFinite(record.updatedAt)) {
220
+ throw new PersistenceError("Stored Redis viewer updatedAt is invalid.");
221
+ }
222
+ return Object.freeze({
223
+ selectedUniverses: Object.freeze(universes.sort((a, b) => a - b)),
224
+ updatedAt: record.updatedAt,
225
+ });
226
+ };
227
+
228
+ const validateViewerRecord = (value: unknown): ViewerRecord => {
229
+ try {
230
+ return viewerRecord(value);
231
+ } catch (error) {
232
+ if (error instanceof PersistenceError) throw error;
233
+ throw new PersistenceError("Redis viewer record is invalid.", error);
234
+ }
235
+ };
236
+
237
+ export class RedisViewerStore extends RedisStoreBase implements ViewerStore {
238
+ readonly #key: string;
239
+
240
+ constructor(options: RedisStoreOptions = {}) {
241
+ super(options);
242
+ this.#key = `${this.prefix}:viewer`;
243
+ }
244
+
245
+ load(): Promise<ViewerRecord | null> {
246
+ return this.ordered("load viewer state", async (client) => {
247
+ const value = await client.get(this.#key);
248
+ return value === null ? null : validateViewerRecord(JSON.parse(value));
249
+ });
250
+ }
251
+
252
+ save(record: ViewerRecord): Promise<void> {
253
+ const validated = validateViewerRecord(record);
254
+ return this.ordered("save viewer state", async (client) => {
255
+ await client.set(this.#key, JSON.stringify(validated));
256
+ });
257
+ }
258
+ }