@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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory state store — per-process default `ClusterStateStore`.
|
|
3
|
+
* Pass a SHARED `Map` to simulate a cross-instance store in tests /
|
|
4
|
+
* single-process multi-instance setups.
|
|
5
|
+
*
|
|
6
|
+
* TTLs are modeled with a parallel `__ttl:` key checked lazily on read.
|
|
7
|
+
*/
|
|
8
|
+
import type { ClusterStateStore } from "../types";
|
|
9
|
+
|
|
10
|
+
export function createMemoryStateStore(
|
|
11
|
+
shared?: Map<string, unknown>,
|
|
12
|
+
): ClusterStateStore & { close(): Promise<void> } {
|
|
13
|
+
const data = shared ?? new Map<string, unknown>();
|
|
14
|
+
|
|
15
|
+
const ttlKey = (key: string): string => `__ttl:${key}`;
|
|
16
|
+
|
|
17
|
+
/** Lazily expire: drop the key when its TTL has passed; report liveness. */
|
|
18
|
+
const alive = (key: string): boolean => {
|
|
19
|
+
const ttl = data.get(ttlKey(key));
|
|
20
|
+
if (ttl === undefined) return true;
|
|
21
|
+
if (Date.now() > Number(ttl)) {
|
|
22
|
+
data.delete(key);
|
|
23
|
+
data.delete(ttlKey(key));
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async get(key) {
|
|
31
|
+
if (!alive(key)) return null;
|
|
32
|
+
const v = data.get(key);
|
|
33
|
+
return typeof v === "string" ? v : null;
|
|
34
|
+
},
|
|
35
|
+
async set(key, value, ttlMs) {
|
|
36
|
+
data.set(key, value);
|
|
37
|
+
if (ttlMs !== undefined) data.set(ttlKey(key), Date.now() + ttlMs);
|
|
38
|
+
},
|
|
39
|
+
async del(key) {
|
|
40
|
+
data.delete(key);
|
|
41
|
+
data.delete(ttlKey(key));
|
|
42
|
+
},
|
|
43
|
+
async sadd(key, member) {
|
|
44
|
+
let s = data.get(key);
|
|
45
|
+
if (!(s instanceof Set)) {
|
|
46
|
+
s = new Set<string>();
|
|
47
|
+
data.set(key, s);
|
|
48
|
+
}
|
|
49
|
+
(s as Set<string>).add(member);
|
|
50
|
+
},
|
|
51
|
+
async srem(key, member) {
|
|
52
|
+
const s = data.get(key);
|
|
53
|
+
if (s instanceof Set) (s as Set<string>).delete(member);
|
|
54
|
+
},
|
|
55
|
+
async smembers(key) {
|
|
56
|
+
if (!alive(key)) return [];
|
|
57
|
+
const s = data.get(key);
|
|
58
|
+
return s instanceof Set ? [...(s as Set<string>)] : [];
|
|
59
|
+
},
|
|
60
|
+
async expire(key, ttlMs) {
|
|
61
|
+
data.set(ttlKey(key), Date.now() + ttlMs);
|
|
62
|
+
},
|
|
63
|
+
async close() {
|
|
64
|
+
data.clear();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis state store (lazy `ioredis`). The production `ClusterStateStore` for
|
|
3
|
+
* horizontally scaled deployments — every call is issued from the offload
|
|
4
|
+
* queue, never from the WS hot path.
|
|
5
|
+
*/
|
|
6
|
+
import type { ClusterStateStore, RedisConnectionOptions } from "../types";
|
|
7
|
+
import { loadRedis } from "./redis-client";
|
|
8
|
+
|
|
9
|
+
export function createRedisStateStore(
|
|
10
|
+
opts: RedisConnectionOptions = {},
|
|
11
|
+
): ClusterStateStore & { close(): Promise<void> } {
|
|
12
|
+
const Redis = loadRedis();
|
|
13
|
+
const r = new Redis(opts);
|
|
14
|
+
return {
|
|
15
|
+
async get(key) {
|
|
16
|
+
const v = await r.get(key);
|
|
17
|
+
return v == null ? null : String(v);
|
|
18
|
+
},
|
|
19
|
+
async set(key, value, ttlMs) {
|
|
20
|
+
if (ttlMs !== undefined) await r.set(key, value, "PX", ttlMs);
|
|
21
|
+
else await r.set(key, value);
|
|
22
|
+
},
|
|
23
|
+
async del(key) {
|
|
24
|
+
await r.del(key);
|
|
25
|
+
},
|
|
26
|
+
async sadd(key, member) {
|
|
27
|
+
await r.sadd(key, member);
|
|
28
|
+
},
|
|
29
|
+
async srem(key, member) {
|
|
30
|
+
await r.srem(key, member);
|
|
31
|
+
},
|
|
32
|
+
async smembers(key) {
|
|
33
|
+
const v = await r.smembers(key);
|
|
34
|
+
return Array.isArray(v) ? v.map(String) : [];
|
|
35
|
+
},
|
|
36
|
+
// redis EXPIRE is second-granularity — round up so short TTLs still hold
|
|
37
|
+
async expire(key, ttlMs) {
|
|
38
|
+
await r.expire(key, Math.max(1, Math.ceil(ttlMs / 1000)));
|
|
39
|
+
},
|
|
40
|
+
async close() {
|
|
41
|
+
await r.quit();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster subject space — the broker channel names derived from the prefix.
|
|
3
|
+
*
|
|
4
|
+
* Every instance subscribes ONE wildcard channel (full-mesh kinds + presence)
|
|
5
|
+
* plus its own per-instance channel (routed targeted delivery). The envelope
|
|
6
|
+
* carries routing, so these names are pure syntax.
|
|
7
|
+
*
|
|
8
|
+
* Pure factory — no I/O.
|
|
9
|
+
*/
|
|
10
|
+
import type { EmitTargetKind } from "../types";
|
|
11
|
+
|
|
12
|
+
/** The three subject shapes used by cluster messaging. */
|
|
13
|
+
export interface ClusterSubjects {
|
|
14
|
+
/** the one channel every instance subscribes to (full-mesh kinds) */
|
|
15
|
+
all(): string;
|
|
16
|
+
/** per-instance channel — routed targeted delivery lands here only */
|
|
17
|
+
instance(id: string): string;
|
|
18
|
+
/** publish channel for a target kind (also the external visibility of the subject space) */
|
|
19
|
+
event(kind: EmitTargetKind, key: string | undefined, name: string): string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createClusterSubjects(prefix: string): ClusterSubjects {
|
|
23
|
+
const base = `${prefix}.cluster`;
|
|
24
|
+
return {
|
|
25
|
+
all: () => `${base}.>`,
|
|
26
|
+
instance: (id) => `${base}.instance.${id}`,
|
|
27
|
+
event: (kind, key, name) =>
|
|
28
|
+
key === undefined ? `${base}.${kind}.${name}` : `${base}.${kind}.${key}.${name}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -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
|
+
}
|