@ignex/nova 0.1.3 → 0.1.6

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 (98) hide show
  1. package/README.md +4 -1
  2. package/docs/ai/TREE.md +69 -9
  3. package/docs/architecture.md +75 -27
  4. package/docs/events.md +83 -1
  5. package/docs/generic-bindings.md +10 -0
  6. package/docs/wire-format.md +65 -18
  7. package/package.json +2 -1
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/generate.ts +97 -3
  10. package/public/server.ts +10 -0
  11. package/rust/src/generated/backend.rs +503 -0
  12. package/rust/src/transcode/generated.rs +376 -17
  13. package/src/bridge/nats/inbound.ts +46 -0
  14. package/src/bridge/nats/index.ts +131 -0
  15. package/src/bridge/nats/real-transport.ts +133 -0
  16. package/src/bridge/nats/types.ts +80 -0
  17. package/src/codegen/constants.ts +14 -4
  18. package/src/codegen/direct-gen.ts +20 -6
  19. package/src/codegen/registry-gen.ts +10 -6
  20. package/src/codegen/rust-glue-gen.ts +10 -3
  21. package/src/codegen/schema-model.ts +28 -3
  22. package/src/codegen/ts-ser-gen.ts +12 -3
  23. package/src/core/auth.ts +65 -4
  24. package/src/core/client-rpc.ts +75 -0
  25. package/src/core/client-state.ts +53 -0
  26. package/src/core/client-wire.ts +183 -8
  27. package/src/core/client.ts +84 -4
  28. package/src/core/groups.ts +5 -0
  29. package/src/core/metrics.ts +38 -21
  30. package/src/core/outbound.ts +50 -6
  31. package/src/core/rate-limit.ts +69 -0
  32. package/src/core/replay.ts +41 -1
  33. package/src/core/resume.ts +181 -0
  34. package/src/core/rooms.ts +10 -3
  35. package/src/core/routing.ts +128 -5
  36. package/src/core/server/client-info.ts +37 -0
  37. package/src/core/server/http-routes.ts +59 -0
  38. package/src/core/{server.ts → server/index.ts} +112 -120
  39. package/src/core/server/metrics-view.ts +53 -0
  40. package/src/core/server/socket-lifecycle.ts +57 -0
  41. package/src/core/state.ts +73 -1
  42. package/src/core/topic-log.ts +86 -0
  43. package/src/events/clients.ts +18 -0
  44. package/src/events/cluster/dedupe.ts +43 -0
  45. package/src/events/cluster/envelope.ts +149 -0
  46. package/src/events/cluster/index.ts +50 -0
  47. package/src/events/cluster/keys.ts +33 -0
  48. package/src/events/cluster/kinds.ts +32 -0
  49. package/src/events/cluster/presence-table.ts +99 -0
  50. package/src/events/cluster/presence.ts +53 -0
  51. package/src/events/cluster/redis-client.ts +50 -0
  52. package/src/events/cluster/store-memory.ts +67 -0
  53. package/src/events/cluster/store-redis.ts +44 -0
  54. package/src/events/cluster/subjects.ts +30 -0
  55. package/src/events/cluster/sync.ts +476 -0
  56. package/src/events/cluster/transport-nats.ts +24 -0
  57. package/src/events/cluster/transport-redis.ts +120 -0
  58. package/src/events/cluster-rpc.ts +196 -0
  59. package/src/events/delivery.ts +83 -0
  60. package/src/events/emit.ts +57 -11
  61. package/src/events/hub/context-factory.ts +79 -0
  62. package/src/events/hub/dispatch.ts +86 -0
  63. package/src/events/hub/index.ts +536 -0
  64. package/src/events/hub/internal.ts +31 -0
  65. package/src/events/hub/metrics-snapshot.ts +84 -0
  66. package/src/events/hub/resolve-cluster.ts +49 -0
  67. package/src/events/queue.ts +36 -9
  68. package/src/events/registry.ts +90 -54
  69. package/src/events/schedule.ts +73 -0
  70. package/src/events/trace.ts +283 -0
  71. package/src/events/types/client.ts +68 -0
  72. package/src/events/types/cluster.ts +40 -0
  73. package/src/events/types/context.ts +50 -0
  74. package/src/events/types/emit-target.ts +29 -0
  75. package/src/events/types/groups.ts +35 -0
  76. package/src/events/types/hub.ts +124 -0
  77. package/src/events/types/index.ts +30 -0
  78. package/src/events/types/metrics.ts +52 -0
  79. package/src/events/types/options.ts +62 -0
  80. package/src/generated/direct-ser.ts +146 -59
  81. package/src/generated/fbs/backend.fbs +23 -0
  82. package/src/generated/registry.ts +92 -33
  83. package/src/generated/rust/backend_generated.rs +503 -0
  84. package/src/generated/ts/backend.ts +4 -0
  85. package/src/generated/ts/resume.ts +74 -0
  86. package/src/generated/ts/resumed.ts +88 -0
  87. package/src/generated/ts/rpc-call.ts +112 -0
  88. package/src/generated/ts/rpc-result.ts +126 -0
  89. package/src/generated/ts/snapshot-request.ts +19 -5
  90. package/src/generated/ts-ser.ts +109 -16
  91. package/src/generated/wire-registry.json +7 -3
  92. package/src/schema/index.ts +45 -1
  93. package/src/transport/transport.ts +117 -77
  94. package/src/bridge/nats.ts +0 -309
  95. package/src/events/cluster.ts +0 -732
  96. package/src/events/hub.ts +0 -481
  97. package/src/events/types.ts +0 -378
  98. package/src/transport/stats.ts +0 -48
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Socket lifecycle — the server-side `open` / `close` WebSocket handlers as
3
+ * small `(state, ws)` actions (functional-composition style, like the other
4
+ * core action modules). The composition root (`index.ts`) wires them into
5
+ * `Bun.serve`'s websocket handlers.
6
+ */
7
+ import type { ServerWebSocket } from "bun";
8
+ import type { Bindings } from "../../bindings/types";
9
+ import { joinGroup as addToGroup, leaveGroup as removeFromGroup } from "../groups";
10
+ import { sendControl } from "../outbound";
11
+ import { adoptGrave, burySession } from "../resume";
12
+ import { leaveRoom } from "../rooms";
13
+ import type { ServerState, WsData } from "../state";
14
+
15
+ /** A socket opened: register, adopt resume history, seed groups, greet. */
16
+ export function onSocketOpen(state: ServerState, ws: ServerWebSocket<WsData>): void {
17
+ state.sockets.add(ws);
18
+ // belt-and-suspenders: an auth race could double-register an id — kick the stale session
19
+ const existing = state.clients.get(ws.data.id);
20
+ if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
21
+ state.clients.set(ws.data.id, ws);
22
+ // resume: adopt a parked history for this client id (continues the
23
+ // previous session's delivery-seq stream) BEFORE anything is sent
24
+ adoptGrave(state, ws);
25
+ // events-layer attach (client record + presence) BEFORE group seeding
26
+ state.onConnect?.(ws);
27
+ for (const g of ws.data.groups) addToGroup(state, ws, g);
28
+ greetClient(state, ws);
29
+ }
30
+
31
+ /** Announce wire version + capabilities, then pin this client's identity. */
32
+ function greetClient(state: ServerState, ws: ServerWebSocket<WsData>): void {
33
+ // announce our wire version + capabilities so clients can negotiate
34
+ sendControl(state, ws, "hello", {
35
+ version: (state.bindings as Bindings).wireVersion,
36
+ caps: [],
37
+ lastSeq: 0,
38
+ });
39
+ // then assign identity so the client knows its id + server-side groups
40
+ sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
41
+ }
42
+
43
+ /** A socket closed: detach everywhere and park the resume history. */
44
+ export function onSocketClose(state: ServerState, ws: ServerWebSocket<WsData>): void {
45
+ // events-layer detach FIRST (client record still carries groups/topics)
46
+ state.onDisconnect?.(ws);
47
+ state.sockets.delete(ws);
48
+ state.clients.delete(ws.data.id);
49
+ for (const g of ws.data.groups) removeFromGroup(state, ws, g);
50
+ ws.data.groups.clear();
51
+ for (const t of ws.data.topics) leaveRoom(state, ws, t);
52
+ ws.data.topics.clear();
53
+ delete ws.data.queue;
54
+ // resume: park the sent-history so a reconnect with the same id can
55
+ // pick up where this session left off (no-op when resume is off)
56
+ burySession(state, ws);
57
+ }
package/src/core/state.ts CHANGED
@@ -10,9 +10,11 @@ import type { ServerWebSocket } from "bun";
10
10
  import { defaultBindings } from "../bindings/default";
11
11
  import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
12
12
  import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
13
+ import { createEventTrace, type EventTrace, type EventTraceOptions } from "../events/trace";
13
14
  import { createTransport, defaultTransport, type Transport } from "../transport/transport";
14
15
  import type { Int64GuardMode } from "./int64-guard";
15
16
  import { createMetrics, type Metrics } from "./metrics";
17
+ import { resolveRateLimit, type RateLimitOptions, type ResolvedRateLimit } from "./rate-limit";
16
18
  import type { RingBuffer } from "./ring";
17
19
 
18
20
  /**
@@ -57,6 +59,15 @@ export interface WsData {
57
59
  connectedAt: number;
58
60
  /** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
59
61
  queue?: RingBuffer<Uint8Array>;
62
+ /** per-connection inbound rate limiter (lazily created on first frame) */
63
+ rate?: import("./rate-limit").RateLimiter;
64
+ /**
65
+ * Next per-connection delivery seq to stamp (envelope v2). Starts at 1;
66
+ * continues a previous session's stream when a grave is adopted.
67
+ */
68
+ sendSeq: number;
69
+ /** bounded sent-frame history for gap recovery (lazily created, resume only) */
70
+ history?: import("./ring").RingBuffer<import("./resume").SentFrame>;
60
71
  }
61
72
 
62
73
  /** Slow-consumer policy (see `IgnBackpressureOptions`). */
@@ -97,9 +108,23 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
97
108
  * traffic. Off by default to keep the hot path allocation-free.
98
109
  */
99
110
  replay?: { historySize?: number };
111
+ /**
112
+ * Gap-free delivery (envelope v2 seq + resume). When set, every frame sent
113
+ * to a socket carries a per-connection delivery seq, the connection keeps a
114
+ * bounded sent-history ring, and closed sessions park that ring in a
115
+ * per-client-id graveyard so reconnects can resume missed frames.
116
+ */
117
+ resume?: { historySize?: number; ttlMs?: number };
118
+ /**
119
+ * Durable topic log behind the replay ring (`src/core/topic-log.ts`). When
120
+ * set, every recorded topic frame is appended and `snapshotRequest`s older
121
+ * than the ring hydrate from the log. Default: none (ring-only).
122
+ */
123
+ topicLog?: import("./topic-log").TopicLog;
100
124
  /**
101
125
  * Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
102
- * the connection (401). Return `true` to allow it (client gets an auto-
126
+ * the connection (401) a hook that throws (or rejects) denies it too.
127
+ * Return `true` to allow it (client gets an auto-
103
128
  * generated id), or a `ClientMeta` object to pin the client id / seed its
104
129
  * server-side groups / attach metadata. Inspect `req` as needed.
105
130
  */
@@ -115,6 +140,22 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
115
140
  maxConnections?: number;
116
141
  /** maximum inbound frame size in bytes (close 1009 beyond) */
117
142
  maxMessageSize?: number;
143
+ /**
144
+ * Per-connection inbound rate limiting (token bucket over ALL frames — app
145
+ * AND control). Default: off (zero hot-path overhead). Over-limit frames are
146
+ * dropped (default) or the socket is closed (`policy: "close"`, code 1008);
147
+ * either way the event is counted in `metrics.rateLimited`.
148
+ */
149
+ rateLimit?: RateLimitOptions;
150
+ /**
151
+ * Authorize a client's topic (room) join — enforced for EVERY join path:
152
+ * `subscribe` control frames, programmatic `server.join`, and auth-seeded
153
+ * topics. Return false to reject (the frame/ call is ignored and counted in
154
+ * `metrics.rejectedJoins`). Default: allow all.
155
+ */
156
+ authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
157
+ /** Authorize a server-side group join (same contract as `authorizeTopic`). */
158
+ authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
118
159
  /**
119
160
  * Lossless-int64 guard for plain `number` int64 fields: values outside the
120
161
  * safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
@@ -141,6 +182,13 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
141
182
  * (`ignex-nova/events`) is bound by default.
142
183
  */
143
184
  events?: import("../events/types").EventsOptions<B>;
185
+ /**
186
+ * Event trace ring — records every fired event (emitted / published /
187
+ * received) into a pre-allocated structure-of-arrays buffer so a debugger
188
+ * (ignex debugbar, MCP) can see what fired without any hot-path allocation.
189
+ * Default: on with capacity 1024; `IGNEX_NOVA_TRACE=0` disables globally.
190
+ */
191
+ trace?: EventTraceOptions;
144
192
  /** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
145
193
  fetch?: (req: Request) => Response | Promise<Response>;
146
194
  }
@@ -163,7 +211,11 @@ export interface ServerState {
163
211
  token?: string | ((token: string) => boolean);
164
212
  maxConnections?: number;
165
213
  maxMessageSize?: number;
214
+ rateLimit: ResolvedRateLimit | null;
215
+ authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
216
+ authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
166
217
  replay: { historySize: number } | null;
218
+ resume: { historySize: number; ttlMs: number } | null;
167
219
  sockets: Set<ServerWebSocket<WsData>>;
168
220
  /** id → live socket (client registry for targeted sends / introspection) */
169
221
  clients: Map<string, ServerWebSocket<WsData>>;
@@ -175,11 +227,23 @@ export interface ServerState {
175
227
  inboundHandlers: Map<string, InboundHandler>;
176
228
  topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
177
229
  replaySeq: number;
230
+ /** optional durable topic log (wired in createServer when `options.topicLog` is set) */
231
+ topicLog?: import("./topic-log").TopicLog;
232
+ /**
233
+ * Responder registry for request/response (`rpcCall` control frames):
234
+ * inner event name → async responder. Registered via `server.handle` /
235
+ * `hub.onRequest`.
236
+ */
237
+ rpcHandlers: Map<string, (payload: unknown, ws: ServerWebSocket<WsData>) => Promise<unknown>>;
238
+ /** parked sent-history rings of closed sessions (cross-connection resume) */
239
+ graves: Map<string, { history: RingBuffer<import("./resume").SentFrame>; nextSeq: number; expiresAt: number }>;
178
240
  /** events-layer lifecycle hooks (wired by createServer when `events` is set) */
179
241
  onConnect?: (ws: ServerWebSocket<WsData>) => void;
180
242
  onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
181
243
  /** fired on ANY group membership change (auth seed, control frames, programmatic) */
182
244
  onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
245
+ /** event trace ring (debugger visibility; pre-allocated, zero-GC writes) */
246
+ trace: EventTrace;
183
247
  }
184
248
 
185
249
  export function createServerState<B extends Bindings = DefaultBindings>(
@@ -205,7 +269,12 @@ export function createServerState<B extends Bindings = DefaultBindings>(
205
269
  ...(options.token !== undefined ? { token: options.token } : {}),
206
270
  ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
207
271
  ...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
272
+ rateLimit: resolveRateLimit(options.rateLimit),
273
+ ...(options.authorizeTopic !== undefined ? { authorizeTopic: options.authorizeTopic } : {}),
274
+ ...(options.authorizeGroup !== undefined ? { authorizeGroup: options.authorizeGroup } : {}),
275
+ ...(options.topicLog !== undefined ? { topicLog: options.topicLog } : {}),
208
276
  replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
277
+ resume: options.resume ? { historySize: options.resume.historySize ?? 256, ttlMs: options.resume.ttlMs ?? 60_000 } : null,
209
278
  sockets: new Set(),
210
279
  clients: new Map(),
211
280
  rooms: new Map(),
@@ -213,5 +282,8 @@ export function createServerState<B extends Bindings = DefaultBindings>(
213
282
  inboundHandlers: new Map(),
214
283
  topicHistory: new Map(),
215
284
  replaySeq: 0,
285
+ rpcHandlers: new Map(),
286
+ graves: new Map(),
287
+ trace: createEventTrace(options.trace),
216
288
  };
217
289
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Durable topic-log seam — the pluggable backend behind the bounded replay
3
+ * ring. The ring keeps the LAST N frames per topic in memory; a {@link TopicLog}
4
+ * receives the same frames so subscribers can resume from points the ring has
5
+ * already forgotten.
6
+ *
7
+ * Contract (deliberately narrow):
8
+ * - `append` is SYNCHRONOUS and must never throw onto the publish hot path —
9
+ * implementations buffer internally and flush on their own schedule (the
10
+ * memory impl appends to an array; a file impl would hand off to a writer;
11
+ * a NATS JetStream / Redis Streams impl would enqueue a publish).
12
+ * - `range(topic, afterSeq, limit?)` returns frames strictly AFTER `afterSeq`
13
+ * oldest → newest, synchronously. Adapters over remote stores should
14
+ * maintain a local read-through cache so this stays sync-friendly.
15
+ * - `latestSeq(topic)` mirrors the server's replay-seq counter for the topic
16
+ * (0 = unknown/empty).
17
+ *
18
+ * Ship-with implementation: {@link createMemoryTopicLog} — per-topic bounded
19
+ * array (drop-oldest), process-local durability (survives ring overflow, not a
20
+ * restart). Production adapters (JetStream / Redis Streams / filesystem)
21
+ * implement the same three methods — see docs/architecture.md ("Durability").
22
+ */
23
+ import { RingBuffer } from "./ring";
24
+
25
+ /** One durably-retained topic frame. */
26
+ export interface LoggedFrame {
27
+ /** global replay seq (the same counter stamped into the topic history) */
28
+ seq: number;
29
+ frame: Uint8Array;
30
+ }
31
+
32
+ export interface TopicLog {
33
+ /** Record a frame for `topic` (fire-and-forget; never throws). */
34
+ append(topic: string, frame: Uint8Array, seq: number): void;
35
+ /** Frames strictly after `afterSeq`, oldest → newest (at most `limit`). */
36
+ range(topic: string, afterSeq: number, limit?: number): LoggedFrame[];
37
+ /** Highest seq retained for `topic` (0 = none). */
38
+ latestSeq(topic: string): number;
39
+ /** Release resources (flush buffers, close files/connections). */
40
+ close(): void;
41
+ }
42
+
43
+ export interface MemoryTopicLogOptions {
44
+ /** max frames retained PER TOPIC (drop-oldest beyond), default 10_000 */
45
+ maxPerTopic?: number;
46
+ }
47
+
48
+ /** Process-local durable log: survives ring overflow, not a restart. */
49
+ export function createMemoryTopicLog(
50
+ opts: MemoryTopicLogOptions = {},
51
+ ): TopicLog {
52
+ const max = Math.max(1, opts.maxPerTopic ?? 10_000);
53
+ const topics = new Map<string, { frames: RingBuffer<LoggedFrame>; latest: number }>();
54
+ const ensure = (topic: string) => {
55
+ let t = topics.get(topic);
56
+ if (!t) {
57
+ t = { frames: new RingBuffer<LoggedFrame>(max, true), latest: 0 };
58
+ topics.set(topic, t);
59
+ }
60
+ return t;
61
+ };
62
+ return {
63
+ append(topic, frame, seq) {
64
+ const t = ensure(topic);
65
+ t.frames.push({ seq, frame: frame.slice() });
66
+ if (seq > t.latest) t.latest = seq;
67
+ },
68
+ range(topic, afterSeq, limit) {
69
+ const t = topics.get(topic);
70
+ if (!t) return [];
71
+ const out: LoggedFrame[] = [];
72
+ for (const e of t.frames) {
73
+ if (e.seq <= afterSeq) continue;
74
+ out.push(e);
75
+ if (limit !== undefined && out.length >= limit) break;
76
+ }
77
+ return out;
78
+ },
79
+ latestSeq(topic) {
80
+ return topics.get(topic)?.latest ?? 0;
81
+ },
82
+ close() {
83
+ topics.clear();
84
+ },
85
+ };
86
+ }
@@ -67,6 +67,11 @@ export interface ClientStore {
67
67
  get(id: string): EventClient | undefined;
68
68
  all(): EventClient[];
69
69
  byUser(userId: string): EventClient[];
70
+ /**
71
+ * Invoke `each` for every live socket of `userId`; returns the count.
72
+ * Allocation-free variant of {@link byUser} for emit hot paths.
73
+ */
74
+ forEachByUser(userId: string, each: (client: EventClient) => void): number;
70
75
  setUserId(clientId: string, userId: string): boolean;
71
76
  onAttach(cb: (client: EventClient) => void): void;
72
77
  onDetach(cb: (client: EventClient) => void): void;
@@ -136,6 +141,19 @@ export function createClientStore(): ClientStore {
136
141
  }
137
142
  return out;
138
143
  },
144
+ forEachByUser(userId, each) {
145
+ const ids = byUser.get(userId);
146
+ if (ids === undefined || ids.size === 0) return 0;
147
+ let n = 0;
148
+ for (const id of ids) {
149
+ const c = byId.get(id);
150
+ if (c !== undefined) {
151
+ each(c);
152
+ n++;
153
+ }
154
+ }
155
+ return n;
156
+ },
139
157
  setUserId(clientId, userId) {
140
158
  const client = byId.get(clientId);
141
159
  if (!client) return false;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Broker-redelivery dedupe window — bounded recent-message-id tracking.
3
+ *
4
+ * Durable brokers may redeliver; every processed message id is recorded in a
5
+ * ring + set pair and duplicates are dropped. Encapsulated factory (like
6
+ * `createMetrics`) — the state is private, the surface is one pure predicate.
7
+ */
8
+ import { RingBuffer } from "../../core/ring";
9
+
10
+ export interface DedupeWindow {
11
+ /**
12
+ * Record `id` and report whether it was ALREADY seen (true → drop the
13
+ * message). Empty ids and a zero-size window disable tracking entirely.
14
+ */
15
+ markSeen(id: string): boolean;
16
+ }
17
+
18
+ /**
19
+ * @param size how many message ids to remember (0 disables; values < 16 are
20
+ * clamped up so the ring has usable capacity).
21
+ */
22
+ export function createDedupeWindow(size: number): DedupeWindow {
23
+ const window = Math.max(0, size);
24
+ if (window === 0) return { markSeen: () => false };
25
+
26
+ const ring = new RingBuffer<string>(Math.max(16, window), true);
27
+ const seen = new Set<string>();
28
+
29
+ return {
30
+ markSeen(id: string): boolean {
31
+ if (id === "") return false;
32
+ if (seen.has(id)) return true;
33
+ // evict the oldest id when the window is full (FIFO — matches redelivery)
34
+ if (ring.length >= window) {
35
+ const evict = ring.shift();
36
+ if (evict !== undefined) seen.delete(evict);
37
+ }
38
+ ring.push(id);
39
+ seen.add(id);
40
+ return false;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Cluster envelope codec — the self-describing binary frame published to the
3
+ * broker. Routing never depends on broker channel syntax; everything a peer
4
+ * needs (origin, kind, key, event name, dedupe id, trace id) rides in the
5
+ * header so any transport works identically.
6
+ *
7
+ * Wire layout (v2):
8
+ * [envVer:1]
9
+ * [originLen:u8][origin:utf8][kind:u8][keyLen:u8][key:utf8][nameLen:u8][name:utf8]
10
+ * [msgIdLen:u8][msgId:utf8][traceLen:u8][trace:utf8]
11
+ * [frame:bytes]
12
+ *
13
+ * v2 adds the envelope VERSION byte, a message id (broker-level redelivery
14
+ * dedupe) and an optional trace id (cross-instance trace correlation). A v1
15
+ * peer's frames fail the version check and are counted as errors.
16
+ *
17
+ * Pure functions — no I/O, no shared state.
18
+ */
19
+ import {
20
+ CLUSTER_ENV_VERSION,
21
+ CLUSTER_KINDS,
22
+ CLUSTER_KIND_ID,
23
+ type ClusterKind,
24
+ clusterKindFromId,
25
+ } from "./kinds";
26
+
27
+ // module-global codecs: allocation happens once per process, not per message
28
+ const enc = new TextEncoder();
29
+ const dec = new TextDecoder();
30
+
31
+ /** One decoded cluster message (`frame` is a view into the input buffer). */
32
+ export interface ClusterEnvelope {
33
+ origin: string;
34
+ kind: ClusterKind;
35
+ key: string;
36
+ name: string;
37
+ frame: Uint8Array;
38
+ /** producer-assigned unique message id (dedupe across broker redeliveries) */
39
+ msgId: string;
40
+ /** optional cross-instance trace id */
41
+ traceId: string;
42
+ }
43
+
44
+ /** Write `[len:u8][bytes]` at `p`; returns the offset after the payload. */
45
+ function putLenPrefixed(out: Uint8Array, p: number, bytes: Uint8Array): number {
46
+ out[p] = bytes.byteLength;
47
+ out.set(bytes, p + 1);
48
+ return p + 1 + bytes.byteLength;
49
+ }
50
+
51
+ /** Encode all length-prefixed header strings up-front (also validates sizes). */
52
+ function encodeHeaderStrings(
53
+ origin: string,
54
+ key: string,
55
+ name: string,
56
+ msgId: string,
57
+ traceId: string,
58
+ ): [Uint8Array, Uint8Array, Uint8Array, Uint8Array, Uint8Array] {
59
+ const o = enc.encode(origin);
60
+ const k = enc.encode(key);
61
+ const n = enc.encode(name);
62
+ const m = enc.encode(msgId);
63
+ const t = enc.encode(traceId);
64
+ // length fields are single bytes — anything longer would silently wrap
65
+ // mod 256 and CORRUPT the frame for every peer; fail loudly instead
66
+ if (
67
+ o.byteLength > 255 ||
68
+ k.byteLength > 255 ||
69
+ n.byteLength > 255 ||
70
+ m.byteLength > 255 ||
71
+ t.byteLength > 255
72
+ ) {
73
+ throw new RangeError(
74
+ "ignex cluster: origin/key/name/msgId/trace exceed the 255-byte envelope limit " +
75
+ `(got ${o.byteLength}/${k.byteLength}/${n.byteLength}/${m.byteLength}/${t.byteLength})`,
76
+ );
77
+ }
78
+ return [o, k, n, m, t];
79
+ }
80
+
81
+ export function encodeClusterMessage(
82
+ origin: string,
83
+ kind: ClusterKind,
84
+ key: string,
85
+ name: string,
86
+ frame: Uint8Array,
87
+ msgId = "",
88
+ traceId = "",
89
+ ): Uint8Array {
90
+ const [o, k, n, m, t] = encodeHeaderStrings(origin, key, name, msgId, traceId);
91
+ // fixed bytes: envVer(1) originLen(1) kind(1) keyLen(1) nameLen(1) msgIdLen(1) traceLen(1)
92
+ const headerLen = 7 + o.byteLength + k.byteLength + n.byteLength + m.byteLength + t.byteLength;
93
+ const out = new Uint8Array(headerLen + frame.byteLength);
94
+ let p = 0;
95
+ out[p] = CLUSTER_ENV_VERSION;
96
+ p++;
97
+ p = putLenPrefixed(out, p, o);
98
+ out[p] = CLUSTER_KIND_ID[kind];
99
+ p++;
100
+ p = putLenPrefixed(out, p, k);
101
+ p = putLenPrefixed(out, p, n);
102
+ p = putLenPrefixed(out, p, m);
103
+ p = putLenPrefixed(out, p, t);
104
+ out.set(frame, p);
105
+ return out;
106
+ }
107
+
108
+ /** Read one length-prefixed string at `at`; `null` when truncated/malformed. */
109
+ function readLenPrefixed(
110
+ bytes: Uint8Array,
111
+ at: number,
112
+ ): { str: string; next: number } | null {
113
+ if (at >= bytes.byteLength) return null;
114
+ const len = bytes[at]!;
115
+ if (at + 1 + len > bytes.byteLength) return null;
116
+ return { str: dec.decode(bytes.subarray(at + 1, at + 1 + len)), next: at + 1 + len };
117
+ }
118
+
119
+ /**
120
+ * Decode a cluster envelope. Returns `null` for undecodable input or a
121
+ * foreign/legacy envelope version (callers count those as errors).
122
+ */
123
+ export function decodeClusterMessage(bytes: Uint8Array): ClusterEnvelope | null {
124
+ if (bytes.byteLength < 4) return null;
125
+ if (bytes[0] !== CLUSTER_ENV_VERSION) return null; // foreign / legacy envelope
126
+ const o = readLenPrefixed(bytes, 1);
127
+ if (!o) return null;
128
+ const kindId = bytes[o.next];
129
+ if (kindId === undefined || kindId >= CLUSTER_KINDS.length) return null;
130
+ const k = readLenPrefixed(bytes, o.next + 1);
131
+ if (!k) return null;
132
+ const n = readLenPrefixed(bytes, k.next);
133
+ if (!n) return null;
134
+ const m = readLenPrefixed(bytes, n.next);
135
+ if (!m) return null;
136
+ const t = readLenPrefixed(bytes, m.next);
137
+ if (!t) return null;
138
+ const kind = clusterKindFromId(kindId);
139
+ if (kind === undefined) return null;
140
+ return {
141
+ origin: o.str,
142
+ kind,
143
+ key: k.str,
144
+ name: n.str,
145
+ msgId: m.str,
146
+ traceId: t.str,
147
+ frame: bytes.subarray(t.next),
148
+ };
149
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cluster sync — barrel. Horizontal scaling for the events layer, decomposed
3
+ * by concern:
4
+ *
5
+ * kinds — routing-kind constants + wire ids
6
+ * envelope — the self-describing binary frame codec (pure)
7
+ * subjects — broker channel names derived from the prefix
8
+ * presence — presence message codec (join/leave/sync)
9
+ * presence-table — in-memory remote-presence index with TTL pruning
10
+ * dedupe — bounded broker-redelivery dedupe window
11
+ * keys — shared-state key builders
12
+ * sync — createClusterSync composition root
13
+ * transport-nats / transport-redis — ClusterTransport adapters
14
+ * store-memory / store-redis — ClusterStateStore adapters
15
+ *
16
+ * All cross-instance work is deferred to the offload queue — the emit call
17
+ * never blocks on a broker.
18
+ */
19
+
20
+ export { CLUSTER_ENV_VERSION, CLUSTER_KINDS, CLUSTER_KIND_ID, clusterKindFromId, type ClusterKind } from "./kinds";
21
+ export { decodeClusterMessage, encodeClusterMessage, type ClusterEnvelope } from "./envelope";
22
+ export { createClusterSubjects, type ClusterSubjects } from "./subjects";
23
+ export {
24
+ decodePresence,
25
+ encodePresence,
26
+ type PresenceJoin,
27
+ type PresenceLeave,
28
+ type PresenceMessage,
29
+ type PresenceSync,
30
+ } from "./presence";
31
+ export { createPresenceTable, type PresenceTable } from "./presence-table";
32
+ export { createDedupeWindow, type DedupeWindow } from "./dedupe";
33
+ export {
34
+ clientDataKey,
35
+ clientGroupStateKey,
36
+ parsePresenceMember,
37
+ presenceInstanceKey,
38
+ presenceUserKey,
39
+ userGroupStateKey,
40
+ } from "./keys";
41
+ export {
42
+ createClusterSync,
43
+ type ClusterMsgMeta,
44
+ type ClusterSync,
45
+ type ClusterSyncOptions,
46
+ } from "./sync";
47
+ export { createNatsClusterTransport } from "./transport-nats";
48
+ export { createRedisClusterTransport } from "./transport-redis";
49
+ export { createMemoryStateStore } from "./store-memory";
50
+ export { createRedisStateStore } from "./store-redis";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared-state key builders — the canonical key names used in the
3
+ * `ClusterStateStore` (Redis in production). Centralized so every instance
4
+ * agrees on the layout; pure string functions.
5
+ */
6
+
7
+ /** User-group membership set (`member` = userId). */
8
+ export const userGroupStateKey = (name: string): string => `ignex:group-users:${name}`;
9
+
10
+ /** Client-group membership set (`member` = clientId). */
11
+ export const clientGroupStateKey = (name: string): string => `ignex:group:${name}`;
12
+
13
+ /** Per-user presence index (`member` = `{instanceId}:{clientId}`). */
14
+ export const presenceUserKey = (userId: string): string => `ignex:presence:user:${userId}`;
15
+
16
+ /** Per-instance presence index (`member` = clientId). */
17
+ export const presenceInstanceKey = (instanceId: string): string =>
18
+ `ignex:presence:instance:${instanceId}`;
19
+
20
+ /** Client data blob (JSON string). */
21
+ export const clientDataKey = (clientId: string): string => `ignex:client-data:${clientId}`;
22
+
23
+ /**
24
+ * Split a `{instanceId}:{clientId}` presence member back into its parts.
25
+ * Returns `null` for malformed members (never crashes on foreign data).
26
+ */
27
+ export function parsePresenceMember(
28
+ member: string,
29
+ ): { instanceId: string; clientId: string } | null {
30
+ const idx = member.indexOf(":");
31
+ if (idx <= 0) return null;
32
+ return { instanceId: member.slice(0, idx), clientId: member.slice(idx + 1) };
33
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cluster routing kinds — the semantic addressing modes carried in the
3
+ * cluster envelope. Numeric ids ride the wire; the strings are internal.
4
+ *
5
+ * Pure constants module (part of the `src/events/cluster` composition).
6
+ */
7
+
8
+ /** Every envelope kind, in wire order (index = kind id). */
9
+ export const CLUSTER_KINDS = ["broadcast", "topic", "group", "user", "client", "presence"] as const;
10
+ export type ClusterKind = (typeof CLUSTER_KINDS)[number];
11
+
12
+ /** Wire encoding of a {@link ClusterKind} (single byte). */
13
+ export const CLUSTER_KIND_ID: Record<ClusterKind, number> = {
14
+ broadcast: 0,
15
+ topic: 1,
16
+ group: 2,
17
+ user: 3,
18
+ client: 4,
19
+ presence: 5,
20
+ };
21
+
22
+ /**
23
+ * Envelope format version. A peer on a different version fails the version
24
+ * check and its frames are counted as errors — mixed-version clusters during
25
+ * a rolling upgrade degrade visibly instead of delivering corrupt frames.
26
+ */
27
+ export const CLUSTER_ENV_VERSION = 2;
28
+
29
+ /** Map a numeric wire kind back to its name (`undefined` when out of range). */
30
+ export function clusterKindFromId(id: number): ClusterKind | undefined {
31
+ return CLUSTER_KINDS[id];
32
+ }