@ignex/nova 0.1.1 → 0.1.3

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 (67) hide show
  1. package/README.md +132 -32
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +232 -0
  4. package/docs/architecture.md +35 -10
  5. package/docs/events.md +170 -0
  6. package/docs/generic-bindings.md +197 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +9 -2
  9. package/index.ts +75 -27
  10. package/package.json +12 -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 +416 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +42 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/transcode/generated.rs +2 -1
  21. package/src/bindings/assemble.ts +73 -0
  22. package/src/bindings/default.ts +65 -0
  23. package/src/bindings/types.ts +113 -0
  24. package/src/bridge/nats.ts +53 -13
  25. package/src/bridge/subjects.ts +3 -0
  26. package/src/codegen/constants.ts +18 -0
  27. package/src/codegen/direct-gen.ts +550 -0
  28. package/src/codegen/fingerprint.ts +44 -0
  29. package/src/codegen/hash.ts +25 -0
  30. package/src/codegen/registry-gen.ts +242 -0
  31. package/src/codegen/rust-glue-gen.ts +545 -0
  32. package/src/codegen/schema-model.ts +338 -0
  33. package/src/codegen/ts-ser-gen.ts +221 -0
  34. package/src/codegen/typebox-to-fbs.ts +60 -0
  35. package/src/core/auth.ts +2 -1
  36. package/src/core/client-heartbeat.ts +2 -1
  37. package/src/core/client-reconnect.ts +9 -2
  38. package/src/core/client-state.ts +21 -8
  39. package/src/core/client-wire.ts +10 -11
  40. package/src/core/client.ts +34 -29
  41. package/src/core/groups.ts +3 -0
  42. package/src/core/metrics.ts +7 -3
  43. package/src/core/outbound.ts +12 -5
  44. package/src/core/routing.ts +17 -8
  45. package/src/core/server.ts +108 -34
  46. package/src/core/state.ts +51 -13
  47. package/src/events/clients.ts +156 -0
  48. package/src/events/cluster.ts +732 -0
  49. package/src/events/data.ts +38 -0
  50. package/src/events/emit.ts +127 -0
  51. package/src/events/global.ts +117 -0
  52. package/src/events/groups.ts +118 -0
  53. package/src/events/hub.ts +481 -0
  54. package/src/events/index.ts +61 -0
  55. package/src/events/queue.ts +96 -0
  56. package/src/events/registry.ts +178 -0
  57. package/src/events/types.ts +378 -0
  58. package/src/generated/direct-ser.ts +2 -1
  59. package/src/generated/fbs/backend.fbs +1 -1
  60. package/src/generated/registry.ts +3 -1
  61. package/src/generated/ts-ser.ts +1 -1
  62. package/src/generated/wire-registry.json +1 -0
  63. package/src/native/ffi.ts +85 -28
  64. package/src/schema/index.ts +5 -2
  65. package/src/server.ts +7 -3
  66. package/src/transport/stats.ts +8 -4
  67. package/src/transport/transport.ts +149 -68
@@ -4,31 +4,37 @@
4
4
  * `IgnServer` API object (no class, no `this`). This is the ONLY place that
5
5
  * knows how the pieces fit together.
6
6
  *
7
+ * Generic over the wire stack: `createServer({ bindings })` with your own
8
+ * generated bindings types `publish` / `on` / ... against YOUR events. The
9
+ * default is the built-in registry, so existing code keeps working unchanged.
10
+ *
7
11
  * Public entry: `public/server.ts` re-exports `createServer` + the types.
8
12
  */
9
13
  import type { ServerWebSocket } from "bun";
10
- import { WIRE_VERSION } from "../generated/registry";
11
- import type { Events, EventName } from "../schema";
12
- import { getEncodeStats, encodeToScratch } from "../transport/transport";
13
- import { setInt64GuardMode } from "./int64-guard";
14
- import type { MetricsSnapshot } from "./metrics";
14
+ import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
15
+ import { createNatsBridge } from "../bridge/nats";
16
+ import { createEventsHub, type EventsHubInternal } from "../events/hub";
17
+ import type { EventsHub } from "../events/types";
15
18
  import { checkUpgrade } from "./auth";
16
- import { drainSocket, sendControl, sendFrame } from "./outbound";
17
- import { handleMessage } from "./routing";
18
- import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "./rooms";
19
19
  import {
20
20
  activeGroups,
21
- groupMembers as groupMemberIds,
22
21
  joinGroup as addToGroup,
23
- leaveGroup as removeFromGroup,
22
+ groupMembers as groupMemberIds,
24
23
  publishToGroup as publishToGroupState,
24
+ leaveGroup as removeFromGroup,
25
25
  } from "./groups";
26
+ import { setInt64GuardMode } from "./int64-guard";
27
+ import type { MetricsSnapshot } from "./metrics";
28
+ import { drainSocket, sendControl, sendFrame } from "./outbound";
29
+ import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "./rooms";
30
+ import { handleMessage } from "./routing";
26
31
  import { createServerState, type IgnServerOptions, type WsData } from "./state";
27
- import { createNatsBridge } from "../bridge/nats";
28
32
 
29
33
  /** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
30
34
  export interface ClientInfo {
31
35
  id: string;
36
+ /** identity this connection acts on behalf of (undefined if none) */
37
+ userId?: string;
32
38
  /** arbitrary app metadata from `authenticate` (undefined if none) */
33
39
  meta?: Record<string, unknown>;
34
40
  /** server-side groups this client belongs to */
@@ -44,7 +50,8 @@ export interface ClientInfo {
44
50
  function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
45
51
  return {
46
52
  id: ws.data.id,
47
- meta: ws.data.meta,
53
+ ...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
54
+ ...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
48
55
  groups: [...ws.data.groups],
49
56
  topics: [...ws.data.topics],
50
57
  connectedAt: ws.data.connectedAt,
@@ -53,18 +60,28 @@ function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
53
60
  }
54
61
 
55
62
  /** The public server API (returned by `createServer`). */
56
- export interface IgnServer {
63
+ export interface IgnServer<B extends Bindings = DefaultBindings> {
57
64
  readonly port: number;
58
65
  readonly clientCount: number;
59
66
  getMetrics(): MetricsSnapshot;
60
67
  /** Broadcast a typed event to every connected client (zero-alloc on the happy path). */
61
- publish<K extends EventName>(name: K, payload: Events[K]): void;
68
+ publish<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
62
69
  /** Send a typed event to a single socket (zero-alloc on the happy path). */
63
- publishTo<K extends EventName>(ws: ServerWebSocket<WsData>, name: K, payload: Events[K]): void;
70
+ publishTo<K extends EventNameOf<B>>(
71
+ ws: ServerWebSocket<WsData>,
72
+ name: K,
73
+ payload: EventsOf<B>[K],
74
+ ): void;
64
75
  /** Publish a typed event to every socket subscribed to `topic`. */
65
- publishToTopic<K extends EventName>(topic: string, name: K, payload: Events[K]): void;
76
+ publishToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
66
77
  /** Send a typed event to a specific client by id. Returns false if that client is offline. */
67
- publishToClient<K extends EventName>(id: string, name: K, payload: Events[K]): boolean;
78
+ publishToClient<K extends EventNameOf<B>>(id: string, name: K, payload: EventsOf<B>[K]): boolean;
79
+ /**
80
+ * Allow clients to send `name` (adds it to the inbound allowlist at runtime).
81
+ * The events layer calls this automatically when `server.events.on(name)` is
82
+ * first used.
83
+ */
84
+ allowInbound<K extends EventNameOf<B>>(name: K): IgnServer<B>;
68
85
  /** Programmatic room membership (clients can also join via subscribe frames). */
69
86
  join(topic: string, ws: ServerWebSocket<WsData>): void;
70
87
  leave(topic: string, ws: ServerWebSocket<WsData>): void;
@@ -74,7 +91,7 @@ export interface IgnServer {
74
91
  joinGroup(id: string, group: string): void;
75
92
  leaveGroup(id: string, group: string): void;
76
93
  /** Publish a typed event to every client in a server-side group. */
77
- publishToGroup<K extends EventName>(group: string, name: K, payload: Events[K]): void;
94
+ publishToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
78
95
  /** Live server-side group names (with at least one member). */
79
96
  groups(): string[];
80
97
  /** Client ids currently in `group`. */
@@ -89,42 +106,59 @@ export interface IgnServer {
89
106
  /** Disconnect a client by id. Returns false if that client is offline. */
90
107
  disconnectClient(id: string): boolean;
91
108
  /** Register a handler for an inbound app event (must be in `options.inbound`). */
92
- on<K extends EventName>(name: K, handler: (payload: Events[K], ws: ServerWebSocket<WsData>) => void): IgnServer;
93
- off<K extends EventName>(name: K): IgnServer;
109
+ on<K extends EventNameOf<B>>(
110
+ name: K,
111
+ handler: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => void,
112
+ ): IgnServer<B>;
113
+ off<K extends EventNameOf<B>>(name: K): IgnServer<B>;
114
+ /**
115
+ * The events hub — present when `createServer({ events: {...} })` is used:
116
+ * typed handlers (`server.events.on`), client records, groups, and the
117
+ * cluster-aware emit surface.
118
+ */
119
+ readonly events: EventsHub<B> | undefined;
94
120
  /** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
95
121
  drain(timeoutMs?: number): Promise<void>;
96
122
  stop(force?: boolean): void;
97
123
  }
98
124
 
99
- export function createServer(options: IgnServerOptions): IgnServer {
125
+ export function createServer<B extends Bindings = DefaultBindings>(
126
+ options: IgnServerOptions<B>,
127
+ ): IgnServer<B> {
100
128
  const state = createServerState(options);
101
129
  setInt64GuardMode(options.int64Guard ?? "off");
130
+ const bindings = state.bindings;
102
131
 
103
132
  // NATS bridge (optional, best-effort — created eagerly, connects in the background)
104
133
  const natsOpt = options.nats;
105
- if (natsOpt) state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt);
134
+ if (natsOpt)
135
+ state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt, undefined, bindings);
106
136
 
107
137
  // Encode once + broadcast to every connected client (NO bridge) — the shared
108
138
  // hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
109
139
  // events reach clients but are never re-bridged to NATS.
110
- function fanOutAll(name: Parameters<typeof encodeToScratch>[0], payload: unknown): Uint8Array {
111
- const frame = encodeToScratch(name, payload);
140
+ function fanOutAll(name: string, payload: unknown): Uint8Array {
141
+ const frame = state.transport.encodeToScratch(name, payload);
112
142
  state.metrics.published++;
113
143
  for (const ws of state.sockets) sendFrame(state, ws, frame);
114
144
  return frame;
115
145
  }
116
146
 
147
+ let eventsHub: EventsHubInternal<B> | undefined;
148
+
117
149
  if (state.bridge) {
118
150
  state.bridge.setOnInbound((name, payload) => {
119
151
  fanOutAll(name, payload);
152
+ // server-side handling of externally-published events (the events layer)
153
+ eventsHub?.dispatchBridgeInbound(name, payload);
120
154
  });
121
155
  }
122
156
 
123
157
  const bun = Bun.serve<WsData>({
124
158
  port: options.port,
125
- hostname: options.hostname,
159
+ ...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
126
160
  idleTimeout: options.idleTimeout ?? 30,
127
- tls: options.tls,
161
+ ...(options.tls !== undefined ? { tls: options.tls } : {}),
128
162
  fetch: (req, srv) => {
129
163
  const url = new URL(req.url);
130
164
  if (url.pathname === state.path) return checkUpgrade(state, req, srv);
@@ -150,13 +184,17 @@ export function createServer(options: IgnServerOptions): IgnServer {
150
184
  const existing = state.clients.get(ws.data.id);
151
185
  if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
152
186
  state.clients.set(ws.data.id, ws);
187
+ // events-layer attach (client record + presence) BEFORE group seeding
188
+ state.onConnect?.(ws);
153
189
  for (const g of ws.data.groups) addToGroup(state, ws, g);
154
190
  // announce our wire version + capabilities so clients can negotiate
155
- sendControl(state, ws, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
191
+ sendControl(state, ws, "hello", { version: bindings.wireVersion, caps: [], lastSeq: 0 });
156
192
  // then assign identity so the client knows its id + server-side groups
157
193
  sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
158
194
  },
159
195
  close: (ws) => {
196
+ // events-layer detach FIRST (client record still carries groups/topics)
197
+ state.onDisconnect?.(ws);
160
198
  state.sockets.delete(ws);
161
199
  state.clients.delete(ws.data.id);
162
200
  for (const g of ws.data.groups) removeFromGroup(state, ws, g);
@@ -170,7 +208,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
170
208
  },
171
209
  });
172
210
 
173
- const api: IgnServer = {
211
+ const api: IgnServer<B> = {
174
212
  get port(): number {
175
213
  return bun.port ?? 0;
176
214
  },
@@ -178,13 +216,16 @@ export function createServer(options: IgnServerOptions): IgnServer {
178
216
  return state.sockets.size;
179
217
  },
180
218
  getMetrics(): MetricsSnapshot {
181
- const stats = getEncodeStats();
219
+ const stats = state.transport.getEncodeStats();
182
220
  for (const [name, n] of Object.entries(stats.direct)) {
183
221
  if (n > 0) state.metrics.countPath(name, "direct");
184
222
  }
185
223
  for (const [name, n] of Object.entries(stats.json)) {
186
224
  if (n > 0) state.metrics.countPath(name, "json");
187
225
  }
226
+ for (const [name, n] of Object.entries(stats.js)) {
227
+ if (n > 0) state.metrics.countPath(name, "js");
228
+ }
188
229
  const snapshot = state.metrics.snapshot(state.sockets.size);
189
230
  const b = state.bridge;
190
231
  if (b) {
@@ -195,6 +236,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
195
236
  snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
196
237
  snapshot.natsStatus = b.status;
197
238
  }
239
+ if (eventsHub) snapshot.events = eventsHub.metrics();
198
240
  return snapshot;
199
241
  },
200
242
  publish(name, payload) {
@@ -203,10 +245,10 @@ export function createServer(options: IgnServerOptions): IgnServer {
203
245
  },
204
246
  publishTo(ws, name, payload) {
205
247
  state.metrics.published++;
206
- sendFrame(state, ws, encodeToScratch(name, payload));
248
+ sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
207
249
  },
208
250
  publishToTopic(topic, name, payload) {
209
- const frame = encodeToScratch(name, payload);
251
+ const frame = state.transport.encodeToScratch(name, payload);
210
252
  state.metrics.published++;
211
253
  publishToRoom(state, topic, frame);
212
254
  state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
@@ -215,9 +257,13 @@ export function createServer(options: IgnServerOptions): IgnServer {
215
257
  const ws = state.clients.get(id);
216
258
  if (!ws) return false;
217
259
  state.metrics.published++;
218
- sendFrame(state, ws, encodeToScratch(name, payload));
260
+ sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
219
261
  return true;
220
262
  },
263
+ allowInbound(name) {
264
+ state.inbound.add(name);
265
+ return api;
266
+ },
221
267
  join(topic, ws) {
222
268
  joinRoom(state, ws, topic);
223
269
  },
@@ -236,7 +282,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
236
282
  if (ws) removeFromGroup(state, ws, group);
237
283
  },
238
284
  publishToGroup(group, name, payload) {
239
- const frame = encodeToScratch(name, payload);
285
+ const frame = state.transport.encodeToScratch(name, payload);
240
286
  state.metrics.published++;
241
287
  publishToGroupState(state, group, frame);
242
288
  state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
@@ -268,15 +314,22 @@ export function createServer(options: IgnServerOptions): IgnServer {
268
314
  return true;
269
315
  },
270
316
  on(name, handler) {
271
- state.inboundHandlers.set(name, handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void);
317
+ state.inboundHandlers.set(
318
+ name,
319
+ handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void,
320
+ );
272
321
  return api;
273
322
  },
274
323
  off(name) {
275
324
  state.inboundHandlers.delete(name);
276
325
  return api;
277
326
  },
327
+ get events(): EventsHub<B> | undefined {
328
+ return eventsHub;
329
+ },
278
330
  async drain(timeoutMs = 2000): Promise<void> {
279
331
  bun.stop(false); // stop listening; keep active sockets draining
332
+ await eventsHub?.close();
280
333
  await state.bridge?.close();
281
334
  const deadline = Date.now() + timeoutMs;
282
335
  while (state.sockets.size > 0 && Date.now() < deadline) {
@@ -286,9 +339,30 @@ export function createServer(options: IgnServerOptions): IgnServer {
286
339
  },
287
340
  stop(force = true): void {
288
341
  bun.stop(force);
342
+ void eventsHub?.close();
289
343
  void state.bridge?.close();
290
344
  },
291
345
  };
292
346
 
347
+ // ── events layer (opt-in) ─────────────────────────────────────────────
348
+ if (options.events) {
349
+ eventsHub = createEventsHub({
350
+ state,
351
+ server: api,
352
+ bindings,
353
+ ...(state.bridge !== undefined ? { serverBridge: state.bridge } : {}),
354
+ options: options.events,
355
+ });
356
+ state.onConnect = (ws) => {
357
+ eventsHub!.attach(ws);
358
+ };
359
+ state.onDisconnect = (ws) => {
360
+ eventsHub!.detach(ws);
361
+ };
362
+ state.onGroupChange = (group, ws, joined) => {
363
+ eventsHub!.onGroupChange(group, ws, joined);
364
+ };
365
+ }
366
+
293
367
  return api;
294
368
  }
package/src/core/state.ts CHANGED
@@ -7,11 +7,13 @@
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 { createTransport, defaultTransport, type Transport } from "../transport/transport";
11
14
  import type { Int64GuardMode } from "./int64-guard";
12
15
  import { createMetrics, type Metrics } from "./metrics";
13
- import { RingBuffer } from "./ring";
14
- import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
16
+ import type { RingBuffer } from "./ring";
15
17
 
16
18
  /**
17
19
  * Optional identity metadata a client may carry for targeting / grouping.
@@ -20,6 +22,12 @@ import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
20
22
  export interface ClientMeta {
21
23
  /** explicit client id; omitted → auto-assigned `crypto.randomUUID()` */
22
24
  id?: string;
25
+ /**
26
+ * The identity this connection acts ON BEHALF OF (e.g. the logged-in user).
27
+ * Several connections may share a `userId` (multi-tab / multi-device) — the
28
+ * events layer groups them for user-targeted emits (`hub.emitToUser`).
29
+ */
30
+ userId?: string;
23
31
  /** server-side groups this client belongs to on connect */
24
32
  groups?: string[];
25
33
  /** arbitrary app metadata (exposed via `getClient` / `getClients`) */
@@ -41,6 +49,8 @@ export interface WsData {
41
49
  groups: Set<string>;
42
50
  /** stable client id (auth metadata or auto-generated UUID) */
43
51
  id: string;
52
+ /** identity this connection acts on behalf of (undefined = anonymous) */
53
+ userId?: string;
44
54
  /** arbitrary app metadata from `authenticate` (undefined if none) */
45
55
  meta?: Record<string, unknown>;
46
56
  /** epoch ms when the socket opened (for `getClients` ordering/uptime) */
@@ -61,15 +71,22 @@ export interface IgnBackpressureOptions {
61
71
  maxQueue?: number;
62
72
  }
63
73
 
64
- export interface IgnServerOptions {
74
+ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
65
75
  port: number;
66
76
  hostname?: string;
67
77
  /** seconds; 0 = no timeout */
68
78
  idleTimeout?: number;
69
79
  /** websocket path, default "/ws" */
70
80
  path?: string;
81
+ /**
82
+ * The wire stack (event ids, decoders, encoders). Defaults to the built-in
83
+ * registry; pass your own (from `generateBindings` + `assembleBindings`) to
84
+ * serve YOUR schema. When provided, the server API (`publish` / `on` / ...)
85
+ * is typed against your `Events`.
86
+ */
87
+ bindings?: B;
71
88
  /** app events clients are ALLOWED to send; control events are always allowed. default [] */
72
- inbound?: EventName[];
89
+ inbound?: EventNameOf<B>[];
73
90
  /** slow-consumer protection. default: off (unbounded buffering) */
74
91
  backpressure?: IgnBackpressureOptions;
75
92
  /**
@@ -117,6 +134,13 @@ export interface IgnServerOptions {
117
134
  * NATS.
118
135
  */
119
136
  nats?: NatsBridgeOptions | NatsBridge;
137
+ /**
138
+ * Enable the events layer (typed event handlers + the global emit, client
139
+ * records with per-connection data, groups, optional cluster sync). Exposed
140
+ * as `server.events`; the module-global `emit`/`on` singleton
141
+ * (`ignex-nova/events`) is bound by default.
142
+ */
143
+ events?: import("../events/types").EventsOptions<B>;
120
144
  /** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
121
145
  fetch?: (req: Request) => Response | Promise<Response>;
122
146
  }
@@ -125,8 +149,12 @@ type InboundHandler = (payload: unknown, ws: ServerWebSocket<WsData>) => void;
125
149
 
126
150
  /** The full, explicit server state — created once per server, passed to actions. */
127
151
  export interface ServerState {
152
+ /** the wire stack this server speaks (ids / decoders / encoders). */
153
+ bindings: Bindings;
154
+ /** per-server encoder (scratch + FFI binding or pure-JS fallback). */
155
+ transport: Transport;
128
156
  path: string;
129
- inbound: ReadonlySet<EventName>;
157
+ inbound: Set<string>;
130
158
  bp: Required<IgnBackpressureOptions> | null;
131
159
  metrics: Metrics;
132
160
  startedAt: number;
@@ -144,13 +172,23 @@ export interface ServerState {
144
172
  groups: Map<string, Set<ServerWebSocket<WsData>>>;
145
173
  /** optional NATS bridge (wired in createServer when `options.nats` is set) */
146
174
  bridge?: NatsBridge;
147
- inboundHandlers: Map<EventName, InboundHandler>;
175
+ inboundHandlers: Map<string, InboundHandler>;
148
176
  topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
149
177
  replaySeq: number;
178
+ /** events-layer lifecycle hooks (wired by createServer when `events` is set) */
179
+ onConnect?: (ws: ServerWebSocket<WsData>) => void;
180
+ onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
181
+ /** fired on ANY group membership change (auth seed, control frames, programmatic) */
182
+ onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
150
183
  }
151
184
 
152
- export function createServerState(options: IgnServerOptions): ServerState {
185
+ export function createServerState<B extends Bindings = DefaultBindings>(
186
+ options: IgnServerOptions<B>,
187
+ ): ServerState {
188
+ const bindings = options.bindings ?? defaultBindings;
153
189
  return {
190
+ bindings,
191
+ transport: bindings === defaultBindings ? defaultTransport : createTransport(bindings),
154
192
  path: options.path ?? "/ws",
155
193
  inbound: new Set(options.inbound ?? []),
156
194
  bp: options.backpressure
@@ -162,11 +200,11 @@ export function createServerState(options: IgnServerOptions): ServerState {
162
200
  : null,
163
201
  metrics: createMetrics(),
164
202
  startedAt: Date.now(),
165
- authenticate: options.authenticate,
166
- allowedOrigins: options.allowedOrigins,
167
- token: options.token,
168
- maxConnections: options.maxConnections,
169
- maxMessageSize: options.maxMessageSize,
203
+ ...(options.authenticate !== undefined ? { authenticate: options.authenticate } : {}),
204
+ ...(options.allowedOrigins !== undefined ? { allowedOrigins: options.allowedOrigins } : {}),
205
+ ...(options.token !== undefined ? { token: options.token } : {}),
206
+ ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
207
+ ...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
170
208
  replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
171
209
  sockets: new Set(),
172
210
  clients: new Map(),
@@ -0,0 +1,156 @@
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
+ setUserId(clientId: string, userId: string): boolean;
71
+ onAttach(cb: (client: EventClient) => void): void;
72
+ onDetach(cb: (client: EventClient) => void): void;
73
+ }
74
+
75
+ export function createClientStore(): ClientStore {
76
+ const byId = new Map<string, MutableEventClient>();
77
+ const byUser = new Map<string, Set<string>>();
78
+ const attachCbs: Array<(client: EventClient) => void> = [];
79
+ const detachCbs: Array<(client: EventClient) => void> = [];
80
+
81
+ const indexUser = (client: EventClient): void => {
82
+ const userId = client.userId;
83
+ if (!userId) return;
84
+ let set = byUser.get(userId);
85
+ if (!set) {
86
+ set = new Set();
87
+ byUser.set(userId, set);
88
+ }
89
+ set.add(client.id);
90
+ };
91
+
92
+ const unindexUser = (client: EventClient): void => {
93
+ const userId = client.userId;
94
+ if (!userId) return;
95
+ const set = byUser.get(userId);
96
+ if (!set) return;
97
+ set.delete(client.id);
98
+ if (set.size === 0) byUser.delete(userId);
99
+ };
100
+
101
+ return {
102
+ get size(): number {
103
+ return byId.size;
104
+ },
105
+ attach(ws) {
106
+ const existing = byId.get(ws.data.id);
107
+ if (existing) return existing;
108
+ const client = createEventClient(ws);
109
+ byId.set(client.id, client);
110
+ indexUser(client);
111
+ for (const cb of attachCbs) cb(client);
112
+ return client;
113
+ },
114
+ detach(ws) {
115
+ const client = byId.get(ws.data.id);
116
+ if (!client) return undefined;
117
+ byId.delete(client.id);
118
+ unindexUser(client);
119
+ client.closed = true;
120
+ for (const cb of detachCbs) cb(client);
121
+ return client;
122
+ },
123
+ get(id) {
124
+ return byId.get(id);
125
+ },
126
+ all() {
127
+ return [...byId.values()];
128
+ },
129
+ byUser(userId) {
130
+ const ids = byUser.get(userId);
131
+ if (!ids) return [];
132
+ const out: EventClient[] = [];
133
+ for (const id of ids) {
134
+ const c = byId.get(id);
135
+ if (c) out.push(c);
136
+ }
137
+ return out;
138
+ },
139
+ setUserId(clientId, userId) {
140
+ const client = byId.get(clientId);
141
+ if (!client) return false;
142
+ client.ws.data.userId = userId;
143
+ unindexUser(client);
144
+ indexUser(client);
145
+ return true;
146
+ },
147
+ onAttach(cb) {
148
+ attachCbs.push(cb as (client: EventClient) => void);
149
+ },
150
+ onDetach(cb) {
151
+ detachCbs.push(cb as (client: EventClient) => void);
152
+ },
153
+ };
154
+ }
155
+
156
+ export type { ClientData };