@ignex/nova 0.1.3 → 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 (98) hide show
  1. package/README.md +4 -1
  2. package/docs/ai/TREE.md +69 -9
  3. package/docs/architecture.md +75 -27
  4. package/docs/events.md +83 -1
  5. package/docs/generic-bindings.md +10 -0
  6. package/docs/wire-format.md +65 -18
  7. package/package.json +2 -1
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/generate.ts +97 -3
  10. package/public/server.ts +10 -0
  11. package/rust/src/generated/backend.rs +503 -0
  12. package/rust/src/transcode/generated.rs +376 -17
  13. package/src/bridge/nats/inbound.ts +46 -0
  14. package/src/bridge/nats/index.ts +131 -0
  15. package/src/bridge/nats/real-transport.ts +133 -0
  16. package/src/bridge/nats/types.ts +80 -0
  17. package/src/codegen/constants.ts +14 -4
  18. package/src/codegen/direct-gen.ts +20 -6
  19. package/src/codegen/registry-gen.ts +10 -6
  20. package/src/codegen/rust-glue-gen.ts +10 -3
  21. package/src/codegen/schema-model.ts +28 -3
  22. package/src/codegen/ts-ser-gen.ts +12 -3
  23. package/src/core/auth.ts +65 -4
  24. package/src/core/client-rpc.ts +75 -0
  25. package/src/core/client-state.ts +42 -0
  26. package/src/core/client-wire.ts +142 -8
  27. package/src/core/client.ts +72 -3
  28. package/src/core/groups.ts +5 -0
  29. package/src/core/metrics.ts +38 -21
  30. package/src/core/outbound.ts +50 -6
  31. package/src/core/rate-limit.ts +69 -0
  32. package/src/core/replay.ts +41 -1
  33. package/src/core/resume.ts +181 -0
  34. package/src/core/rooms.ts +10 -3
  35. package/src/core/routing.ts +128 -5
  36. package/src/core/server/client-info.ts +37 -0
  37. package/src/core/server/http-routes.ts +59 -0
  38. package/src/core/{server.ts → server/index.ts} +112 -120
  39. package/src/core/server/metrics-view.ts +53 -0
  40. package/src/core/server/socket-lifecycle.ts +57 -0
  41. package/src/core/state.ts +73 -1
  42. package/src/core/topic-log.ts +86 -0
  43. package/src/events/clients.ts +18 -0
  44. package/src/events/cluster/dedupe.ts +43 -0
  45. package/src/events/cluster/envelope.ts +149 -0
  46. package/src/events/cluster/index.ts +50 -0
  47. package/src/events/cluster/keys.ts +33 -0
  48. package/src/events/cluster/kinds.ts +32 -0
  49. package/src/events/cluster/presence-table.ts +99 -0
  50. package/src/events/cluster/presence.ts +53 -0
  51. package/src/events/cluster/redis-client.ts +50 -0
  52. package/src/events/cluster/store-memory.ts +67 -0
  53. package/src/events/cluster/store-redis.ts +44 -0
  54. package/src/events/cluster/subjects.ts +30 -0
  55. package/src/events/cluster/sync.ts +476 -0
  56. package/src/events/cluster/transport-nats.ts +24 -0
  57. package/src/events/cluster/transport-redis.ts +120 -0
  58. package/src/events/cluster-rpc.ts +196 -0
  59. package/src/events/delivery.ts +83 -0
  60. package/src/events/emit.ts +57 -11
  61. package/src/events/hub/context-factory.ts +79 -0
  62. package/src/events/hub/dispatch.ts +86 -0
  63. package/src/events/hub/index.ts +536 -0
  64. package/src/events/hub/internal.ts +31 -0
  65. package/src/events/hub/metrics-snapshot.ts +84 -0
  66. package/src/events/hub/resolve-cluster.ts +49 -0
  67. package/src/events/queue.ts +36 -9
  68. package/src/events/registry.ts +90 -54
  69. package/src/events/schedule.ts +73 -0
  70. package/src/events/trace.ts +283 -0
  71. package/src/events/types/client.ts +68 -0
  72. package/src/events/types/cluster.ts +40 -0
  73. package/src/events/types/context.ts +50 -0
  74. package/src/events/types/emit-target.ts +29 -0
  75. package/src/events/types/groups.ts +35 -0
  76. package/src/events/types/hub.ts +124 -0
  77. package/src/events/types/index.ts +30 -0
  78. package/src/events/types/metrics.ts +52 -0
  79. package/src/events/types/options.ts +62 -0
  80. package/src/generated/direct-ser.ts +146 -59
  81. package/src/generated/fbs/backend.fbs +23 -0
  82. package/src/generated/registry.ts +92 -33
  83. package/src/generated/rust/backend_generated.rs +503 -0
  84. package/src/generated/ts/backend.ts +4 -0
  85. package/src/generated/ts/resume.ts +74 -0
  86. package/src/generated/ts/resumed.ts +88 -0
  87. package/src/generated/ts/rpc-call.ts +112 -0
  88. package/src/generated/ts/rpc-result.ts +126 -0
  89. package/src/generated/ts/snapshot-request.ts +19 -5
  90. package/src/generated/ts-ser.ts +109 -16
  91. package/src/generated/wire-registry.json +7 -3
  92. package/src/schema/index.ts +45 -1
  93. package/src/transport/transport.ts +117 -77
  94. package/src/bridge/nats.ts +0 -309
  95. package/src/events/cluster.ts +0 -732
  96. package/src/events/hub.ts +0 -481
  97. package/src/events/types.ts +0 -378
  98. package/src/transport/stats.ts +0 -48
@@ -0,0 +1,59 @@
1
+ /**
2
+ * HTTP routes — the non-WebSocket side of `Bun.serve`'s fetch: the upgrade
3
+ * path, a JSON `/health` probe, an auth-gated `/clients` introspection
4
+ * endpoint, and the user-supplied fallback.
5
+ *
6
+ * Each route is a small pure-ish function `(state, req) => Response | null`
7
+ * (`null` = "not my route"); `handleHttpRequest` composes them in order.
8
+ */
9
+ import type { ServerState, WsData } from "../state";
10
+ import { authorizeHttp, checkUpgrade } from "../auth";
11
+ import { toClientInfo } from "./client-info";
12
+
13
+ /** The Bun server handle (needed by `checkUpgrade` for `srv.upgrade`). */
14
+ type BunServer = ReturnType<typeof Bun.serve<WsData>>;
15
+
16
+ /** JSON response helper (the only shape these routes return). */
17
+ const json = (body: unknown, status = 200): Response =>
18
+ new Response(JSON.stringify(body), {
19
+ ...(status === 200 ? {} : { status }),
20
+ headers: { "content-type": "application/json" },
21
+ });
22
+
23
+ /** GET /health — liveness + basic counters (never gated; safe for probes). */
24
+ function healthRoute(state: ServerState): Response {
25
+ const h = state.metrics.snapshot(state.sockets.size);
26
+ return json({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs });
27
+ }
28
+
29
+ /** GET /clients — active-client introspection. */
30
+ async function clientsRoute(state: ServerState, req: Request): Promise<Response> {
31
+ // gated whenever the server has an auth surface (token or authenticate);
32
+ // public only for unsecured servers
33
+ const denied = await authorizeHttp(state, req);
34
+ if (denied) return denied;
35
+ return json([...state.clients.values()].map(toClientInfo));
36
+ }
37
+
38
+ /**
39
+ * Route an HTTP request. Order matters:
40
+ * 1. the WS upgrade path (`state.path`),
41
+ * 2. built-in introspection endpoints,
42
+ * 3. the user's custom `fetch` (when provided),
43
+ * 4. 404.
44
+ */
45
+ export async function handleHttpRequest(
46
+ state: ServerState,
47
+ req: Request,
48
+ srv: BunServer,
49
+ customFetch?: (req: Request) => Response | Promise<Response>,
50
+ ): Promise<Response> {
51
+ const url = new URL(req.url);
52
+ if (url.pathname === state.path) {
53
+ return (await checkUpgrade(state, req, srv)) ?? new Response("upgrade failed", { status: 400 });
54
+ }
55
+ if (url.pathname === "/health") return healthRoute(state);
56
+ if (url.pathname === "/clients") return clientsRoute(state, req);
57
+ if (customFetch) return customFetch(req);
58
+ return new Response("not found", { status: 404 });
59
+ }
@@ -2,7 +2,12 @@
2
2
  * Server composition root — wires `createServerState` + the action modules
3
3
  * (auth / rooms / outbound / routing) into `Bun.serve`, and returns a plain
4
4
  * `IgnServer` API object (no class, no `this`). This is the ONLY place that
5
- * knows how the pieces fit together.
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
6
11
  *
7
12
  * Generic over the wire stack: `createServer({ bindings })` with your own
8
13
  * generated bindings types `publish` / `on` / ... against YOUR events. The
@@ -11,53 +16,33 @@
11
16
  * Public entry: `public/server.ts` re-exports `createServer` + the types.
12
17
  */
13
18
  import type { ServerWebSocket } from "bun";
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";
18
- import { checkUpgrade } from "./auth";
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";
19
31
  import {
20
32
  activeGroups,
21
- joinGroup as addToGroup,
22
33
  groupMembers as groupMemberIds,
23
34
  publishToGroup as publishToGroupState,
24
35
  leaveGroup as removeFromGroup,
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";
31
- import { createServerState, type IgnServerOptions, type WsData } from "./state";
32
-
33
- /** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
34
- export interface ClientInfo {
35
- id: string;
36
- /** identity this connection acts on behalf of (undefined if none) */
37
- userId?: string;
38
- /** arbitrary app metadata from `authenticate` (undefined if none) */
39
- meta?: Record<string, unknown>;
40
- /** server-side groups this client belongs to */
41
- groups: string[];
42
- /** topics/rooms this client has joined */
43
- topics: string[];
44
- /** epoch ms the socket connected */
45
- connectedAt: number;
46
- /** remote IP (from the socket) */
47
- ip: string;
48
- }
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";
49
44
 
50
- function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
51
- return {
52
- id: ws.data.id,
53
- ...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
54
- ...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
55
- groups: [...ws.data.groups],
56
- topics: [...ws.data.topics],
57
- connectedAt: ws.data.connectedAt,
58
- ip: ws.remoteAddress,
59
- };
60
- }
45
+ export type { ClientInfo } from "./client-info";
61
46
 
62
47
  /** The public server API (returned by `createServer`). */
63
48
  export interface IgnServer<B extends Bindings = DefaultBindings> {
@@ -111,12 +96,34 @@ export interface IgnServer<B extends Bindings = DefaultBindings> {
111
96
  handler: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => void,
112
97
  ): IgnServer<B>;
113
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;
114
121
  /**
115
122
  * The events hub — present when `createServer({ events: {...} })` is used:
116
123
  * typed handlers (`server.events.on`), client records, groups, and the
117
124
  * cluster-aware emit surface.
118
125
  */
119
- readonly events: EventsHub<B> | undefined;
126
+ readonly events: import("../../events/types").EventsHub<B> | undefined;
120
127
  /** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
121
128
  drain(timeoutMs?: number): Promise<void>;
122
129
  stop(force?: boolean): void;
@@ -128,27 +135,42 @@ export function createServer<B extends Bindings = DefaultBindings>(
128
135
  const state = createServerState(options);
129
136
  setInt64GuardMode(options.int64Guard ?? "off");
130
137
  const bindings = state.bindings;
138
+ const trace = state.trace; // hot-path local (one property load, ever)
131
139
 
132
140
  // NATS bridge (optional, best-effort — created eagerly, connects in the background)
133
141
  const natsOpt = options.nats;
134
142
  if (natsOpt)
135
- state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt, undefined, bindings);
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.
136
150
 
137
- // Encode once + broadcast to every connected client (NO bridge) — the shared
138
- // hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
139
- // events reach clients but are never re-bridged to NATS.
151
+ /** Encode + count + trace a broadcast frame (no delivery yet). */
140
152
  function fanOutAll(name: string, payload: unknown): Uint8Array {
141
153
  const frame = state.transport.encodeToScratch(name, payload);
142
154
  state.metrics.published++;
143
- for (const ws of state.sockets) sendFrame(state, ws, frame);
155
+ trace.record("out.publish", name, "broadcast", undefined, frame.byteLength);
144
156
  return frame;
145
157
  }
146
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
+
147
164
  let eventsHub: EventsHubInternal<B> | undefined;
148
165
 
149
166
  if (state.bridge) {
150
167
  state.bridge.setOnInbound((name, payload) => {
151
- fanOutAll(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);
152
174
  // server-side handling of externally-published events (the events layer)
153
175
  eventsHub?.dispatchBridgeInbound(name, payload);
154
176
  });
@@ -159,55 +181,17 @@ export function createServer<B extends Bindings = DefaultBindings>(
159
181
  ...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
160
182
  idleTimeout: options.idleTimeout ?? 30,
161
183
  ...(options.tls !== undefined ? { tls: options.tls } : {}),
162
- fetch: (req, srv) => {
163
- const url = new URL(req.url);
164
- if (url.pathname === state.path) return checkUpgrade(state, req, srv);
165
- if (url.pathname === "/health") {
166
- const h = state.metrics.snapshot(state.sockets.size);
167
- return new Response(
168
- JSON.stringify({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs }),
169
- { headers: { "content-type": "application/json" } },
170
- );
171
- }
172
- if (url.pathname === "/clients") {
173
- return new Response(JSON.stringify([...state.clients.values()].map(toClientInfo)), {
174
- headers: { "content-type": "application/json" },
175
- });
176
- }
177
- if (options.fetch) return options.fetch(req);
178
- return new Response("not found", { status: 404 });
179
- },
184
+ fetch: (req, srv) => handleHttpRequest(state, req, srv, options.fetch),
180
185
  websocket: {
181
- open: (ws) => {
182
- state.sockets.add(ws);
183
- // belt-and-suspenders: an auth race could double-register an id — kick the stale session
184
- const existing = state.clients.get(ws.data.id);
185
- if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
186
- state.clients.set(ws.data.id, ws);
187
- // events-layer attach (client record + presence) BEFORE group seeding
188
- state.onConnect?.(ws);
189
- for (const g of ws.data.groups) addToGroup(state, ws, g);
190
- // announce our wire version + capabilities so clients can negotiate
191
- sendControl(state, ws, "hello", { version: bindings.wireVersion, caps: [], lastSeq: 0 });
192
- // then assign identity so the client knows its id + server-side groups
193
- sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
194
- },
195
- close: (ws) => {
196
- // events-layer detach FIRST (client record still carries groups/topics)
197
- state.onDisconnect?.(ws);
198
- state.sockets.delete(ws);
199
- state.clients.delete(ws.data.id);
200
- for (const g of ws.data.groups) removeFromGroup(state, ws, g);
201
- ws.data.groups.clear();
202
- for (const t of ws.data.topics) leaveRoom(state, ws, t);
203
- ws.data.topics.clear();
204
- delete ws.data.queue;
205
- },
186
+ open: (ws) => onSocketOpen(state, ws),
187
+ close: (ws) => onSocketClose(state, ws),
206
188
  message: (ws, msg) => handleMessage(state, ws, msg),
207
189
  drain: (ws) => drainSocket(state, ws),
208
190
  },
209
191
  });
210
192
 
193
+ // ── publish actions (encode once → bridge copy → local fan-out) ──────
194
+
211
195
  const api: IgnServer<B> = {
212
196
  get port(): number {
213
197
  return bun.port ?? 0;
@@ -216,48 +200,45 @@ export function createServer<B extends Bindings = DefaultBindings>(
216
200
  return state.sockets.size;
217
201
  },
218
202
  getMetrics(): MetricsSnapshot {
219
- const stats = state.transport.getEncodeStats();
220
- for (const [name, n] of Object.entries(stats.direct)) {
221
- if (n > 0) state.metrics.countPath(name, "direct");
222
- }
223
- for (const [name, n] of Object.entries(stats.json)) {
224
- if (n > 0) state.metrics.countPath(name, "json");
225
- }
226
- for (const [name, n] of Object.entries(stats.js)) {
227
- if (n > 0) state.metrics.countPath(name, "js");
228
- }
229
- const snapshot = state.metrics.snapshot(state.sockets.size);
230
- const b = state.bridge;
231
- if (b) {
232
- snapshot.bridged = b.stats.bridged;
233
- snapshot.bridgedBytes = b.stats.bridgedBytes;
234
- snapshot.bridgeErrors = b.stats.bridgeErrors;
235
- snapshot.bridgeInbound = b.stats.bridgeInbound;
236
- snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
237
- snapshot.natsStatus = b.status;
238
- }
239
- if (eventsHub) snapshot.events = eventsHub.metrics();
240
- return snapshot;
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();
241
215
  },
242
216
  publish(name, payload) {
243
217
  const frame = fanOutAll(name, payload);
218
+ // pristine frame out FIRST (bridge copies), then seq-stamped local sends
244
219
  state.bridge?.publish(state.bridge.subjects.broadcast(name), frame);
220
+ fanOutAllLocal(frame);
245
221
  },
246
222
  publishTo(ws, name, payload) {
247
223
  state.metrics.published++;
248
- sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
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);
249
227
  },
250
228
  publishToTopic(topic, name, payload) {
251
229
  const frame = state.transport.encodeToScratch(name, payload);
252
230
  state.metrics.published++;
253
- publishToRoom(state, topic, frame);
231
+ trace.record("out.publish", name, "topic", topic, frame.byteLength);
254
232
  state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
233
+ publishToRoom(state, topic, frame);
255
234
  },
256
235
  publishToClient(id, name, payload) {
257
236
  const ws = state.clients.get(id);
258
237
  if (!ws) return false;
259
238
  state.metrics.published++;
260
- sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
239
+ const frame = state.transport.encodeToScratch(name, payload);
240
+ trace.record("out.publish", name, "client", id, frame.byteLength);
241
+ sendFrame(state, ws, frame);
261
242
  return true;
262
243
  },
263
244
  allowInbound(name) {
@@ -284,8 +265,9 @@ export function createServer<B extends Bindings = DefaultBindings>(
284
265
  publishToGroup(group, name, payload) {
285
266
  const frame = state.transport.encodeToScratch(name, payload);
286
267
  state.metrics.published++;
287
- publishToGroupState(state, group, frame);
268
+ trace.record("out.publish", name, "group", group, frame.byteLength);
288
269
  state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
270
+ publishToGroupState(state, group, frame);
289
271
  },
290
272
  groups() {
291
273
  return activeGroups(state);
@@ -324,7 +306,17 @@ export function createServer<B extends Bindings = DefaultBindings>(
324
306
  state.inboundHandlers.delete(name);
325
307
  return api;
326
308
  },
327
- get events(): EventsHub<B> | undefined {
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() {
328
320
  return eventsHub;
329
321
  },
330
322
  async drain(timeoutMs = 2000): Promise<void> {
@@ -344,7 +336,7 @@ export function createServer<B extends Bindings = DefaultBindings>(
344
336
  },
345
337
  };
346
338
 
347
- // ── events layer (opt-in) ─────────────────────────────────────────────
339
+ // ── events layer (opt-in) ────────────────────────────────────────────
348
340
  if (options.events) {
349
341
  eventsHub = createEventsHub({
350
342
  state,
@@ -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
+ }
package/src/core/state.ts CHANGED
@@ -10,9 +10,11 @@ import type { ServerWebSocket } from "bun";
10
10
  import { defaultBindings } from "../bindings/default";
11
11
  import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
12
12
  import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
13
+ import { createEventTrace, type EventTrace, type EventTraceOptions } from "../events/trace";
13
14
  import { createTransport, defaultTransport, type Transport } from "../transport/transport";
14
15
  import type { Int64GuardMode } from "./int64-guard";
15
16
  import { createMetrics, type Metrics } from "./metrics";
17
+ import { resolveRateLimit, type RateLimitOptions, type ResolvedRateLimit } from "./rate-limit";
16
18
  import type { RingBuffer } from "./ring";
17
19
 
18
20
  /**
@@ -57,6 +59,15 @@ export interface WsData {
57
59
  connectedAt: number;
58
60
  /** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
59
61
  queue?: RingBuffer<Uint8Array>;
62
+ /** per-connection inbound rate limiter (lazily created on first frame) */
63
+ rate?: import("./rate-limit").RateLimiter;
64
+ /**
65
+ * Next per-connection delivery seq to stamp (envelope v2). Starts at 1;
66
+ * continues a previous session's stream when a grave is adopted.
67
+ */
68
+ sendSeq: number;
69
+ /** bounded sent-frame history for gap recovery (lazily created, resume only) */
70
+ history?: import("./ring").RingBuffer<import("./resume").SentFrame>;
60
71
  }
61
72
 
62
73
  /** Slow-consumer policy (see `IgnBackpressureOptions`). */
@@ -97,9 +108,23 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
97
108
  * traffic. Off by default to keep the hot path allocation-free.
98
109
  */
99
110
  replay?: { historySize?: number };
111
+ /**
112
+ * Gap-free delivery (envelope v2 seq + resume). When set, every frame sent
113
+ * to a socket carries a per-connection delivery seq, the connection keeps a
114
+ * bounded sent-history ring, and closed sessions park that ring in a
115
+ * per-client-id graveyard so reconnects can resume missed frames.
116
+ */
117
+ resume?: { historySize?: number; ttlMs?: number };
118
+ /**
119
+ * Durable topic log behind the replay ring (`src/core/topic-log.ts`). When
120
+ * set, every recorded topic frame is appended and `snapshotRequest`s older
121
+ * than the ring hydrate from the log. Default: none (ring-only).
122
+ */
123
+ topicLog?: import("./topic-log").TopicLog;
100
124
  /**
101
125
  * Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
102
- * the connection (401). Return `true` to allow it (client gets an auto-
126
+ * the connection (401) a hook that throws (or rejects) denies it too.
127
+ * Return `true` to allow it (client gets an auto-
103
128
  * generated id), or a `ClientMeta` object to pin the client id / seed its
104
129
  * server-side groups / attach metadata. Inspect `req` as needed.
105
130
  */
@@ -115,6 +140,22 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
115
140
  maxConnections?: number;
116
141
  /** maximum inbound frame size in bytes (close 1009 beyond) */
117
142
  maxMessageSize?: number;
143
+ /**
144
+ * Per-connection inbound rate limiting (token bucket over ALL frames — app
145
+ * AND control). Default: off (zero hot-path overhead). Over-limit frames are
146
+ * dropped (default) or the socket is closed (`policy: "close"`, code 1008);
147
+ * either way the event is counted in `metrics.rateLimited`.
148
+ */
149
+ rateLimit?: RateLimitOptions;
150
+ /**
151
+ * Authorize a client's topic (room) join — enforced for EVERY join path:
152
+ * `subscribe` control frames, programmatic `server.join`, and auth-seeded
153
+ * topics. Return false to reject (the frame/ call is ignored and counted in
154
+ * `metrics.rejectedJoins`). Default: allow all.
155
+ */
156
+ authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
157
+ /** Authorize a server-side group join (same contract as `authorizeTopic`). */
158
+ authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
118
159
  /**
119
160
  * Lossless-int64 guard for plain `number` int64 fields: values outside the
120
161
  * safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
@@ -141,6 +182,13 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
141
182
  * (`ignex-nova/events`) is bound by default.
142
183
  */
143
184
  events?: import("../events/types").EventsOptions<B>;
185
+ /**
186
+ * Event trace ring — records every fired event (emitted / published /
187
+ * received) into a pre-allocated structure-of-arrays buffer so a debugger
188
+ * (ignex debugbar, MCP) can see what fired without any hot-path allocation.
189
+ * Default: on with capacity 1024; `IGNEX_NOVA_TRACE=0` disables globally.
190
+ */
191
+ trace?: EventTraceOptions;
144
192
  /** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
145
193
  fetch?: (req: Request) => Response | Promise<Response>;
146
194
  }
@@ -163,7 +211,11 @@ export interface ServerState {
163
211
  token?: string | ((token: string) => boolean);
164
212
  maxConnections?: number;
165
213
  maxMessageSize?: number;
214
+ rateLimit: ResolvedRateLimit | null;
215
+ authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
216
+ authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
166
217
  replay: { historySize: number } | null;
218
+ resume: { historySize: number; ttlMs: number } | null;
167
219
  sockets: Set<ServerWebSocket<WsData>>;
168
220
  /** id → live socket (client registry for targeted sends / introspection) */
169
221
  clients: Map<string, ServerWebSocket<WsData>>;
@@ -175,11 +227,23 @@ export interface ServerState {
175
227
  inboundHandlers: Map<string, InboundHandler>;
176
228
  topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
177
229
  replaySeq: number;
230
+ /** optional durable topic log (wired in createServer when `options.topicLog` is set) */
231
+ topicLog?: import("./topic-log").TopicLog;
232
+ /**
233
+ * Responder registry for request/response (`rpcCall` control frames):
234
+ * inner event name → async responder. Registered via `server.handle` /
235
+ * `hub.onRequest`.
236
+ */
237
+ rpcHandlers: Map<string, (payload: unknown, ws: ServerWebSocket<WsData>) => Promise<unknown>>;
238
+ /** parked sent-history rings of closed sessions (cross-connection resume) */
239
+ graves: Map<string, { history: RingBuffer<import("./resume").SentFrame>; nextSeq: number; expiresAt: number }>;
178
240
  /** events-layer lifecycle hooks (wired by createServer when `events` is set) */
179
241
  onConnect?: (ws: ServerWebSocket<WsData>) => void;
180
242
  onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
181
243
  /** fired on ANY group membership change (auth seed, control frames, programmatic) */
182
244
  onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
245
+ /** event trace ring (debugger visibility; pre-allocated, zero-GC writes) */
246
+ trace: EventTrace;
183
247
  }
184
248
 
185
249
  export function createServerState<B extends Bindings = DefaultBindings>(
@@ -205,7 +269,12 @@ export function createServerState<B extends Bindings = DefaultBindings>(
205
269
  ...(options.token !== undefined ? { token: options.token } : {}),
206
270
  ...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
207
271
  ...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
272
+ rateLimit: resolveRateLimit(options.rateLimit),
273
+ ...(options.authorizeTopic !== undefined ? { authorizeTopic: options.authorizeTopic } : {}),
274
+ ...(options.authorizeGroup !== undefined ? { authorizeGroup: options.authorizeGroup } : {}),
275
+ ...(options.topicLog !== undefined ? { topicLog: options.topicLog } : {}),
208
276
  replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
277
+ resume: options.resume ? { historySize: options.resume.historySize ?? 256, ttlMs: options.resume.ttlMs ?? 60_000 } : null,
209
278
  sockets: new Set(),
210
279
  clients: new Map(),
211
280
  rooms: new Map(),
@@ -213,5 +282,8 @@ export function createServerState<B extends Bindings = DefaultBindings>(
213
282
  inboundHandlers: new Map(),
214
283
  topicHistory: new Map(),
215
284
  replaySeq: 0,
285
+ rpcHandlers: new Map(),
286
+ graves: new Map(),
287
+ trace: createEventTrace(options.trace),
216
288
  };
217
289
  }