@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
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Server composition root — wires `createServerState` + the action modules
3
+ * (auth / rooms / outbound / routing) into `Bun.serve`, and returns a plain
4
+ * `IgnServer` API object (no class, no `this`). This is the ONLY place that
5
+ * knows how the pieces fit together; each concern lives in its own module:
6
+ *
7
+ * client-info.ts — ClientInfo snapshot type + pure mapper
8
+ * http-routes.ts — fetch handler (health / clients / fallback)
9
+ * socket-lifecycle.ts — open/close handlers as `(state, ws)` actions
10
+ * metrics-view.ts — pure MetricsSnapshot assembly
11
+ *
12
+ * Generic over the wire stack: `createServer({ bindings })` with your own
13
+ * generated bindings types `publish` / `on` / ... against YOUR events. The
14
+ * default is the built-in registry, so existing code keeps working unchanged.
15
+ *
16
+ * Public entry: `public/server.ts` re-exports `createServer` + the types.
17
+ */
18
+ import type { ServerWebSocket } from "bun";
19
+ import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../../bindings/types";
20
+ import { createNatsBridge } from "../../bridge/nats";
21
+ import { createEventsHub, type EventsHubInternal } from "../../events/hub";
22
+ import type {
23
+ EventTraceRow,
24
+ EventTraceStats,
25
+ TraceQueryOptions,
26
+ } from "../../events/trace";
27
+ import { joinGroup as addToGroup } from "../groups";
28
+ import { setInt64GuardMode } from "../int64-guard";
29
+ import type { MetricsSnapshot } from "../metrics";
30
+ import { drainSocket, sendFrame } from "../outbound";
31
+ import {
32
+ activeGroups,
33
+ groupMembers as groupMemberIds,
34
+ publishToGroup as publishToGroupState,
35
+ leaveGroup as removeFromGroup,
36
+ } from "../groups";
37
+ import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "../rooms";
38
+ import { handleMessage } from "../routing";
39
+ import { createServerState, type IgnServerOptions, type WsData } from "../state";
40
+ import { toClientInfo, type ClientInfo } from "./client-info";
41
+ import { handleHttpRequest } from "./http-routes";
42
+ import { buildServerMetrics } from "./metrics-view";
43
+ import { onSocketClose, onSocketOpen } from "./socket-lifecycle";
44
+
45
+ export type { ClientInfo } from "./client-info";
46
+
47
+ /** The public server API (returned by `createServer`). */
48
+ export interface IgnServer<B extends Bindings = DefaultBindings> {
49
+ readonly port: number;
50
+ readonly clientCount: number;
51
+ getMetrics(): MetricsSnapshot;
52
+ /** Broadcast a typed event to every connected client (zero-alloc on the happy path). */
53
+ publish<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
54
+ /** Send a typed event to a single socket (zero-alloc on the happy path). */
55
+ publishTo<K extends EventNameOf<B>>(
56
+ ws: ServerWebSocket<WsData>,
57
+ name: K,
58
+ payload: EventsOf<B>[K],
59
+ ): void;
60
+ /** Publish a typed event to every socket subscribed to `topic`. */
61
+ publishToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
62
+ /** Send a typed event to a specific client by id. Returns false if that client is offline. */
63
+ publishToClient<K extends EventNameOf<B>>(id: string, name: K, payload: EventsOf<B>[K]): boolean;
64
+ /**
65
+ * Allow clients to send `name` (adds it to the inbound allowlist at runtime).
66
+ * The events layer calls this automatically when `server.events.on(name)` is
67
+ * first used.
68
+ */
69
+ allowInbound<K extends EventNameOf<B>>(name: K): IgnServer<B>;
70
+ /** Programmatic room membership (clients can also join via subscribe frames). */
71
+ join(topic: string, ws: ServerWebSocket<WsData>): void;
72
+ leave(topic: string, ws: ServerWebSocket<WsData>): void;
73
+ /** Live topic names (with at least one subscriber). */
74
+ topics(): string[];
75
+ /** Server-side group membership (targeted fan-out, no replay). */
76
+ joinGroup(id: string, group: string): void;
77
+ leaveGroup(id: string, group: string): void;
78
+ /** Publish a typed event to every client in a server-side group. */
79
+ publishToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
80
+ /** Live server-side group names (with at least one member). */
81
+ groups(): string[];
82
+ /** Client ids currently in `group`. */
83
+ groupMembers(group: string): string[];
84
+ /** Groups a client belongs to ([] if offline/unknown). */
85
+ clientGroups(id: string): string[];
86
+ /** Active-client introspection. */
87
+ getClient(id: string): ClientInfo | undefined;
88
+ getClients(): ClientInfo[];
89
+ /** Force-close a specific client socket (e.g. from a policy / admin action). */
90
+ closeClient(ws: ServerWebSocket<WsData>): void;
91
+ /** Disconnect a client by id. Returns false if that client is offline. */
92
+ disconnectClient(id: string): boolean;
93
+ /** Register a handler for an inbound app event (must be in `options.inbound`). */
94
+ on<K extends EventNameOf<B>>(
95
+ name: K,
96
+ handler: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => void,
97
+ ): IgnServer<B>;
98
+ off<K extends EventNameOf<B>>(name: K): IgnServer<B>;
99
+ /**
100
+ * Register a request/response responder for `name`: clients call it via
101
+ * `client.request(name, payload)` and receive the returned payload (encoded
102
+ * with the SAME event schema). Request/response share the event's schema.
103
+ */
104
+ handle<K extends EventNameOf<B>>(
105
+ name: K,
106
+ responder: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => Promise<EventsOf<B>[K]> | EventsOf<B>[K],
107
+ ): IgnServer<B>;
108
+ /**
109
+ * The event trace ring — what fired recently (emitted / published /
110
+ * received), with per-event aggregates. Debugger-facing (ignex debugbar,
111
+ * MCP); rows are materialized on read only.
112
+ */
113
+ getEventTrace(options?: TraceQueryOptions): {
114
+ enabled: boolean;
115
+ capacity: number;
116
+ stats: EventTraceStats;
117
+ recent: EventTraceRow[];
118
+ };
119
+ /** Drop all retained trace records (counters survive). */
120
+ clearEventTrace(): void;
121
+ /**
122
+ * The events hub — present when `createServer({ events: {...} })` is used:
123
+ * typed handlers (`server.events.on`), client records, groups, and the
124
+ * cluster-aware emit surface.
125
+ */
126
+ readonly events: import("../../events/types").EventsHub<B> | undefined;
127
+ /** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
128
+ drain(timeoutMs?: number): Promise<void>;
129
+ stop(force?: boolean): void;
130
+ }
131
+
132
+ export function createServer<B extends Bindings = DefaultBindings>(
133
+ options: IgnServerOptions<B>,
134
+ ): IgnServer<B> {
135
+ const state = createServerState(options);
136
+ setInt64GuardMode(options.int64Guard ?? "off");
137
+ const bindings = state.bindings;
138
+ const trace = state.trace; // hot-path local (one property load, ever)
139
+
140
+ // NATS bridge (optional, best-effort — created eagerly, connects in the background)
141
+ const natsOpt = options.nats;
142
+ if (natsOpt)
143
+ // discriminate on `subjects` (bridge-only surface): both bridges AND raw
144
+ // transports have `publish`, so a publish-based probe misclassifies
145
+ state.bridge = "subjects" in natsOpt ? natsOpt : createNatsBridge(natsOpt, undefined, bindings);
146
+
147
+ // Encode once. EXTERNAL copies (NATS bridge / cluster envelope) are taken
148
+ // BEFORE any per-socket delivery-seq stamping mutates the scratch header —
149
+ // external consumers must see pristine frames.
150
+
151
+ /** Encode + count + trace a broadcast frame (no delivery yet). */
152
+ function fanOutAll(name: string, payload: unknown): Uint8Array {
153
+ const frame = state.transport.encodeToScratch(name, payload);
154
+ state.metrics.published++;
155
+ trace.record("out.publish", name, "broadcast", undefined, frame.byteLength);
156
+ return frame;
157
+ }
158
+
159
+ /** Deliver an already-encoded frame to every connected socket. */
160
+ function fanOutAllLocal(frame: Uint8Array): void {
161
+ for (const ws of state.sockets) sendFrame(state, ws, frame);
162
+ }
163
+
164
+ let eventsHub: EventsHubInternal<B> | undefined;
165
+
166
+ if (state.bridge) {
167
+ state.bridge.setOnInbound((name, payload) => {
168
+ // trace: the event ARRIVED from the bridge, then is fanned out locally
169
+ // by fanOutAll below (which records its own out.publish row).
170
+ const id = bindings.anyEventNameToId[name];
171
+ if (id !== undefined) trace.record("in.bridge", name, undefined, undefined, 0);
172
+ const frame = fanOutAll(name, payload);
173
+ fanOutAllLocal(frame);
174
+ // server-side handling of externally-published events (the events layer)
175
+ eventsHub?.dispatchBridgeInbound(name, payload);
176
+ });
177
+ }
178
+
179
+ const bun = Bun.serve<WsData>({
180
+ port: options.port,
181
+ ...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
182
+ idleTimeout: options.idleTimeout ?? 30,
183
+ ...(options.tls !== undefined ? { tls: options.tls } : {}),
184
+ fetch: (req, srv) => handleHttpRequest(state, req, srv, options.fetch),
185
+ websocket: {
186
+ open: (ws) => onSocketOpen(state, ws),
187
+ close: (ws) => onSocketClose(state, ws),
188
+ message: (ws, msg) => handleMessage(state, ws, msg),
189
+ drain: (ws) => drainSocket(state, ws),
190
+ },
191
+ });
192
+
193
+ // ── publish actions (encode once → bridge copy → local fan-out) ──────
194
+
195
+ const api: IgnServer<B> = {
196
+ get port(): number {
197
+ return bun.port ?? 0;
198
+ },
199
+ get clientCount(): number {
200
+ return state.sockets.size;
201
+ },
202
+ getMetrics(): MetricsSnapshot {
203
+ return buildServerMetrics(state, eventsHub?.metrics());
204
+ },
205
+ getEventTrace(options?: TraceQueryOptions) {
206
+ return {
207
+ enabled: state.trace.enabled,
208
+ capacity: state.trace.capacity,
209
+ stats: state.trace.stats(),
210
+ recent: state.trace.recent(options),
211
+ };
212
+ },
213
+ clearEventTrace() {
214
+ state.trace.clear();
215
+ },
216
+ publish(name, payload) {
217
+ const frame = fanOutAll(name, payload);
218
+ // pristine frame out FIRST (bridge copies), then seq-stamped local sends
219
+ state.bridge?.publish(state.bridge.subjects.broadcast(name), frame);
220
+ fanOutAllLocal(frame);
221
+ },
222
+ publishTo(ws, name, payload) {
223
+ state.metrics.published++;
224
+ const frame = state.transport.encodeToScratch(name, payload);
225
+ trace.record("out.publish", name, "client", ws.data.id, frame.byteLength);
226
+ sendFrame(state, ws, frame);
227
+ },
228
+ publishToTopic(topic, name, payload) {
229
+ const frame = state.transport.encodeToScratch(name, payload);
230
+ state.metrics.published++;
231
+ trace.record("out.publish", name, "topic", topic, frame.byteLength);
232
+ state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
233
+ publishToRoom(state, topic, frame);
234
+ },
235
+ publishToClient(id, name, payload) {
236
+ const ws = state.clients.get(id);
237
+ if (!ws) return false;
238
+ state.metrics.published++;
239
+ const frame = state.transport.encodeToScratch(name, payload);
240
+ trace.record("out.publish", name, "client", id, frame.byteLength);
241
+ sendFrame(state, ws, frame);
242
+ return true;
243
+ },
244
+ allowInbound(name) {
245
+ state.inbound.add(name);
246
+ return api;
247
+ },
248
+ join(topic, ws) {
249
+ joinRoom(state, ws, topic);
250
+ },
251
+ leave(topic, ws) {
252
+ leaveRoom(state, ws, topic);
253
+ },
254
+ topics() {
255
+ return roomTopics(state);
256
+ },
257
+ joinGroup(id, group) {
258
+ const ws = state.clients.get(id);
259
+ if (ws) addToGroup(state, ws, group);
260
+ },
261
+ leaveGroup(id, group) {
262
+ const ws = state.clients.get(id);
263
+ if (ws) removeFromGroup(state, ws, group);
264
+ },
265
+ publishToGroup(group, name, payload) {
266
+ const frame = state.transport.encodeToScratch(name, payload);
267
+ state.metrics.published++;
268
+ trace.record("out.publish", name, "group", group, frame.byteLength);
269
+ state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
270
+ publishToGroupState(state, group, frame);
271
+ },
272
+ groups() {
273
+ return activeGroups(state);
274
+ },
275
+ groupMembers(group) {
276
+ return groupMemberIds(state, group);
277
+ },
278
+ clientGroups(id) {
279
+ const ws = state.clients.get(id);
280
+ return ws ? [...ws.data.groups] : [];
281
+ },
282
+ getClient(id) {
283
+ const ws = state.clients.get(id);
284
+ return ws ? toClientInfo(ws) : undefined;
285
+ },
286
+ getClients() {
287
+ return [...state.clients.values()].map(toClientInfo);
288
+ },
289
+ closeClient(ws) {
290
+ ws.close(1000, "closed by server");
291
+ },
292
+ disconnectClient(id) {
293
+ const ws = state.clients.get(id);
294
+ if (!ws) return false;
295
+ ws.close(1000, "closed by server");
296
+ return true;
297
+ },
298
+ on(name, handler) {
299
+ state.inboundHandlers.set(
300
+ name,
301
+ handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void,
302
+ );
303
+ return api;
304
+ },
305
+ off(name) {
306
+ state.inboundHandlers.delete(name);
307
+ return api;
308
+ },
309
+ handle(name, responder) {
310
+ state.rpcHandlers.set(
311
+ name,
312
+ responder as (
313
+ payload: unknown,
314
+ ws: ServerWebSocket<WsData>,
315
+ ) => Promise<unknown>,
316
+ );
317
+ return api;
318
+ },
319
+ get events() {
320
+ return eventsHub;
321
+ },
322
+ async drain(timeoutMs = 2000): Promise<void> {
323
+ bun.stop(false); // stop listening; keep active sockets draining
324
+ await eventsHub?.close();
325
+ await state.bridge?.close();
326
+ const deadline = Date.now() + timeoutMs;
327
+ while (state.sockets.size > 0 && Date.now() < deadline) {
328
+ await Bun.sleep(10);
329
+ }
330
+ bun.stop(true);
331
+ },
332
+ stop(force = true): void {
333
+ bun.stop(force);
334
+ void eventsHub?.close();
335
+ void state.bridge?.close();
336
+ },
337
+ };
338
+
339
+ // ── events layer (opt-in) ────────────────────────────────────────────
340
+ if (options.events) {
341
+ eventsHub = createEventsHub({
342
+ state,
343
+ server: api,
344
+ bindings,
345
+ ...(state.bridge !== undefined ? { serverBridge: state.bridge } : {}),
346
+ options: options.events,
347
+ });
348
+ state.onConnect = (ws) => {
349
+ eventsHub!.attach(ws);
350
+ };
351
+ state.onDisconnect = (ws) => {
352
+ eventsHub!.detach(ws);
353
+ };
354
+ state.onGroupChange = (group, ws, joined) => {
355
+ eventsHub!.onGroupChange(group, ws, joined);
356
+ };
357
+ }
358
+
359
+ return api;
360
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Metrics view — assembles the server-level `MetricsSnapshot` by folding the
3
+ * transport's cumulative per-path encode counters and the bridge stats into
4
+ * the core metrics snapshot. Pure assembly: no counter is re-accumulated, so
5
+ * polling `getMetrics()` repeatedly never inflates what it reports.
6
+ */
7
+ import type { NatsBridge } from "../../bridge/nats";
8
+ import type { MetricsSnapshot } from "../metrics";
9
+ import type { ServerState } from "../state";
10
+
11
+ /**
12
+ * Derive per-event path counts from the transport's OWN cumulative counters
13
+ * (totals since start) — a pure projection of `{direct,json,js}` per name.
14
+ */
15
+ function buildPathCounts(state: ServerState): MetricsSnapshot["pathCounts"] {
16
+ const encodeStats = state.transport.getEncodeStats();
17
+ const pathCountsObj: Record<string, { direct: number; json: number; js: number }> = {};
18
+ const names = new Set<string>([
19
+ ...Object.keys(encodeStats.direct),
20
+ ...Object.keys(encodeStats.json),
21
+ ...Object.keys(encodeStats.js),
22
+ ]);
23
+ for (const name of names) {
24
+ const direct = encodeStats.direct[name] ?? 0;
25
+ const json = encodeStats.json[name] ?? 0;
26
+ const js = encodeStats.js[name] ?? 0;
27
+ if (direct > 0 || json > 0 || js > 0) pathCountsObj[name] = { direct, json, js };
28
+ }
29
+ return pathCountsObj;
30
+ }
31
+
32
+ /** Fold bridge counters into the snapshot (only when a bridge is wired). */
33
+ function foldBridgeStats(snapshot: MetricsSnapshot, bridge: NatsBridge | undefined): void {
34
+ if (!bridge) return;
35
+ snapshot.bridged = bridge.stats.bridged;
36
+ snapshot.bridgedBytes = bridge.stats.bridgedBytes;
37
+ snapshot.bridgeErrors = bridge.stats.bridgeErrors;
38
+ snapshot.bridgeInbound = bridge.stats.bridgeInbound;
39
+ snapshot.bridgeInboundErrors = bridge.stats.bridgeInboundErrors;
40
+ snapshot.natsStatus = bridge.status;
41
+ }
42
+
43
+ /** Build the complete server metrics snapshot for `getMetrics()`. */
44
+ export function buildServerMetrics(
45
+ state: ServerState,
46
+ eventsMetrics: MetricsSnapshot["events"],
47
+ ): MetricsSnapshot {
48
+ const snapshot = state.metrics.snapshot(state.sockets.size);
49
+ snapshot.pathCounts = buildPathCounts(state);
50
+ foldBridgeStats(snapshot, state.bridge);
51
+ if (eventsMetrics !== undefined) snapshot.events = eventsMetrics;
52
+ return snapshot;
53
+ }
@@ -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
+ }