@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.
- 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 +53 -0
- package/src/core/client-wire.ts +183 -8
- package/src/core/client.ts +84 -4
- 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,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)
|
|
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
|
+
}
|
package/src/events/clients.ts
CHANGED
|
@@ -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
|
+
}
|