@ignex/nova 0.1.1 → 0.1.5

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 (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -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 +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Cluster sync — the composition of cross-instance messaging.
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
+ * ROUTED DELIVERY: targeted emits (kind client/user) can be published ONLY to
11
+ * the instance(s) that own the target connection(s) (per-instance subjects,
12
+ * via {@link ClusterSync.route}) instead of the full-mesh wildcard.
13
+ *
14
+ * Presence works with no shared state: join/leave messages + periodic
15
+ * per-instance heartbeat with TTL pruning (see `presence.ts` /
16
+ * `presence-table.ts`). An optional `ClusterStateStore` additionally indexes
17
+ * user→clients and group membership cluster-wide.
18
+ *
19
+ * Composes the pure pieces in this folder: `envelope`, `subjects`,
20
+ * `presence`, `presence-table`, `dedupe`, `keys`.
21
+ */
22
+ import type { Bindings } from "../../bindings/types";
23
+ import type { EmitTargetKind, ClusterStateStore, ClusterTransport, RemoteClient } from "../types";
24
+ import type { TaskQueue } from "../queue";
25
+ import { createDedupeWindow } from "./dedupe";
26
+ import {
27
+ decodeClusterMessage,
28
+ encodeClusterMessage,
29
+ type ClusterEnvelope,
30
+ } from "./envelope";
31
+ import {
32
+ clientDataKey,
33
+ clientGroupStateKey,
34
+ parsePresenceMember,
35
+ presenceInstanceKey,
36
+ presenceUserKey,
37
+ userGroupStateKey,
38
+ } from "./keys";
39
+ import { decodePresence, encodePresence, type PresenceMessage } from "./presence";
40
+ import { createPresenceTable, type PresenceTable } from "./presence-table";
41
+ import { createClusterSubjects, type ClusterSubjects } from "./subjects";
42
+ import type { ClusterKind } from "./kinds";
43
+
44
+ export interface ClusterSyncOptions {
45
+ instanceId: string;
46
+ prefix: string;
47
+ transport: ClusterTransport;
48
+ queue: TaskQueue;
49
+ bindings: Bindings;
50
+ /** optional shared-state store (presence / groups / client data) */
51
+ stateStore?: ClusterStateStore;
52
+ /** remote presence TTL (ms) */
53
+ presenceTtlMs: number;
54
+ /** presence re-announce + prune cadence (ms) */
55
+ heartbeatMs: number;
56
+ /**
57
+ * Broker-level redelivery dedupe window (messages tracked by id), default
58
+ * 4096 entries. Protects against double-delivery from durable brokers.
59
+ */
60
+ dedupeWindow?: number;
61
+ /**
62
+ * A valid remote frame must be delivered locally. `frame` is a view of the
63
+ * message buffer — hand it to `ws.send`/replay immediately (Bun copies;
64
+ * replay records an owned copy).
65
+ */
66
+ onRemoteFrame: (
67
+ kind: EmitTargetKind,
68
+ key: string,
69
+ name: string,
70
+ frame: Uint8Array,
71
+ meta: { readonly msgId: string; readonly traceId: string },
72
+ ) => void;
73
+ onError?: (err: Error) => void;
74
+ }
75
+
76
+ /** Per-message metadata flowing through the cluster path. */
77
+ export interface ClusterMsgMeta {
78
+ /** unique message id (generated per emit; used for redelivery dedupe) */
79
+ msgId?: string;
80
+ /** cross-instance trace id (propagated into remote EventContexts) */
81
+ traceId?: string;
82
+ }
83
+
84
+ export interface ClusterSync {
85
+ /**
86
+ * Offloaded cross-instance publish for a local emit — full-mesh kinds go to
87
+ * the wildcard subject space; copies the scratch view immediately.
88
+ */
89
+ publish(kind: EmitTargetKind, key: string | undefined, name: string, frame: Uint8Array, meta?: ClusterMsgMeta): void;
90
+ /**
91
+ * ROUTED targeted delivery: publish ONLY to the given instances'
92
+ * per-instance subjects (envelope keeps the semantic kind/key so receivers
93
+ * deliver locally without re-broadcasting). Falls back to nothing when the
94
+ * list is empty — callers decide their own fallback.
95
+ */
96
+ route(instances: readonly string[], kind: EmitTargetKind, key: string, name: string, frame: Uint8Array, meta?: ClusterMsgMeta): void;
97
+ /** instance ids that currently hold `clientId` ([] = unknown → caller falls back) */
98
+ instancesForClient(clientId: string): string[];
99
+ /** instance ids that currently hold a connection of `userId` */
100
+ instancesForUser(userId: string): string[];
101
+ /** every other instance heard from recently (presence heartbeats) */
102
+ knownInstances(): string[];
103
+ /** local connection joined/left → presence + state-store index (offloaded) */
104
+ clientJoined(client: { id: string; userId?: string }): void;
105
+ clientLeft(client: { id: string; userId?: string }): void;
106
+ /** local client-group membership change → shared state store (offloaded) */
107
+ clientGroupChanged(group: string, clientId: string, joined: boolean): void;
108
+ /** user-group membership changed → shared state store (offloaded) */
109
+ userGroupChanged(name: string, members: ReadonlySet<string>): void;
110
+ /** cluster-wide clients of a user (state store), [] when none configured */
111
+ clusterUserClients(userId: string): Promise<Array<{ instanceId: string; clientId: string }>>;
112
+ /** cluster-wide client-group members (state store) */
113
+ clusterGroupMembers(name: string): Promise<string[]>;
114
+ /** cluster-wide user-group members (state store) */
115
+ clusterUserGroupMembers(name: string): Promise<string[]>;
116
+ /** write client data to the shared state store (offloaded) */
117
+ setRemoteClientData(clientId: string, json: string): void;
118
+ /** read client data from the shared state store */
119
+ getRemoteClientData(clientId: string): Promise<Record<string, unknown> | undefined>;
120
+ /** connections known on other instances (presence — no shared state needed) */
121
+ remoteClients(): RemoteClient[];
122
+ /** cluster counters (folded into hub.metrics()) */
123
+ stats(): { received: number; droppedSelf: number; droppedDupe: number; errors: number };
124
+ close(): Promise<void>;
125
+ }
126
+
127
+ /** Mutable counters folded into `stats()` (one object, incremented in place). */
128
+ interface SyncCounters {
129
+ received: number;
130
+ droppedSelf: number;
131
+ droppedDupe: number;
132
+ errors: number;
133
+ }
134
+
135
+ /**
136
+ * Apply one decoded inbound envelope, preserving the canonical gate order:
137
+ * self-drop → liveness touch → redelivery dedupe → accepted → deliver.
138
+ * Mutates only the presence table + counters passed in.
139
+ */
140
+ function processInbound(
141
+ msg: ClusterEnvelope,
142
+ deps: {
143
+ instanceId: string;
144
+ presence: PresenceTable;
145
+ counters: SyncCounters;
146
+ /** record-and-test dedupe (true = already processed → drop) */
147
+ markSeen(id: string): boolean;
148
+ onPresence: (frame: Uint8Array) => void;
149
+ onRemoteFrame: ClusterSyncOptions["onRemoteFrame"];
150
+ readFrameHeader: Bindings["readFrameHeader"];
151
+ },
152
+ ): void {
153
+ const { presence, counters } = deps;
154
+ if (msg.origin === deps.instanceId) {
155
+ counters.droppedSelf++; // self-publish — already delivered locally
156
+ return;
157
+ }
158
+ presence.touch(msg.origin, Date.now());
159
+ if (deps.markSeen(msg.msgId)) {
160
+ counters.droppedDupe++; // broker redelivery of an already-processed message
161
+ return;
162
+ }
163
+ counters.received++;
164
+ if (msg.kind === "presence") {
165
+ deps.onPresence(msg.frame);
166
+ return;
167
+ }
168
+ const header = deps.readFrameHeader(msg.frame);
169
+ if (!header) {
170
+ counters.errors++;
171
+ return;
172
+ }
173
+ deps.onRemoteFrame(msg.kind as EmitTargetKind, msg.key, header.name, msg.frame, {
174
+ msgId: msg.msgId,
175
+ traceId: msg.traceId,
176
+ });
177
+ }
178
+
179
+ export function createClusterSync(opts: ClusterSyncOptions): ClusterSync {
180
+ const { instanceId, transport, queue, bindings, stateStore } = opts;
181
+ const subjects: ClusterSubjects = createClusterSubjects(opts.prefix);
182
+ const presence = createPresenceTable();
183
+ const dedupe = createDedupeWindow(opts.dedupeWindow ?? 4096);
184
+ // users with at least one LOCAL connection (state-store TTL refresh)
185
+ const localUserIds = new Set<string>();
186
+ // last-known user-group membership (diff → minimal sadd/srem traffic)
187
+ const userGroupCache = new Map<string, Set<string>>();
188
+
189
+ let closed = false;
190
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
191
+ let unsubscribe: (() => void) | null = null;
192
+
193
+ const counters: SyncCounters = { received: 0, droppedSelf: 0, droppedDupe: 0, errors: 0 };
194
+
195
+ const reportError = (err: unknown): void => {
196
+ opts.onError?.(err instanceof Error ? err : new Error(String(err)));
197
+ };
198
+
199
+ // ── offloaded shared-state ops ────────────────────────────────────────
200
+
201
+ /** Fire-and-forget state-store op through the offload queue (no-op when unset). */
202
+ const store = (op: () => Promise<unknown>): void => {
203
+ if (!stateStore || closed) return;
204
+ queue.enqueue(() => {
205
+ void op().catch(reportError);
206
+ });
207
+ };
208
+
209
+ /** TTL-refresh every presence key owned by this instance (heartbeat tick). */
210
+ const refreshStoreTtls = (): void => {
211
+ if (!stateStore) return;
212
+ store(() => stateStore!.expire(presenceInstanceKey(instanceId), opts.presenceTtlMs));
213
+ for (const userId of localUserIds)
214
+ store(() => stateStore!.expire(presenceUserKey(userId), opts.presenceTtlMs));
215
+ };
216
+
217
+ /** Diff `members` against the cache → minimal sadd/srem ops (offloaded). */
218
+ const syncUserGroup = (name: string, members: ReadonlySet<string>): void => {
219
+ if (!stateStore) return;
220
+ const prev = userGroupCache.get(name) ?? new Set<string>();
221
+ const key = userGroupStateKey(name);
222
+ for (const m of members) {
223
+ if (!prev.has(m)) store(() => stateStore!.sadd(key, m));
224
+ }
225
+ for (const m of prev) {
226
+ if (!members.has(m)) store(() => stateStore!.srem(key, m));
227
+ }
228
+ userGroupCache.set(name, new Set(members));
229
+ };
230
+
231
+ // ── publishing ────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Enqueue an encoded message for delivery. The exact subject is computed
235
+ * here (subscribe side uses the wildcard; the envelope carries routing).
236
+ */
237
+ const publishToCluster = (
238
+ kind: ClusterKind,
239
+ key: string,
240
+ name: string,
241
+ data: Uint8Array,
242
+ subjectOverride?: string,
243
+ ): void => {
244
+ if (closed) return;
245
+ const subject =
246
+ subjectOverride ??
247
+ (kind === "presence"
248
+ ? `${opts.prefix}.cluster.presence`
249
+ : subjects.event(kind as EmitTargetKind, key || undefined, name));
250
+ queue.enqueue(() => {
251
+ if (!transport.connected) {
252
+ counters.errors++; // offline broker — frames dropped, visible in metrics
253
+ return;
254
+ }
255
+ try {
256
+ transport.publish(subject, data);
257
+ } catch {
258
+ counters.errors++;
259
+ }
260
+ });
261
+ };
262
+
263
+ /** Announce a presence message on the shared presence channel. */
264
+ const announce = (msg: PresenceMessage): void => {
265
+ // presence rides the SAME envelope as event frames (kind = "presence")
266
+ const payload = encodeClusterMessage(
267
+ instanceId,
268
+ "presence",
269
+ "",
270
+ "",
271
+ encodePresence(msg),
272
+ );
273
+ publishToCluster("presence", "", "", payload);
274
+ };
275
+
276
+ // ── presence tick ─────────────────────────────────────────────────────
277
+
278
+ const heartbeat = (): void => {
279
+ if (closed) return;
280
+ presence.prune(opts.presenceTtlMs);
281
+ announce({ t: "s", i: instanceId, at: Date.now() });
282
+ refreshStoreTtls();
283
+ };
284
+ heartbeatTimer = setInterval(heartbeat, opts.heartbeatMs);
285
+
286
+ // ── inbound pipeline ──────────────────────────────────────────────────
287
+
288
+ /** Apply a decoded presence payload to the local table. */
289
+ const handlePresencePayload = (bytes: Uint8Array): void => {
290
+ const msg = decodePresence(bytes);
291
+ if (!msg || msg.i === instanceId) return;
292
+ if (msg.t === "j") presence.join(msg.c, msg.i, msg.u, msg.at);
293
+ else if (msg.t === "l") presence.leave(msg.c, msg.i);
294
+ else if (msg.t === "s") presence.refreshInstance(msg.i, msg.at);
295
+ };
296
+
297
+ const handleMessage = (data: Uint8Array): void => {
298
+ const msg = decodeClusterMessage(data);
299
+ if (!msg) {
300
+ counters.errors++; // undecodable / foreign-envelope — counted once in stats()
301
+ return;
302
+ }
303
+ processInbound(msg, {
304
+ instanceId,
305
+ presence,
306
+ counters,
307
+ markSeen: dedupe.markSeen,
308
+ onPresence: handlePresencePayload,
309
+ onRemoteFrame: opts.onRemoteFrame,
310
+ readFrameHeader: bindings.readFrameHeader,
311
+ });
312
+ };
313
+
314
+ // wildcard (full-mesh kinds + presence) AND our own per-instance subject
315
+ // (routed targeted delivery from peers)
316
+ const unsubInstance = transport.subscribe(subjects.instance(instanceId), handleMessage);
317
+ const unsubAll = transport.subscribe(subjects.all(), handleMessage);
318
+ unsubscribe = () => {
319
+ unsubAll();
320
+ unsubInstance();
321
+ };
322
+
323
+ // ── public surface ────────────────────────────────────────────────────
324
+ return {
325
+ publish(kind, key, name, frame, meta) {
326
+ publishToCluster(
327
+ kind,
328
+ key ?? "",
329
+ name,
330
+ encodeClusterMessage(instanceId, kind, key ?? "", name, frame, meta?.msgId ?? "", meta?.traceId ?? ""),
331
+ );
332
+ },
333
+
334
+ route(instances, kind, key, name, frame, meta) {
335
+ if (closed || instances.length === 0) return;
336
+ for (const target of instances) {
337
+ if (target === instanceId) continue; // local delivery already happened
338
+ publishToCluster(
339
+ kind,
340
+ key,
341
+ name,
342
+ encodeClusterMessage(instanceId, kind, key, name, frame, meta?.msgId ?? "", meta?.traceId ?? ""),
343
+ subjects.instance(target),
344
+ );
345
+ }
346
+ },
347
+
348
+ instancesForClient(clientId) {
349
+ return presence.instancesForClient(clientId);
350
+ },
351
+
352
+ instancesForUser(userId) {
353
+ return presence.instancesForUser(userId);
354
+ },
355
+
356
+ knownInstances() {
357
+ return presence.knownInstances();
358
+ },
359
+
360
+ clientJoined(client) {
361
+ announce({
362
+ t: "j",
363
+ i: instanceId,
364
+ c: client.id,
365
+ ...(client.userId !== undefined ? { u: client.userId } : {}),
366
+ at: Date.now(),
367
+ });
368
+ if (client.userId) localUserIds.add(client.userId);
369
+ if (!stateStore) return;
370
+ store(async () => {
371
+ await stateStore!.sadd(presenceInstanceKey(instanceId), client.id);
372
+ if (client.userId) {
373
+ await stateStore!.sadd(presenceUserKey(client.userId), `${instanceId}:${client.id}`);
374
+ await stateStore!.expire(presenceUserKey(client.userId), opts.presenceTtlMs);
375
+ }
376
+ });
377
+ },
378
+
379
+ clientLeft(client) {
380
+ announce({ t: "l", i: instanceId, c: client.id });
381
+ if (!stateStore) return;
382
+ store(async () => {
383
+ await stateStore!.srem(presenceInstanceKey(instanceId), client.id);
384
+ if (client.userId)
385
+ await stateStore!.srem(presenceUserKey(client.userId), `${instanceId}:${client.id}`);
386
+ });
387
+ },
388
+
389
+ userGroupChanged(name, members) {
390
+ syncUserGroup(name, members);
391
+ },
392
+
393
+ clientGroupChanged(group, clientId, joined) {
394
+ if (!stateStore) return;
395
+ const key = clientGroupStateKey(group);
396
+ store(() => (joined ? stateStore!.sadd(key, clientId) : stateStore!.srem(key, clientId)));
397
+ },
398
+
399
+ async clusterUserClients(userId) {
400
+ if (!stateStore) return [];
401
+ try {
402
+ const members = await stateStore.smembers(presenceUserKey(userId));
403
+ const out: Array<{ instanceId: string; clientId: string }> = [];
404
+ for (const m of members) {
405
+ const parsed = parsePresenceMember(m);
406
+ if (parsed) out.push(parsed);
407
+ }
408
+ return out;
409
+ } catch (err) {
410
+ reportError(err);
411
+ return [];
412
+ }
413
+ },
414
+
415
+ async clusterGroupMembers(name) {
416
+ if (!stateStore) return [];
417
+ try {
418
+ return await stateStore.smembers(clientGroupStateKey(name));
419
+ } catch (err) {
420
+ reportError(err);
421
+ return [];
422
+ }
423
+ },
424
+
425
+ async clusterUserGroupMembers(name) {
426
+ if (!stateStore) return [];
427
+ try {
428
+ return await stateStore.smembers(userGroupStateKey(name));
429
+ } catch (err) {
430
+ reportError(err);
431
+ return [];
432
+ }
433
+ },
434
+
435
+ setRemoteClientData(clientId, json) {
436
+ if (!stateStore) return;
437
+ store(() => stateStore!.set(clientDataKey(clientId), json, opts.presenceTtlMs));
438
+ },
439
+
440
+ async getRemoteClientData(clientId) {
441
+ if (!stateStore) return undefined;
442
+ try {
443
+ const raw = await stateStore.get(clientDataKey(clientId));
444
+ if (raw == null) return undefined;
445
+ return JSON.parse(raw) as Record<string, unknown>;
446
+ } catch (err) {
447
+ reportError(err);
448
+ return undefined;
449
+ }
450
+ },
451
+
452
+ remoteClients() {
453
+ return presence.remoteClients();
454
+ },
455
+
456
+ stats() {
457
+ return { ...counters };
458
+ },
459
+
460
+ async close() {
461
+ closed = true;
462
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
463
+ heartbeatTimer = null;
464
+ if (unsubscribe) {
465
+ try {
466
+ unsubscribe();
467
+ } catch {
468
+ // already unsubscribed
469
+ }
470
+ unsubscribe = null;
471
+ }
472
+ await queue.drain();
473
+ await transport.close();
474
+ },
475
+ };
476
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * NATS cluster transport — wraps the server's (or a dedicated) NATS bridge as
3
+ * a {@link ClusterTransport}. The owning bridge decides its own lifecycle;
4
+ * this adapter only adapts the surface.
5
+ */
6
+ import type { NatsBridge } from "../../bridge/nats";
7
+ import type { ClusterTransport } from "../types";
8
+
9
+ export function createNatsClusterTransport(bridge: NatsBridge): ClusterTransport {
10
+ return {
11
+ get connected(): boolean {
12
+ return bridge.status === "connected";
13
+ },
14
+ publish(subject, data) {
15
+ bridge.publish(subject, data); // copies bytes + counts bridge stats
16
+ },
17
+ subscribe(subject, cb) {
18
+ return bridge.subscribeRaw(subject, cb);
19
+ },
20
+ close() {
21
+ return Promise.resolve(); // the owning bridge decides its own lifecycle
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Redis pub/sub cluster transport (lazy `ioredis`, optional peer dependency).
3
+ * Binary-safe (Buffer replies via `returnBuffers`), pattern-subscribes the
4
+ * cluster channel (`{prefix}.cluster.*`), fire-and-forget publishes — never
5
+ * blocks the caller. Async publish failures are reported to `onError`.
6
+ *
7
+ * NATS-style wildcards (`foo.>`) are translated to Redis pattern
8
+ * subscriptions (`foo.*`) so both brokers share one subject grammar.
9
+ */
10
+ import type { ClusterTransport, RedisConnectionOptions } from "../types";
11
+ import { loadRedis, redisConnArgs, type IoredisClient } from "./redis-client";
12
+
13
+ type MessageListener = (data: Uint8Array) => void;
14
+
15
+ /** Convert an ioredis message payload into bytes (binary-safe). */
16
+ function toBytes(msg: unknown): Uint8Array {
17
+ const b = msg instanceof Uint8Array ? msg : Buffer.from(String(msg));
18
+ return new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
19
+ }
20
+
21
+ export function createRedisClusterTransport(
22
+ opts: RedisConnectionOptions,
23
+ onError?: (err: Error) => void,
24
+ ): ClusterTransport {
25
+ const Redis = loadRedis();
26
+ const listeners = new Map<string, Set<MessageListener>>();
27
+ const patternListeners = new Map<string, Set<MessageListener>>();
28
+ let closed = false;
29
+
30
+ // two connections: publishers must not block on subscriber-mode connections
31
+ const make = (): IoredisClient => {
32
+ const c = redisConnArgs(opts);
33
+ return c.url ? new Redis(c.url, { returnBuffers: true }) : new Redis({ ...c.options, returnBuffers: true });
34
+ };
35
+ const pub = make();
36
+ const sub = make();
37
+
38
+ /** Dispatch a broker message to every listener registered for `channel`. */
39
+ const dispatch = (table: Map<string, Set<MessageListener>>, channel: string, msg: unknown): void => {
40
+ const cbs = table.get(channel);
41
+ if (!cbs) return;
42
+ const data = toBytes(msg);
43
+ for (const cb of cbs) cb(data);
44
+ };
45
+
46
+ sub.on("message", (channel: unknown, msg: unknown) => {
47
+ dispatch(listeners, String(channel), msg);
48
+ });
49
+ sub.on("pmessage", (_pattern: unknown, channel: unknown, msg: unknown) => {
50
+ dispatch(patternListeners, String(channel), msg);
51
+ });
52
+
53
+ /**
54
+ * Shared subscribe bookkeeping: register `cb`, dial the broker once per
55
+ * channel, and return an unsubscribe that tears the subscription down when
56
+ * the last listener goes away.
57
+ */
58
+ const subscribeWith = (
59
+ table: Map<string, Set<MessageListener>>,
60
+ key: string,
61
+ dial: (k: string) => void,
62
+ hangUp: (k: string) => void,
63
+ cb: MessageListener,
64
+ ): (() => void) => {
65
+ let set = table.get(key);
66
+ if (!set) {
67
+ set = new Set();
68
+ table.set(key, set);
69
+ dial(key);
70
+ }
71
+ set.add(cb);
72
+ return () => {
73
+ const s = table.get(key);
74
+ if (!s) return;
75
+ s.delete(cb);
76
+ if (s.size === 0) {
77
+ table.delete(key);
78
+ hangUp(key);
79
+ }
80
+ };
81
+ };
82
+
83
+ return {
84
+ get connected(): boolean {
85
+ return !closed && pub.status === "ready" && sub.status === "ready";
86
+ },
87
+ publish(subject, data) {
88
+ if (closed) return;
89
+ void pub
90
+ .publish(subject, Buffer.from(data.buffer, data.byteOffset, data.byteLength))
91
+ .catch((err: unknown) => onError?.(err instanceof Error ? err : new Error(String(err))));
92
+ },
93
+ subscribe(subject, cb) {
94
+ if (subject.includes(">")) {
95
+ // NATS-style wildcard → Redis pattern subscription
96
+ const pattern = subject.replace(/\.>+$/, ".*");
97
+ return subscribeWith(
98
+ patternListeners,
99
+ pattern,
100
+ (p) => void sub.psubscribe(p),
101
+ (p) => void sub.punsubscribe(p),
102
+ cb,
103
+ );
104
+ }
105
+ return subscribeWith(
106
+ listeners,
107
+ subject,
108
+ (s) => void sub.subscribe(s),
109
+ (s) => void sub.unsubscribe(s),
110
+ cb,
111
+ );
112
+ },
113
+ async close() {
114
+ closed = true;
115
+ listeners.clear();
116
+ patternListeners.clear();
117
+ await Promise.allSettled([pub.quit(), sub.quit()]);
118
+ },
119
+ };
120
+ }