@ignex/nova 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +53 -0
  26. package/src/core/client-wire.ts +183 -8
  27. package/src/core/client.ts +84 -4
  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
@@ -11,9 +11,13 @@
11
11
  */
12
12
  import type { ServerWebSocket } from "bun";
13
13
  import type { ControlEventName, ControlEvents } from "../schema";
14
- import { sendControl } from "./outbound";
14
+ import { capturePayload } from "../events/trace";
15
+ import { sendControl, doSend } from "./outbound";
16
+ import { replayAfter } from "./resume";
15
17
  import { joinRoom, leaveRoom } from "./rooms";
18
+ import { topicHistoryFrom } from "./replay";
16
19
  import { joinGroup, leaveGroup } from "./groups";
20
+ import { createRateLimiter } from "./rate-limit";
17
21
  import type { ServerState, WsData } from "./state";
18
22
 
19
23
  // allocated once per process, reused for every inbound text frame
@@ -24,12 +28,44 @@ export function handleMessage(
24
28
  ws: ServerWebSocket<WsData>,
25
29
  raw: string | Buffer,
26
30
  ): void {
27
- const bytes = typeof raw === "string" ? textEncoder.encode(raw) : new Uint8Array(raw as Buffer);
31
+ // cheap oversize pre-check BEFORE any conversion work (string length is a
32
+ // lower bound of its UTF-8 size, so `>` here can never false-positive)
33
+ if (state.maxMessageSize !== undefined) {
34
+ const approx = typeof raw === "string" ? raw.length : raw.byteLength;
35
+ if (approx > state.maxMessageSize) {
36
+ state.metrics.protocolErrors++;
37
+ ws.close(1009, "message too big");
38
+ return;
39
+ }
40
+ }
41
+ // binary frames are viewed ZERO-COPY (decode is synchronous and every
42
+ // downstream holder — replay history, backpressure queue, NATS bridge —
43
+ // takes its own owned copy); only text frames pay one transcode.
44
+ const bytes =
45
+ typeof raw === "string"
46
+ ? textEncoder.encode(raw)
47
+ : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
28
48
  if (state.maxMessageSize !== undefined && bytes.byteLength > state.maxMessageSize) {
49
+ // multi-byte text frames can exceed the lower bound checked above
29
50
  state.metrics.protocolErrors++;
30
51
  ws.close(1009, "message too big");
31
52
  return;
32
53
  }
54
+ // per-connection token bucket — evaluated before ANY decode work so a
55
+ // flooding client is shed at ~constant cost (app AND control frames).
56
+ const rl = state.rateLimit;
57
+ if (rl !== null) {
58
+ let limiter = ws.data.rate;
59
+ if (limiter === undefined) {
60
+ limiter = createRateLimiter(rl);
61
+ ws.data.rate = limiter;
62
+ }
63
+ if (!limiter.allow(Date.now())) {
64
+ state.metrics.rateLimited++;
65
+ if (limiter.policy === "close") ws.close(1008, "rate limit exceeded");
66
+ return;
67
+ }
68
+ }
33
69
  const header = state.bindings.readFrameHeader(bytes);
34
70
  if (!header) {
35
71
  state.metrics.protocolErrors++;
@@ -43,7 +79,23 @@ export function handleMessage(
43
79
  const name = header.name;
44
80
  if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
45
81
  state.metrics.inbound++;
46
- state.inboundHandlers.get(name)?.(state.bindings.decodePayload(header.id, bytes), ws);
82
+ // trace: one row per accepted client event (the debugger's "what came in").
83
+ // The payload itself is materialized only when somebody consumes it (a
84
+ // handler exists or capture is on) — never allocated otherwise.
85
+ const handler = state.inboundHandlers.get(name);
86
+ const payload =
87
+ handler !== undefined || state.trace.captures
88
+ ? state.bindings.decodePayload(header.id, bytes)
89
+ : undefined;
90
+ state.trace.record(
91
+ "in.client",
92
+ name,
93
+ undefined,
94
+ ws.data.id,
95
+ bytes.byteLength,
96
+ state.trace.captures ? capturePayload(payload, 2000) : undefined,
97
+ );
98
+ handler?.(payload, ws);
47
99
  // Horizontal scaling: when the bridge is configured with `bridgeClientEvents`,
48
100
  // every accepted client event is re-published to `{prefix}.inbound.<event>` so
49
101
  // OTHER server instances (and BE consumers) receive it. NATS-inbound frames
@@ -70,7 +122,28 @@ export function handleControl(
70
122
  if (p.version !== state.bindings.wireVersion) {
71
123
  // protocol version mismatch — refuse this client
72
124
  ws.close(1002, "wire version mismatch");
125
+ break;
73
126
  }
127
+ // cross-session resume: the client reconnects with `lastSeq > 0` and a
128
+ // parked history was adopted for its id (server.open wires adoption).
129
+ if (p.lastSeq > 0 && ws.data.history !== undefined && ws.data.history.length > 0) {
130
+ const r = replayAfter(state, ws, p.lastSeq);
131
+ state.metrics.resumesServed++;
132
+ state.metrics.framesReplayed += r.replayed;
133
+ if (!r.ok) state.metrics.resumeMisses++;
134
+ sendControl(state, ws, "resumed", { ok: r.ok, from: r.from });
135
+ }
136
+ break;
137
+ }
138
+ case "resume": {
139
+ // same-connection gap recovery: replay everything after the client's
140
+ // last contiguous seq (original seqs preserved — no re-stamping)
141
+ const lastSeq = (payload as ControlEvents["resume"]).lastSeq;
142
+ const r = replayAfter(state, ws, lastSeq);
143
+ state.metrics.resumesServed++;
144
+ state.metrics.framesReplayed += r.replayed;
145
+ if (!r.ok) state.metrics.resumeMisses++;
146
+ sendControl(state, ws, "resumed", { ok: r.ok, from: r.from });
74
147
  break;
75
148
  }
76
149
  case "subscribe": {
@@ -96,8 +169,58 @@ export function handleControl(
96
169
  case "pong":
97
170
  // client keepalive reply — tracked on the client side (heartbeat)
98
171
  break;
99
- case "snapshotRequest":
100
- // per-topic replay from the ring buffer (see replay.ts)
172
+ case "snapshotRequest": {
173
+ // per-topic replay from the ring buffer (+ topic log when configured),
174
+ // strictly after `fromSeq` (0 = from the beginning of retained history)
175
+ const p = payload as ControlEvents["snapshotRequest"];
176
+ for (const frame of topicHistoryFrom(state, p.topic, p.fromSeq)) doSend(state, ws, frame);
177
+ break;
178
+ }
179
+ case "rpcCall": {
180
+ const p = payload as ControlEvents["rpcCall"];
181
+ const responder = state.rpcHandlers.get(p.name);
182
+ const reply = (ok: boolean, payloadB64: string, err = ""): void => {
183
+ sendControl(state, ws, "rpcResult", { id: p.id, ok, err, payloadB64 });
184
+ };
185
+ if (!responder) {
186
+ reply(false, "", `no handler for "${p.name}"`);
187
+ return;
188
+ }
189
+ const inner = decodeB64Frame(state, p.payloadB64);
190
+ if (!inner) {
191
+ reply(false, "", "undecodable request payload");
192
+ return;
193
+ }
194
+ void (async () => {
195
+ try {
196
+ const out = await responder(inner.payload, ws);
197
+ // encode with the SAME event name (request/response share a schema)
198
+ const frame = state.transport.encodeToScratch(p.name, out);
199
+ reply(true, b64(frame));
200
+ } catch (err) {
201
+ reply(false, "", err instanceof Error ? err.message : String(err));
202
+ }
203
+ })();
101
204
  break;
205
+ }
102
206
  }
103
207
  }
208
+
209
+ /** Decode a base64 wire frame (rpcCall payload) into { name, payload }. */
210
+ function decodeB64Frame(
211
+ state: ServerState,
212
+ b64: string,
213
+ ): { name: string; payload: unknown } | null {
214
+ try {
215
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
216
+ return state.bindings.decodeFrame(bytes) as { name: string; payload: unknown } | null;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ const b64 = (bytes: Uint8Array): string => {
223
+ let s = "";
224
+ for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]!);
225
+ return btoa(s);
226
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Client introspection view — the snapshot shape returned by
3
+ * `server.getClient` / `server.getClients` and the GET /clients endpoint.
4
+ *
5
+ * `toClientInfo` is a PURE mapper from live socket state to the plain object.
6
+ */
7
+ import type { ServerWebSocket } from "bun";
8
+ import type { WsData } from "../state";
9
+
10
+ /** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
11
+ export interface ClientInfo {
12
+ id: string;
13
+ /** identity this connection acts on behalf of (undefined if none) */
14
+ userId?: string;
15
+ /** arbitrary app metadata from `authenticate` (undefined if none) */
16
+ meta?: Record<string, unknown>;
17
+ /** server-side groups this client belongs to */
18
+ groups: string[];
19
+ /** topics/rooms this client has joined */
20
+ topics: string[];
21
+ /** epoch ms the socket connected */
22
+ connectedAt: number;
23
+ /** remote IP (from the socket) */
24
+ ip: string;
25
+ }
26
+
27
+ export function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
28
+ return {
29
+ id: ws.data.id,
30
+ ...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
31
+ ...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
32
+ groups: [...ws.data.groups],
33
+ topics: [...ws.data.topics],
34
+ connectedAt: ws.data.connectedAt,
35
+ ip: ws.remoteAddress,
36
+ };
37
+ }
@@ -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
+ }