@ignex/nova 0.1.1 → 0.1.3

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 (67) hide show
  1. package/README.md +132 -32
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +232 -0
  4. package/docs/architecture.md +35 -10
  5. package/docs/events.md +170 -0
  6. package/docs/generic-bindings.md +197 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +9 -2
  9. package/index.ts +75 -27
  10. package/package.json +12 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +416 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +42 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/transcode/generated.rs +2 -1
  21. package/src/bindings/assemble.ts +73 -0
  22. package/src/bindings/default.ts +65 -0
  23. package/src/bindings/types.ts +113 -0
  24. package/src/bridge/nats.ts +53 -13
  25. package/src/bridge/subjects.ts +3 -0
  26. package/src/codegen/constants.ts +18 -0
  27. package/src/codegen/direct-gen.ts +550 -0
  28. package/src/codegen/fingerprint.ts +44 -0
  29. package/src/codegen/hash.ts +25 -0
  30. package/src/codegen/registry-gen.ts +242 -0
  31. package/src/codegen/rust-glue-gen.ts +545 -0
  32. package/src/codegen/schema-model.ts +338 -0
  33. package/src/codegen/ts-ser-gen.ts +221 -0
  34. package/src/codegen/typebox-to-fbs.ts +60 -0
  35. package/src/core/auth.ts +2 -1
  36. package/src/core/client-heartbeat.ts +2 -1
  37. package/src/core/client-reconnect.ts +9 -2
  38. package/src/core/client-state.ts +21 -8
  39. package/src/core/client-wire.ts +10 -11
  40. package/src/core/client.ts +34 -29
  41. package/src/core/groups.ts +3 -0
  42. package/src/core/metrics.ts +7 -3
  43. package/src/core/outbound.ts +12 -5
  44. package/src/core/routing.ts +17 -8
  45. package/src/core/server.ts +108 -34
  46. package/src/core/state.ts +51 -13
  47. package/src/events/clients.ts +156 -0
  48. package/src/events/cluster.ts +732 -0
  49. package/src/events/data.ts +38 -0
  50. package/src/events/emit.ts +127 -0
  51. package/src/events/global.ts +117 -0
  52. package/src/events/groups.ts +118 -0
  53. package/src/events/hub.ts +481 -0
  54. package/src/events/index.ts +61 -0
  55. package/src/events/queue.ts +96 -0
  56. package/src/events/registry.ts +178 -0
  57. package/src/events/types.ts +378 -0
  58. package/src/generated/direct-ser.ts +2 -1
  59. package/src/generated/fbs/backend.fbs +1 -1
  60. package/src/generated/registry.ts +3 -1
  61. package/src/generated/ts-ser.ts +1 -1
  62. package/src/generated/wire-registry.json +1 -0
  63. package/src/native/ffi.ts +85 -28
  64. package/src/schema/index.ts +5 -2
  65. package/src/server.ts +7 -3
  66. package/src/transport/stats.ts +8 -4
  67. package/src/transport/transport.ts +149 -68
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Cluster sync — horizontal scaling for the events layer.
3
+ *
4
+ * Every emit is delivered locally (synchronous, on the WS hot path) and then
5
+ * re-published to a cluster channel so OTHER instances deliver it to their own
6
+ * clients. All cross-instance work is deferred to the offload queue — the emit
7
+ * call never blocks on a broker, and a slow/saturated broker never stalls the
8
+ * socket loop.
9
+ *
10
+ * Self-describing envelope (routing never depends on broker channel syntax):
11
+ * [originLen:u8][origin:utf8][kind:u8][keyLen:u8][key:utf8][nameLen:u8][name:utf8][frame:bytes]
12
+ * kind: 0=broadcast 1=topic 2=group 3=user 4=client 5=presence
13
+ *
14
+ * The origin instance id gives self-delivery dedupe without broker no-local
15
+ * semantics — every instance subscribes ONE channel (`{prefix}.cluster.>` /
16
+ * `{prefix}.cluster.*` on Redis) and drops frames it published itself.
17
+ *
18
+ * Presence works with no shared state: join/leave messages + periodic
19
+ * per-instance heartbeat, with TTL pruning. An optional `ClusterStateStore`
20
+ * (Redis in production) additionally indexes user→clients, user-group
21
+ * membership and client data cluster-wide, so any instance can answer
22
+ * "who is online / in user group X / what data does client Y carry".
23
+ */
24
+ import { createRequire } from "node:module";
25
+ import type { Bindings } from "../bindings/types";
26
+ import type { NatsBridge } from "../bridge/nats";
27
+ import type { TaskQueue } from "./queue";
28
+ import type {
29
+ ClusterStateStore,
30
+ ClusterTransport,
31
+ EmitTargetKind,
32
+ RedisConnectionOptions,
33
+ RemoteClient,
34
+ } from "./types";
35
+
36
+ // ── envelope ────────────────────────────────────────────────────────────────
37
+
38
+ export const CLUSTER_KINDS = ["broadcast", "topic", "group", "user", "client", "presence"] as const;
39
+ export type ClusterKind = (typeof CLUSTER_KINDS)[number];
40
+ export const CLUSTER_KIND_ID: Record<ClusterKind, number> = {
41
+ broadcast: 0,
42
+ topic: 1,
43
+ group: 2,
44
+ user: 3,
45
+ client: 4,
46
+ presence: 5,
47
+ };
48
+
49
+ const enc = new TextEncoder();
50
+ const dec = new TextDecoder();
51
+
52
+ export interface ClusterEnvelope {
53
+ origin: string;
54
+ kind: ClusterKind;
55
+ key: string;
56
+ name: string;
57
+ frame: Uint8Array;
58
+ }
59
+
60
+ export function encodeClusterMessage(
61
+ origin: string,
62
+ kind: ClusterKind,
63
+ key: string,
64
+ name: string,
65
+ frame: Uint8Array,
66
+ ): Uint8Array {
67
+ const o = enc.encode(origin);
68
+ const k = enc.encode(key);
69
+ const n = enc.encode(name);
70
+ // header = [originLen][origin][kind][keyLen][key][nameLen][name] → 4 fixed bytes
71
+ const out = new Uint8Array(4 + o.byteLength + k.byteLength + n.byteLength + frame.byteLength);
72
+ let p = 0;
73
+ out[p] = o.byteLength;
74
+ p++;
75
+ out.set(o, p);
76
+ p += o.byteLength;
77
+ out[p] = CLUSTER_KIND_ID[kind];
78
+ p++;
79
+ out[p] = k.byteLength;
80
+ p++;
81
+ out.set(k, p);
82
+ p += k.byteLength;
83
+ out[p] = n.byteLength;
84
+ p++;
85
+ out.set(n, p);
86
+ p += n.byteLength;
87
+ out.set(frame, p);
88
+ return out;
89
+ }
90
+
91
+ export function decodeClusterMessage(bytes: Uint8Array): ClusterEnvelope | null {
92
+ const read = (at: number): { len: number; str: string; next: number } | null => {
93
+ if (at >= bytes.byteLength) return null;
94
+ const len = bytes[at]!;
95
+ if (at + 1 + len > bytes.byteLength) return null;
96
+ return { len, str: dec.decode(bytes.subarray(at + 1, at + 1 + len)), next: at + 1 + len };
97
+ };
98
+ if (bytes.byteLength < 3) return null;
99
+ const o = read(0);
100
+ if (!o) return null;
101
+ const kindId = bytes[o.next];
102
+ if (kindId === undefined || kindId >= CLUSTER_KINDS.length) return null;
103
+ const k = read(o.next + 1);
104
+ if (!k) return null;
105
+ const n = read(k.next);
106
+ if (!n) return null;
107
+ return {
108
+ origin: o.str,
109
+ kind: CLUSTER_KINDS[kindId]!,
110
+ key: k.str,
111
+ name: n.str,
112
+ frame: bytes.subarray(n.next),
113
+ };
114
+ }
115
+
116
+ // ── subjects ────────────────────────────────────────────────────────────────
117
+
118
+ export interface ClusterSubjects {
119
+ /** the one channel every instance subscribes to */
120
+ all(): string;
121
+ /** publish channel for a target kind (also the external visibility of the subject space) */
122
+ event(kind: EmitTargetKind, key: string | undefined, name: string): string;
123
+ }
124
+
125
+ export function createClusterSubjects(prefix: string): ClusterSubjects {
126
+ const base = `${prefix}.cluster`;
127
+ return {
128
+ all: () => `${base}.>`,
129
+ event: (kind, key, name) =>
130
+ key === undefined ? `${base}.${kind}.${name}` : `${base}.${kind}.${key}.${name}`,
131
+ };
132
+ }
133
+
134
+ // ── presence message payloads (kind = "presence", frame = JSON) ────────────
135
+
136
+ interface PresenceJoin {
137
+ t: "j";
138
+ i: string;
139
+ c: string;
140
+ u?: string;
141
+ at: number;
142
+ }
143
+ interface PresenceLeave {
144
+ t: "l";
145
+ i: string;
146
+ c: string;
147
+ }
148
+ interface PresenceSync {
149
+ t: "s";
150
+ i: string;
151
+ at: number;
152
+ }
153
+ type PresenceMessage = PresenceJoin | PresenceLeave | PresenceSync;
154
+
155
+ function encodePresence(msg: PresenceMessage): Uint8Array {
156
+ return enc.encode(JSON.stringify(msg));
157
+ }
158
+
159
+ function decodePresence(bytes: Uint8Array): PresenceMessage | null {
160
+ try {
161
+ return JSON.parse(dec.decode(bytes)) as PresenceMessage;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ // ── cluster sync ───────────────────────────────────────────────────────────
168
+
169
+ export interface ClusterSyncOptions {
170
+ instanceId: string;
171
+ prefix: string;
172
+ transport: ClusterTransport;
173
+ queue: TaskQueue;
174
+ bindings: Bindings;
175
+ /** optional shared-state store (presence / groups / client data) */
176
+ stateStore?: ClusterStateStore;
177
+ /** remote presence TTL (ms) */
178
+ presenceTtlMs: number;
179
+ /** presence re-announce + prune cadence (ms) */
180
+ heartbeatMs: number;
181
+ /**
182
+ * A valid remote frame must be delivered locally. `frame` is a view of the
183
+ * message buffer — hand it to `ws.send`/replay immediately (Bun copies;
184
+ * replay records an owned copy).
185
+ */
186
+ onRemoteFrame: (kind: EmitTargetKind, key: string, name: string, frame: Uint8Array) => void;
187
+ onError?: (err: Error) => void;
188
+ }
189
+
190
+ export interface ClusterSync {
191
+ /** offloaded cross-instance publish for a local emit (copies the scratch view) */
192
+ publish(kind: EmitTargetKind, key: string | undefined, name: string, frame: Uint8Array): void;
193
+ /** local connection joined/left → presence + state-store index (offloaded) */
194
+ clientJoined(client: { id: string; userId?: string }): void;
195
+ clientLeft(client: { id: string; userId?: string }): void;
196
+ /** local client-group membership change → shared state store (offloaded) */
197
+ clientGroupChanged(group: string, clientId: string, joined: boolean): void;
198
+ /** user-group membership changed → shared state store (offloaded) */
199
+ userGroupChanged(name: string, members: ReadonlySet<string>): void;
200
+ /** cluster-wide clients of a user (state store), [] when none configured */
201
+ clusterUserClients(userId: string): Promise<Array<{ instanceId: string; clientId: string }>>;
202
+ /** cluster-wide client-group members (state store) */
203
+ clusterGroupMembers(name: string): Promise<string[]>;
204
+ /** cluster-wide user-group members (state store) */
205
+ clusterUserGroupMembers(name: string): Promise<string[]>;
206
+ /** write client data to the shared state store (offloaded) */
207
+ setRemoteClientData(clientId: string, json: string): void;
208
+ /** read client data from the shared state store */
209
+ getRemoteClientData(clientId: string): Promise<Record<string, unknown> | undefined>;
210
+ /** connections known on other instances (presence — no shared state needed) */
211
+ remoteClients(): RemoteClient[];
212
+ /** cluster counters (folded into hub.metrics()) */
213
+ stats(): { received: number; droppedSelf: number; errors: number };
214
+ close(): Promise<void>;
215
+ }
216
+
217
+ export function createClusterSync(opts: ClusterSyncOptions): ClusterSync {
218
+ const { instanceId, prefix, transport, queue, bindings, stateStore } = opts;
219
+ const subjects = createClusterSubjects(prefix);
220
+ const remote = new Map<string, RemoteClient>(); // clientId → remote record
221
+ const localUserIds = new Set<string>(); // this instance's users (state-store TTL refresh)
222
+ const userGroupCache = new Map<string, Set<string>>();
223
+ const userGroupStateKey = (name: string): string => `ignex:group-users:${name}`;
224
+ const clientGroupStateKey = (name: string): string => `ignex:group:${name}`;
225
+ const presenceUserKey = (userId: string): string => `ignex:presence:user:${userId}`;
226
+ const presenceInstanceKey = (): string => `ignex:presence:instance:${instanceId}`;
227
+ const clientDataKey = (clientId: string): string => `ignex:client-data:${clientId}`;
228
+ let closed = false;
229
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
230
+ let unsubscribe: (() => void) | null = null;
231
+ let received = 0;
232
+ let droppedSelf = 0;
233
+ let errors = 0;
234
+
235
+ const reportError = (err: unknown): void => {
236
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
237
+ };
238
+
239
+ // state-store ops are fire-and-forget through the offload queue
240
+ const store = (op: () => Promise<unknown>): void => {
241
+ if (!stateStore || closed) return;
242
+ queue.enqueue(() => {
243
+ void op().catch(reportError);
244
+ });
245
+ };
246
+
247
+ const publishToCluster = (
248
+ kind: ClusterKind,
249
+ key: string,
250
+ name: string,
251
+ data: Uint8Array,
252
+ ): void => {
253
+ if (closed) return;
254
+ // exact subject (subscribe side uses the wildcard; the envelope carries routing)
255
+ const subject =
256
+ kind === "presence"
257
+ ? `${prefix}.cluster.presence`
258
+ : subjects.event(kind as EmitTargetKind, key || undefined, name);
259
+ queue.enqueue(() => {
260
+ if (!transport.connected) {
261
+ errors++; // offline broker — frames dropped, visible in metrics
262
+ return;
263
+ }
264
+ try {
265
+ transport.publish(subject, data);
266
+ } catch {
267
+ errors++;
268
+ }
269
+ });
270
+ };
271
+
272
+ // ── presence ──────────────────────────────────────────────────────────
273
+ const announce = (msg: PresenceMessage): void => {
274
+ // presence rides the SAME envelope as event frames (kind = "presence")
275
+ const payload = encodeClusterMessage(instanceId, "presence", "", "", encodePresence(msg));
276
+ publishToCluster("presence", "", "", payload);
277
+ };
278
+
279
+ const refreshRemoteInstance = (instance: string, at: number): void => {
280
+ for (const r of remote.values()) {
281
+ if (r.instanceId === instance) r.lastSeen = at;
282
+ }
283
+ };
284
+
285
+ const handlePresence = (bytes: Uint8Array): void => {
286
+ const msg = decodePresence(bytes);
287
+ if (!msg) return;
288
+ if (msg.t === "j") {
289
+ if (msg.i === instanceId) return;
290
+ remote.set(msg.c, {
291
+ clientId: msg.c,
292
+ instanceId: msg.i,
293
+ ...(msg.u !== undefined ? { userId: msg.u } : {}),
294
+ lastSeen: msg.at,
295
+ });
296
+ return;
297
+ }
298
+ if (msg.t === "l") {
299
+ const r = remote.get(msg.c);
300
+ if (r && r.instanceId === msg.i) remote.delete(msg.c);
301
+ return;
302
+ }
303
+ if (msg.t === "s" && msg.i !== instanceId) refreshRemoteInstance(msg.i, msg.at);
304
+ };
305
+
306
+ const prune = (): void => {
307
+ const ttl = opts.presenceTtlMs;
308
+ const now = Date.now();
309
+ for (const [clientId, r] of remote) {
310
+ if (now - r.lastSeen > ttl) remote.delete(clientId);
311
+ }
312
+ };
313
+
314
+ const heartbeat = (): void => {
315
+ if (closed) return;
316
+ prune();
317
+ announce({ t: "s", i: instanceId, at: Date.now() });
318
+ if (stateStore) {
319
+ store(() => stateStore!.expire(presenceInstanceKey(), opts.presenceTtlMs));
320
+ for (const userId of localUserIds)
321
+ store(() => stateStore!.expire(presenceUserKey(userId), opts.presenceTtlMs));
322
+ }
323
+ };
324
+
325
+ heartbeatTimer = setInterval(heartbeat, opts.heartbeatMs);
326
+
327
+ // ── inbound ───────────────────────────────────────────────────────────
328
+ const handleMessage = (data: Uint8Array): void => {
329
+ const msg = decodeClusterMessage(data);
330
+ if (!msg) {
331
+ errors++; // undecodable envelope — counted once in stats()
332
+ return;
333
+ }
334
+ if (msg.origin === instanceId) {
335
+ droppedSelf++; // self-publish — already delivered locally
336
+ return;
337
+ }
338
+ received++;
339
+ if (msg.kind === "presence") {
340
+ handlePresence(msg.frame);
341
+ return;
342
+ }
343
+ const header = bindings.readFrameHeader(msg.frame);
344
+ if (!header) {
345
+ errors++;
346
+ return;
347
+ }
348
+ opts.onRemoteFrame(msg.kind as EmitTargetKind, msg.key, header.name, msg.frame);
349
+ };
350
+
351
+ if (!closed) {
352
+ unsubscribe = transport.subscribe(subjects.all(), handleMessage);
353
+ }
354
+
355
+ return {
356
+ publish(kind, key, name, frame) {
357
+ publishToCluster(
358
+ kind,
359
+ key ?? "",
360
+ name,
361
+ encodeClusterMessage(instanceId, kind, key ?? "", name, frame),
362
+ );
363
+ },
364
+ clientJoined(client) {
365
+ announce({
366
+ t: "j",
367
+ i: instanceId,
368
+ c: client.id,
369
+ ...(client.userId !== undefined ? { u: client.userId } : {}),
370
+ at: Date.now(),
371
+ });
372
+ if (client.userId) localUserIds.add(client.userId);
373
+ if (!stateStore) return;
374
+ store(async () => {
375
+ await stateStore!.sadd(presenceInstanceKey(), client.id);
376
+ if (client.userId) {
377
+ await stateStore!.sadd(presenceUserKey(client.userId), `${instanceId}:${client.id}`);
378
+ await stateStore!.expire(presenceUserKey(client.userId), opts.presenceTtlMs);
379
+ }
380
+ });
381
+ },
382
+ clientLeft(client) {
383
+ announce({ t: "l", i: instanceId, c: client.id });
384
+ if (!stateStore) return;
385
+ store(async () => {
386
+ await stateStore!.srem(presenceInstanceKey(), client.id);
387
+ if (client.userId)
388
+ await stateStore!.srem(presenceUserKey(client.userId), `${instanceId}:${client.id}`);
389
+ });
390
+ },
391
+ userGroupChanged(name, members) {
392
+ if (!stateStore) return;
393
+ const prev = userGroupCache.get(name) ?? new Set<string>();
394
+ const key = userGroupStateKey(name);
395
+ for (const m of members) {
396
+ if (!prev.has(m)) store(() => stateStore!.sadd(key, m));
397
+ }
398
+ for (const m of prev) {
399
+ if (!members.has(m)) store(() => stateStore!.srem(key, m));
400
+ }
401
+ userGroupCache.set(name, new Set(members));
402
+ },
403
+ clientGroupChanged(group, clientId, joined) {
404
+ if (!stateStore) return;
405
+ const key = clientGroupStateKey(group);
406
+ store(() => (joined ? stateStore!.sadd(key, clientId) : stateStore!.srem(key, clientId)));
407
+ },
408
+ async clusterUserClients(userId) {
409
+ if (!stateStore) return [];
410
+ try {
411
+ const members = await stateStore.smembers(presenceUserKey(userId));
412
+ const out: Array<{ instanceId: string; clientId: string }> = [];
413
+ for (const m of members) {
414
+ const idx = m.indexOf(":");
415
+ if (idx > 0) out.push({ instanceId: m.slice(0, idx), clientId: m.slice(idx + 1) });
416
+ }
417
+ return out;
418
+ } catch (err) {
419
+ reportError(err);
420
+ return [];
421
+ }
422
+ },
423
+ async clusterGroupMembers(name) {
424
+ if (!stateStore) return [];
425
+ try {
426
+ return await stateStore.smembers(clientGroupStateKey(name));
427
+ } catch (err) {
428
+ reportError(err);
429
+ return [];
430
+ }
431
+ },
432
+ async clusterUserGroupMembers(name) {
433
+ if (!stateStore) return [];
434
+ try {
435
+ return await stateStore.smembers(userGroupStateKey(name));
436
+ } catch (err) {
437
+ reportError(err);
438
+ return [];
439
+ }
440
+ },
441
+ setRemoteClientData(clientId, json) {
442
+ if (!stateStore) return;
443
+ store(() => stateStore!.set(clientDataKey(clientId), json, opts.presenceTtlMs));
444
+ },
445
+ async getRemoteClientData(clientId) {
446
+ if (!stateStore) return undefined;
447
+ try {
448
+ const raw = await stateStore.get(clientDataKey(clientId));
449
+ if (raw == null) return undefined;
450
+ return JSON.parse(raw) as Record<string, unknown>;
451
+ } catch (err) {
452
+ reportError(err);
453
+ return undefined;
454
+ }
455
+ },
456
+ remoteClients() {
457
+ return [...remote.values()];
458
+ },
459
+ stats() {
460
+ return { received, droppedSelf, errors };
461
+ },
462
+ async close() {
463
+ closed = true;
464
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
465
+ heartbeatTimer = null;
466
+ if (unsubscribe) {
467
+ try {
468
+ unsubscribe();
469
+ } catch {
470
+ // already unsubscribed
471
+ }
472
+ unsubscribe = null;
473
+ }
474
+ await queue.drain();
475
+ await transport.close();
476
+ },
477
+ };
478
+ }
479
+
480
+ // ── transport adapters ─────────────────────────────────────────────────────
481
+
482
+ /** Wrap the server's (or a dedicated) NATS bridge as a cluster transport. */
483
+ export function createNatsClusterTransport(bridge: NatsBridge): ClusterTransport {
484
+ return {
485
+ get connected(): boolean {
486
+ return bridge.status === "connected";
487
+ },
488
+ publish(subject, data) {
489
+ bridge.publish(subject, data); // copies bytes + counts bridge stats
490
+ },
491
+ subscribe(subject, cb) {
492
+ return bridge.subscribeRaw(subject, cb);
493
+ },
494
+ close() {
495
+ return Promise.resolve(); // the owning bridge decides its own lifecycle
496
+ },
497
+ };
498
+ }
499
+
500
+ const nodeRequire = createRequire(import.meta.url);
501
+
502
+ /** Synchronous optional loader for `ioredis` (never bundled). */
503
+ function loadRedis(): unknown {
504
+ try {
505
+ return nodeRequire("ioredis");
506
+ } catch {
507
+ throw new Error(
508
+ "ignex events cluster: Redis configured but 'ioredis' is not installed — run `bun add ioredis` (or pass a custom cluster.transport / cluster.state)",
509
+ );
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Redis pub/sub cluster transport (lazy `ioredis`, optional peer dependency).
515
+ * Binary-safe (Buffer replies via `returnBuffers`), pattern-subscribes the
516
+ * cluster channel (`{prefix}.cluster.*`), fire-and-forget publishes — never
517
+ * blocks the caller. Async publish failures are reported to `onError`.
518
+ */
519
+ export function createRedisClusterTransport(
520
+ opts: RedisConnectionOptions,
521
+ onError?: (err: Error) => void,
522
+ ): ClusterTransport {
523
+ const Redis = loadRedis() as new (
524
+ ...args: unknown[]
525
+ ) => {
526
+ publish(channel: string, data: Buffer): Promise<unknown>;
527
+ subscribe(...channels: string[]): Promise<unknown>;
528
+ psubscribe(...patterns: string[]): Promise<unknown>;
529
+ unsubscribe(...channels: string[]): Promise<unknown>;
530
+ punsubscribe(...patterns: string[]): Promise<unknown>;
531
+ on(event: string, cb: (...args: unknown[]) => void): unknown;
532
+ quit(): Promise<unknown>;
533
+ readonly status: string;
534
+ };
535
+ const listeners = new Map<string, Set<(data: Uint8Array) => void>>();
536
+ const patternListeners = new Map<string, Set<(data: Uint8Array) => void>>();
537
+ let closed = false;
538
+ const conn = (): { url?: string; options?: Record<string, unknown> } =>
539
+ typeof opts === "string" ? { url: opts } : { options: opts };
540
+ const make = (): InstanceType<typeof Redis> => {
541
+ const c = conn();
542
+ return c.url
543
+ ? new Redis(c.url, { returnBuffers: true })
544
+ : new Redis({ ...c.options, returnBuffers: true });
545
+ };
546
+ const pub = make();
547
+ const sub = make();
548
+
549
+ const toBytes = (msg: unknown): Uint8Array => {
550
+ const b = msg instanceof Uint8Array ? msg : Buffer.from(String(msg));
551
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
552
+ };
553
+
554
+ sub.on("message", (channel: unknown, msg: unknown) => {
555
+ const cbs = listeners.get(String(channel));
556
+ if (!cbs) return;
557
+ const data = toBytes(msg);
558
+ for (const cb of cbs) cb(data);
559
+ });
560
+ sub.on("pmessage", (_pattern: unknown, channel: unknown, msg: unknown) => {
561
+ const cbs = patternListeners.get(String(channel));
562
+ if (!cbs) return;
563
+ const data = toBytes(msg);
564
+ for (const cb of cbs) cb(data);
565
+ });
566
+
567
+ return {
568
+ get connected(): boolean {
569
+ return !closed && pub.status === "ready" && sub.status === "ready";
570
+ },
571
+ publish(subject, data) {
572
+ if (closed) return;
573
+ void pub
574
+ .publish(subject, Buffer.from(data.buffer, data.byteOffset, data.byteLength))
575
+ .catch((err: unknown) => onError?.(err instanceof Error ? err : new Error(String(err))));
576
+ },
577
+ subscribe(subject, cb) {
578
+ if (subject.includes(">")) {
579
+ // NATS-style wildcard → Redis pattern subscription
580
+ const pattern = subject.replace(/\.>+$/, ".*");
581
+ let set = patternListeners.get(pattern);
582
+ if (!set) {
583
+ set = new Set();
584
+ patternListeners.set(pattern, set);
585
+ void sub.psubscribe(pattern);
586
+ }
587
+ set.add(cb);
588
+ return () => {
589
+ const s = patternListeners.get(pattern);
590
+ if (!s) return;
591
+ s.delete(cb);
592
+ if (s.size === 0) {
593
+ patternListeners.delete(pattern);
594
+ void sub.punsubscribe(pattern);
595
+ }
596
+ };
597
+ }
598
+ let set = listeners.get(subject);
599
+ if (!set) {
600
+ set = new Set();
601
+ listeners.set(subject, set);
602
+ void sub.subscribe(subject);
603
+ }
604
+ set.add(cb);
605
+ return () => {
606
+ const s = listeners.get(subject);
607
+ if (!s) return;
608
+ s.delete(cb);
609
+ if (s.size === 0) {
610
+ listeners.delete(subject);
611
+ void sub.unsubscribe(subject);
612
+ }
613
+ };
614
+ },
615
+ async close() {
616
+ closed = true;
617
+ listeners.clear();
618
+ patternListeners.clear();
619
+ await Promise.allSettled([pub.quit(), sub.quit()]);
620
+ },
621
+ };
622
+ }
623
+
624
+ // ── state-store adapters ────────────────────────────────────────────────────
625
+
626
+ /**
627
+ * In-memory state store — per-process default. Pass a SHARED `Map` to simulate
628
+ * a cross-instance store in tests / single-process multi-instance setups.
629
+ */
630
+ export function createMemoryStateStore(
631
+ shared?: Map<string, unknown>,
632
+ ): ClusterStateStore & { close(): Promise<void> } {
633
+ const data = shared ?? new Map<string, unknown>();
634
+ const ttlKey = (key: string): string => `__ttl:${key}`;
635
+ const alive = (key: string): boolean => {
636
+ const ttl = data.get(ttlKey(key));
637
+ if (ttl === undefined) return true;
638
+ if (Date.now() > Number(ttl)) {
639
+ data.delete(key);
640
+ data.delete(ttlKey(key));
641
+ return false;
642
+ }
643
+ return true;
644
+ };
645
+
646
+ return {
647
+ async get(key) {
648
+ if (!alive(key)) return null;
649
+ const v = data.get(key);
650
+ return typeof v === "string" ? v : null;
651
+ },
652
+ async set(key, value, ttlMs) {
653
+ data.set(key, value);
654
+ if (ttlMs !== undefined) data.set(ttlKey(key), Date.now() + ttlMs);
655
+ },
656
+ async del(key) {
657
+ data.delete(key);
658
+ data.delete(ttlKey(key));
659
+ },
660
+ async sadd(key, member) {
661
+ let s = data.get(key);
662
+ if (!(s instanceof Set)) {
663
+ s = new Set<string>();
664
+ data.set(key, s);
665
+ }
666
+ (s as Set<string>).add(member);
667
+ },
668
+ async srem(key, member) {
669
+ const s = data.get(key);
670
+ if (s instanceof Set) (s as Set<string>).delete(member);
671
+ },
672
+ async smembers(key) {
673
+ if (!alive(key)) return [];
674
+ const s = data.get(key);
675
+ return s instanceof Set ? [...(s as Set<string>)] : [];
676
+ },
677
+ async expire(key, ttlMs) {
678
+ data.set(ttlKey(key), Date.now() + ttlMs);
679
+ },
680
+ async close() {
681
+ data.clear();
682
+ },
683
+ };
684
+ }
685
+
686
+ /** Redis state store (lazy `ioredis`). Called from the offload queue. */
687
+ export function createRedisStateStore(
688
+ opts: RedisConnectionOptions = {},
689
+ ): ClusterStateStore & { close(): Promise<void> } {
690
+ const Redis = loadRedis() as new (
691
+ ...args: unknown[]
692
+ ) => {
693
+ get(key: string): Promise<unknown>;
694
+ set(...args: unknown[]): Promise<unknown>;
695
+ del(...keys: string[]): Promise<unknown>;
696
+ sadd(key: string, member: string): Promise<unknown>;
697
+ srem(key: string, member: string): Promise<unknown>;
698
+ smembers(key: string): Promise<unknown>;
699
+ expire(key: string, seconds: number): Promise<unknown>;
700
+ quit(): Promise<unknown>;
701
+ };
702
+ const r = new Redis(opts);
703
+ return {
704
+ async get(key) {
705
+ const v = await r.get(key);
706
+ return v == null ? null : String(v);
707
+ },
708
+ async set(key, value, ttlMs) {
709
+ if (ttlMs !== undefined) await r.set(key, value, "PX", ttlMs);
710
+ else await r.set(key, value);
711
+ },
712
+ async del(key) {
713
+ await r.del(key);
714
+ },
715
+ async sadd(key, member) {
716
+ await r.sadd(key, member);
717
+ },
718
+ async srem(key, member) {
719
+ await r.srem(key, member);
720
+ },
721
+ async smembers(key) {
722
+ const v = await r.smembers(key);
723
+ return Array.isArray(v) ? v.map(String) : [];
724
+ },
725
+ async expire(key, ttlMs) {
726
+ await r.expire(key, Math.max(1, Math.ceil(ttlMs / 1000)));
727
+ },
728
+ async close() {
729
+ await r.quit();
730
+ },
731
+ };
732
+ }