@ignex/nova 0.1.3 → 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.
- package/README.md +4 -1
- package/docs/ai/TREE.md +69 -9
- package/docs/architecture.md +75 -27
- package/docs/events.md +83 -1
- package/docs/generic-bindings.md +10 -0
- package/docs/wire-format.md +65 -18
- package/package.json +2 -1
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/generate.ts +97 -3
- package/public/server.ts +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +376 -17
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/codegen/constants.ts +14 -4
- package/src/codegen/direct-gen.ts +20 -6
- package/src/codegen/registry-gen.ts +10 -6
- package/src/codegen/rust-glue-gen.ts +10 -3
- package/src/codegen/schema-model.ts +28 -3
- package/src/codegen/ts-ser-gen.ts +12 -3
- package/src/core/auth.ts +65 -4
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +42 -0
- package/src/core/client-wire.ts +142 -8
- package/src/core/client.ts +72 -3
- package/src/core/groups.ts +5 -0
- package/src/core/metrics.ts +38 -21
- package/src/core/outbound.ts +50 -6
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +128 -5
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/{server.ts → server/index.ts} +112 -120
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +73 -1
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +18 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +57 -11
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/queue.ts +36 -9
- package/src/events/registry.ts +90 -54
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +146 -59
- package/src/generated/fbs/backend.fbs +23 -0
- package/src/generated/registry.ts +92 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +109 -16
- package/src/generated/wire-registry.json +7 -3
- package/src/schema/index.ts +45 -1
- package/src/transport/transport.ts +117 -77
- package/src/bridge/nats.ts +0 -309
- package/src/events/cluster.ts +0 -732
- package/src/events/hub.ts +0 -481
- package/src/events/types.ts +0 -378
- package/src/transport/stats.ts +0 -48
package/src/events/cluster.ts
DELETED
|
@@ -1,732 +0,0 @@
|
|
|
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
|
-
}
|