@ignex/nova 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
package/src/core/state.ts CHANGED
@@ -7,11 +7,15 @@
7
7
  * PUBLIC surface and are re-exported by `public/server.ts`.
8
8
  */
9
9
  import type { ServerWebSocket } from "bun";
10
- import type { EventName } from "../schema";
10
+ import { defaultBindings } from "../bindings/default";
11
+ import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
12
+ import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
13
+ import { createEventTrace, type EventTrace, type EventTraceOptions } from "../events/trace";
14
+ import { createTransport, defaultTransport, type Transport } from "../transport/transport";
11
15
  import type { Int64GuardMode } from "./int64-guard";
12
16
  import { createMetrics, type Metrics } from "./metrics";
13
- import { RingBuffer } from "./ring";
14
- import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
17
+ import { resolveRateLimit, type RateLimitOptions, type ResolvedRateLimit } from "./rate-limit";
18
+ import type { RingBuffer } from "./ring";
15
19
 
16
20
  /**
17
21
  * Optional identity metadata a client may carry for targeting / grouping.
@@ -20,6 +24,12 @@ import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
20
24
  export interface ClientMeta {
21
25
  /** explicit client id; omitted → auto-assigned `crypto.randomUUID()` */
22
26
  id?: string;
27
+ /**
28
+ * The identity this connection acts ON BEHALF OF (e.g. the logged-in user).
29
+ * Several connections may share a `userId` (multi-tab / multi-device) — the
30
+ * events layer groups them for user-targeted emits (`hub.emitToUser`).
31
+ */
32
+ userId?: string;
23
33
  /** server-side groups this client belongs to on connect */
24
34
  groups?: string[];
25
35
  /** arbitrary app metadata (exposed via `getClient` / `getClients`) */
@@ -41,12 +51,23 @@ export interface WsData {
41
51
  groups: Set<string>;
42
52
  /** stable client id (auth metadata or auto-generated UUID) */
43
53
  id: string;
54
+ /** identity this connection acts on behalf of (undefined = anonymous) */
55
+ userId?: string;
44
56
  /** arbitrary app metadata from `authenticate` (undefined if none) */
45
57
  meta?: Record<string, unknown>;
46
58
  /** epoch ms when the socket opened (for `getClients` ordering/uptime) */
47
59
  connectedAt: number;
48
60
  /** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
49
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>;
50
71
  }
51
72
 
52
73
  /** Slow-consumer policy (see `IgnBackpressureOptions`). */
@@ -61,15 +82,22 @@ export interface IgnBackpressureOptions {
61
82
  maxQueue?: number;
62
83
  }
63
84
 
64
- export interface IgnServerOptions {
85
+ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
65
86
  port: number;
66
87
  hostname?: string;
67
88
  /** seconds; 0 = no timeout */
68
89
  idleTimeout?: number;
69
90
  /** websocket path, default "/ws" */
70
91
  path?: string;
92
+ /**
93
+ * The wire stack (event ids, decoders, encoders). Defaults to the built-in
94
+ * registry; pass your own (from `generateBindings` + `assembleBindings`) to
95
+ * serve YOUR schema. When provided, the server API (`publish` / `on` / ...)
96
+ * is typed against your `Events`.
97
+ */
98
+ bindings?: B;
71
99
  /** app events clients are ALLOWED to send; control events are always allowed. default [] */
72
- inbound?: EventName[];
100
+ inbound?: EventNameOf<B>[];
73
101
  /** slow-consumer protection. default: off (unbounded buffering) */
74
102
  backpressure?: IgnBackpressureOptions;
75
103
  /**
@@ -80,9 +108,23 @@ export interface IgnServerOptions {
80
108
  * traffic. Off by default to keep the hot path allocation-free.
81
109
  */
82
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;
83
124
  /**
84
125
  * Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
85
- * 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-
86
128
  * generated id), or a `ClientMeta` object to pin the client id / seed its
87
129
  * server-side groups / attach metadata. Inspect `req` as needed.
88
130
  */
@@ -98,6 +140,22 @@ export interface IgnServerOptions {
98
140
  maxConnections?: number;
99
141
  /** maximum inbound frame size in bytes (close 1009 beyond) */
100
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;
101
159
  /**
102
160
  * Lossless-int64 guard for plain `number` int64 fields: values outside the
103
161
  * safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
@@ -117,6 +175,20 @@ export interface IgnServerOptions {
117
175
  * NATS.
118
176
  */
119
177
  nats?: NatsBridgeOptions | NatsBridge;
178
+ /**
179
+ * Enable the events layer (typed event handlers + the global emit, client
180
+ * records with per-connection data, groups, optional cluster sync). Exposed
181
+ * as `server.events`; the module-global `emit`/`on` singleton
182
+ * (`ignex-nova/events`) is bound by default.
183
+ */
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;
120
192
  /** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
121
193
  fetch?: (req: Request) => Response | Promise<Response>;
122
194
  }
@@ -125,8 +197,12 @@ type InboundHandler = (payload: unknown, ws: ServerWebSocket<WsData>) => void;
125
197
 
126
198
  /** The full, explicit server state — created once per server, passed to actions. */
127
199
  export interface ServerState {
200
+ /** the wire stack this server speaks (ids / decoders / encoders). */
201
+ bindings: Bindings;
202
+ /** per-server encoder (scratch + FFI binding or pure-JS fallback). */
203
+ transport: Transport;
128
204
  path: string;
129
- inbound: ReadonlySet<EventName>;
205
+ inbound: Set<string>;
130
206
  bp: Required<IgnBackpressureOptions> | null;
131
207
  metrics: Metrics;
132
208
  startedAt: number;
@@ -135,7 +211,11 @@ export interface ServerState {
135
211
  token?: string | ((token: string) => boolean);
136
212
  maxConnections?: number;
137
213
  maxMessageSize?: number;
214
+ rateLimit: ResolvedRateLimit | null;
215
+ authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
216
+ authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
138
217
  replay: { historySize: number } | null;
218
+ resume: { historySize: number; ttlMs: number } | null;
139
219
  sockets: Set<ServerWebSocket<WsData>>;
140
220
  /** id → live socket (client registry for targeted sends / introspection) */
141
221
  clients: Map<string, ServerWebSocket<WsData>>;
@@ -144,13 +224,35 @@ export interface ServerState {
144
224
  groups: Map<string, Set<ServerWebSocket<WsData>>>;
145
225
  /** optional NATS bridge (wired in createServer when `options.nats` is set) */
146
226
  bridge?: NatsBridge;
147
- inboundHandlers: Map<EventName, InboundHandler>;
227
+ inboundHandlers: Map<string, InboundHandler>;
148
228
  topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
149
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 }>;
240
+ /** events-layer lifecycle hooks (wired by createServer when `events` is set) */
241
+ onConnect?: (ws: ServerWebSocket<WsData>) => void;
242
+ onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
243
+ /** fired on ANY group membership change (auth seed, control frames, programmatic) */
244
+ onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
245
+ /** event trace ring (debugger visibility; pre-allocated, zero-GC writes) */
246
+ trace: EventTrace;
150
247
  }
151
248
 
152
- export function createServerState(options: IgnServerOptions): ServerState {
249
+ export function createServerState<B extends Bindings = DefaultBindings>(
250
+ options: IgnServerOptions<B>,
251
+ ): ServerState {
252
+ const bindings = options.bindings ?? defaultBindings;
153
253
  return {
254
+ bindings,
255
+ transport: bindings === defaultBindings ? defaultTransport : createTransport(bindings),
154
256
  path: options.path ?? "/ws",
155
257
  inbound: new Set(options.inbound ?? []),
156
258
  bp: options.backpressure
@@ -162,12 +264,17 @@ export function createServerState(options: IgnServerOptions): ServerState {
162
264
  : null,
163
265
  metrics: createMetrics(),
164
266
  startedAt: Date.now(),
165
- authenticate: options.authenticate,
166
- allowedOrigins: options.allowedOrigins,
167
- token: options.token,
168
- maxConnections: options.maxConnections,
169
- maxMessageSize: options.maxMessageSize,
267
+ ...(options.authenticate !== undefined ? { authenticate: options.authenticate } : {}),
268
+ ...(options.allowedOrigins !== undefined ? { allowedOrigins: options.allowedOrigins } : {}),
269
+ ...(options.token !== undefined ? { token: options.token } : {}),
270
+ ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
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 } : {}),
170
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,
171
278
  sockets: new Set(),
172
279
  clients: new Map(),
173
280
  rooms: new Map(),
@@ -175,5 +282,8 @@ export function createServerState(options: IgnServerOptions): ServerState {
175
282
  inboundHandlers: new Map(),
176
283
  topicHistory: new Map(),
177
284
  replaySeq: 0,
285
+ rpcHandlers: new Map(),
286
+ graves: new Map(),
287
+ trace: createEventTrace(options.trace),
178
288
  };
179
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
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Client store — the live registry of active connections, "who is connected,
3
+ * on whose behalf, and what the app remembers about them".
4
+ *
5
+ * - `byId: Map<clientId, EventClient>` — one record per socket.
6
+ * - `byUser: Map<userId, Set<clientId>>` — the reverse "on what behalf" index,
7
+ * so a user with several tabs/devices is one logical target.
8
+ * - Each record carries a per-connection `data` store (created on attach,
9
+ * dropped on detach) — `hub.setClientData` / `client.data` read/write it.
10
+ *
11
+ * Pure local state: cross-instance presence / state sync lives in `cluster.ts`
12
+ * and is driven FROM this store via the `onAttach` / `onDetach` hooks (which
13
+ * the hub wires to offloaded queue work, keeping connect/disconnect O(1)).
14
+ */
15
+ import type { ServerWebSocket } from "bun";
16
+ import type { WsData } from "../core/state";
17
+ import { createClientData } from "./data";
18
+ import type { ClientData, EventClient } from "./types";
19
+
20
+ /** Factory for a client record bound to a live socket. */
21
+ export function createEventClient(
22
+ ws: ServerWebSocket<WsData>,
23
+ ): EventClient {
24
+ const client: MutableEventClient = {
25
+ get id(): string {
26
+ return ws.data.id;
27
+ },
28
+ // `userId` is the identity this connection acts on behalf of — read live
29
+ // from the socket data (single source of truth; `setUserId` writes there).
30
+ get userId(): string | undefined {
31
+ return ws.data.userId;
32
+ },
33
+ get meta(): Record<string, unknown> | undefined {
34
+ return ws.data.meta;
35
+ },
36
+ data: createClientData(),
37
+ get groups(): ReadonlySet<string> {
38
+ return ws.data.groups;
39
+ },
40
+ get topics(): ReadonlySet<string> {
41
+ return ws.data.topics;
42
+ },
43
+ get connectedAt(): number {
44
+ return ws.data.connectedAt;
45
+ },
46
+ get ip(): string {
47
+ return ws.remoteAddress;
48
+ },
49
+ // plain mutable property — the store flips it to true on detach
50
+ closed: false,
51
+ get ws(): ServerWebSocket<WsData> {
52
+ return ws;
53
+ },
54
+ };
55
+ return client;
56
+ }
57
+
58
+ /** The client record with readonly modifiers stripped (internal mutation). */
59
+ export type MutableEventClient = {
60
+ -readonly [K in keyof EventClient]: EventClient[K];
61
+ };
62
+
63
+ export interface ClientStore {
64
+ readonly size: number;
65
+ attach(ws: ServerWebSocket<WsData>): EventClient;
66
+ detach(ws: ServerWebSocket<WsData>): EventClient | undefined;
67
+ get(id: string): EventClient | undefined;
68
+ all(): EventClient[];
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;
75
+ setUserId(clientId: string, userId: string): boolean;
76
+ onAttach(cb: (client: EventClient) => void): void;
77
+ onDetach(cb: (client: EventClient) => void): void;
78
+ }
79
+
80
+ export function createClientStore(): ClientStore {
81
+ const byId = new Map<string, MutableEventClient>();
82
+ const byUser = new Map<string, Set<string>>();
83
+ const attachCbs: Array<(client: EventClient) => void> = [];
84
+ const detachCbs: Array<(client: EventClient) => void> = [];
85
+
86
+ const indexUser = (client: EventClient): void => {
87
+ const userId = client.userId;
88
+ if (!userId) return;
89
+ let set = byUser.get(userId);
90
+ if (!set) {
91
+ set = new Set();
92
+ byUser.set(userId, set);
93
+ }
94
+ set.add(client.id);
95
+ };
96
+
97
+ const unindexUser = (client: EventClient): void => {
98
+ const userId = client.userId;
99
+ if (!userId) return;
100
+ const set = byUser.get(userId);
101
+ if (!set) return;
102
+ set.delete(client.id);
103
+ if (set.size === 0) byUser.delete(userId);
104
+ };
105
+
106
+ return {
107
+ get size(): number {
108
+ return byId.size;
109
+ },
110
+ attach(ws) {
111
+ const existing = byId.get(ws.data.id);
112
+ if (existing) return existing;
113
+ const client = createEventClient(ws);
114
+ byId.set(client.id, client);
115
+ indexUser(client);
116
+ for (const cb of attachCbs) cb(client);
117
+ return client;
118
+ },
119
+ detach(ws) {
120
+ const client = byId.get(ws.data.id);
121
+ if (!client) return undefined;
122
+ byId.delete(client.id);
123
+ unindexUser(client);
124
+ client.closed = true;
125
+ for (const cb of detachCbs) cb(client);
126
+ return client;
127
+ },
128
+ get(id) {
129
+ return byId.get(id);
130
+ },
131
+ all() {
132
+ return [...byId.values()];
133
+ },
134
+ byUser(userId) {
135
+ const ids = byUser.get(userId);
136
+ if (!ids) return [];
137
+ const out: EventClient[] = [];
138
+ for (const id of ids) {
139
+ const c = byId.get(id);
140
+ if (c) out.push(c);
141
+ }
142
+ return out;
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
+ },
157
+ setUserId(clientId, userId) {
158
+ const client = byId.get(clientId);
159
+ if (!client) return false;
160
+ client.ws.data.userId = userId;
161
+ unindexUser(client);
162
+ indexUser(client);
163
+ return true;
164
+ },
165
+ onAttach(cb) {
166
+ attachCbs.push(cb as (client: EventClient) => void);
167
+ },
168
+ onDetach(cb) {
169
+ detachCbs.push(cb as (client: EventClient) => void);
170
+ },
171
+ };
172
+ }
173
+
174
+ export type { ClientData };
@@ -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
+ }