@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
@@ -7,6 +7,7 @@ import type { ServerWebSocket } from "bun";
7
7
  import type { ControlEventName, ControlEvents } from "../schema";
8
8
  import { decide } from "./backpressure";
9
9
  import { RingBuffer } from "./ring";
10
+ import { ensureHistory, recordSent, stampSeq } from "./resume";
10
11
  import type { ServerState, WsData } from "./state";
11
12
 
12
13
  /** Actual socket write + counters (single accounting point). */
@@ -17,17 +18,52 @@ export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: U
17
18
  }
18
19
 
19
20
  /**
20
- * Send one frame to a socket, honoring the configured backpressure policy.
21
- * Happy path (no backpressure configured, or socket under the high-water mark)
22
- * is a direct `ws.send` zero allocations. Under pressure, `ws.send` is
23
- * replaced by a bounded queue (drop-oldest) / skip (drop-newest) / close
24
- * (disconnect).
21
+ * Send one frame to a socket, honoring the configured backpressure policy AND
22
+ * (when resume is enabled) stamping a per-connection delivery seq into the
23
+ * envelope v2 header first. Only APP frames are stamped: the delivery seq is
24
+ * an app-delivery guarantee, and control frames (ping/pong/welcome/resume
25
+ * acks) must never create ordering obligations for the client's gap gate.
26
+ *
27
+ * The stamp mutates the shared scratch view IN PLACE — safe because every
28
+ * external copy (bridge / cluster / replay history) is taken before this
29
+ * point, and `ws.send` copies synchronously.
30
+ *
31
+ * Pass `seq` to send a pre-stamped frame verbatim (resume replays keep their
32
+ * original delivery seqs and are not re-recorded).
25
33
  */
26
34
  export function sendFrame(
27
35
  state: ServerState,
28
36
  ws: ServerWebSocket<WsData>,
29
37
  frame: Uint8Array,
38
+ opts?: { readonly seq?: number },
30
39
  ): void {
40
+ if (opts?.seq !== undefined) {
41
+ // pre-stamped replay frame — write it as-is
42
+ const bp0 = state.bp;
43
+ if (!bp0) {
44
+ doSend(state, ws, frame);
45
+ return;
46
+ }
47
+ const d0 = decide(bp0, ws);
48
+ if (d0.kind === "send") doSend(state, ws, frame);
49
+ else if (d0.kind === "close") {
50
+ state.metrics.disconnectedSlow++;
51
+ ws.close(1013, "slow consumer");
52
+ }
53
+ return;
54
+ }
55
+ if (
56
+ state.resume !== null &&
57
+ state.resume !== undefined &&
58
+ !isControlFrame(state.bindings, frame) &&
59
+ ensureHistory(state, ws) !== undefined
60
+ ) {
61
+ const seq = ws.data.sendSeq++;
62
+ if (stampSeq(state.bindings, frame, seq)) {
63
+ recordSent(state, ws, frame, seq);
64
+ state.metrics.stampedSeq++;
65
+ }
66
+ }
31
67
  const bp = state.bp;
32
68
  if (!bp) {
33
69
  doSend(state, ws, frame);
@@ -52,7 +88,7 @@ export function sendFrame(
52
88
  q = new RingBuffer<Uint8Array>();
53
89
  ws.data.queue = q;
54
90
  }
55
- q.push(frame.slice()); // owned copy for the queue
91
+ q.push(frame.slice()); // owned copy for the queue (already seq-stamped)
56
92
  for (let i = 0; i < d.dropHead; i++) {
57
93
  q.shift();
58
94
  state.metrics.droppedOldest++;
@@ -62,6 +98,14 @@ export function sendFrame(
62
98
  }
63
99
  }
64
100
 
101
+ /** Cheap envelope-id probe: true when `frame` is a transport-internal event. */
102
+ function isControlFrame(bindings: ServerState["bindings"], frame: Uint8Array): boolean {
103
+ if (frame.byteLength < 5) return false;
104
+ const id =
105
+ (frame[1]! | (frame[2]! << 8) | (frame[3]! << 16) | (frame[4]! << 24)) >>> 0;
106
+ return bindings.isControlId(id);
107
+ }
108
+
65
109
  /** Flush a slow socket's drop-oldest queue as the OS buffers drain. */
66
110
  export function drainSocket(state: ServerState, ws: ServerWebSocket<WsData>): void {
67
111
  const q = ws.data.queue;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Per-connection inbound rate limiting — a token bucket evaluated on EVERY
3
+ * inbound frame (app AND control) before any decode work, so a flooding
4
+ * client pays ~nothing and can't starve the loop.
5
+ *
6
+ * Default OFF (`options.rateLimit` unset → `null`, zero hot-path overhead).
7
+ * When enabled, each connection lazily gets its own limiter on its first
8
+ * inbound frame (no per-connect allocation for idle listeners):
9
+ *
10
+ * - tokens refill continuously at `messagesPerSecond`;
11
+ * - bucket capacity is `burst` (default = messagesPerSecond), so short
12
+ * spikes ride through while sustained floods are shed;
13
+ * - `policy: "drop"` silently sheds over-limit frames (counted in
14
+ * `metrics.rateLimited`); `policy: "close"` closes the socket 1008.
15
+ */
16
+ export interface RateLimitOptions {
17
+ /** sustained inbound frames per second per connection, default 100 */
18
+ messagesPerSecond?: number;
19
+ /** burst capacity above the sustained rate, default = messagesPerSecond */
20
+ burst?: number;
21
+ /** what happens to over-limit frames, default "drop" */
22
+ policy?: "drop" | "close";
23
+ }
24
+
25
+ /** Fully-resolved options (defaults applied once at server creation). */
26
+ export type ResolvedRateLimit = Required<RateLimitOptions>;
27
+
28
+ export interface RateLimiter {
29
+ readonly policy: "drop" | "close";
30
+ /** Consume one inbound frame; false = over limit. Monotonic `now` (epoch ms). */
31
+ allow(now: number): boolean;
32
+ }
33
+
34
+ const MIN_RATE = 0.001; // guard against a zero refill (permanent lock-up)
35
+
36
+ /** Apply option defaults once at instantiation time (not per message). */
37
+ export function resolveRateLimit(opts?: RateLimitOptions): ResolvedRateLimit | null {
38
+ if (!opts) return null;
39
+ const messagesPerSecond = Math.max(MIN_RATE, opts.messagesPerSecond ?? 100);
40
+ const burst = Math.max(1, opts.burst ?? Math.ceil(messagesPerSecond));
41
+ return { messagesPerSecond, burst, policy: opts.policy ?? "drop" };
42
+ }
43
+
44
+ /**
45
+ * Create one connection's limiter from resolved options. The bucket starts
46
+ * FULL (`burst` tokens) so a well-behaved client is never punished for
47
+ * connecting right after a burst of legitimate traffic.
48
+ */
49
+ export function createRateLimiter(r: ResolvedRateLimit): RateLimiter {
50
+ let tokens = r.burst;
51
+ let last = -1; // anchored on the FIRST frame (no pre-first-frame credit)
52
+ return {
53
+ policy: r.policy,
54
+ allow(now: number): boolean {
55
+ if (last < 0) {
56
+ last = now;
57
+ } else if (now > last) {
58
+ // continuous refill since the last consume (clamped to capacity)
59
+ tokens = Math.min(r.burst, tokens + ((now - last) / 1000) * r.messagesPerSecond);
60
+ last = now;
61
+ }
62
+ if (tokens >= 1) {
63
+ tokens -= 1;
64
+ return true;
65
+ }
66
+ return false;
67
+ },
68
+ };
69
+ }
@@ -6,6 +6,11 @@
6
6
  * History is a bounded `RingBuffer` (capacity = historySize): recording is O(1)
7
7
  * and, once full, the OLDEST frame is overwritten automatically — same
8
8
  * semantics as the old `push` + `shift`, but without the O(n) array shift.
9
+ *
10
+ * DURABILITY SEAM: an optional {@link TopicLog} (see `topic-log.ts`) receives
11
+ * every recorded frame so history can outlive the ring window. When a client
12
+ * asks for a seq the ring no longer holds, `topicHistoryFrom` hydrates the
13
+ * missing prefix from the log.
9
14
  */
10
15
  import { RingBuffer } from "./ring";
11
16
  import type { ServerState } from "./state";
@@ -18,7 +23,9 @@ export function recordReplay(state: ServerState, topic: string, frame: Uint8Arra
18
23
  hist = new RingBuffer<{ seq: number; frame: Uint8Array }>(state.replay.historySize, true);
19
24
  state.topicHistory.set(topic, hist);
20
25
  }
21
- hist.push({ seq: ++state.replaySeq, frame: frame.slice() }); // owned copy for replay
26
+ const seq = ++state.replaySeq;
27
+ hist.push({ seq, frame: frame.slice() }); // owned copy for replay
28
+ state.topicLog?.append(topic, frame, seq);
22
29
  }
23
30
 
24
31
  /** The recorded frames for `topic`, oldest → newest (already owned copies). */
@@ -29,3 +36,36 @@ export function replayFrames(state: ServerState, topic: string): Uint8Array[] {
29
36
  for (const e of hist) out.push(e.frame);
30
37
  return out;
31
38
  }
39
+
40
+ /**
41
+ * Recorded frames for `topic` strictly after `fromSeq` (0 = everything
42
+ * retained), oldest → newest. Synchronous: serves from the in-memory ring;
43
+ * when the ring's oldest entry leaves a hole above `fromSeq` and a durable
44
+ * {@link TopicLog} is configured, the missing prefix is hydrated from the log
45
+ * (blocking briefly is acceptable on this control path).
46
+ */
47
+ export function topicHistoryFrom(
48
+ state: ServerState,
49
+ topic: string,
50
+ fromSeq: number,
51
+ ): Uint8Array[] {
52
+ const hist = state.topicHistory.get(topic);
53
+ const ring: Array<{ seq: number; frame: Uint8Array }> = [];
54
+ if (hist) for (const e of hist) if (e.seq > fromSeq) ring.push(e);
55
+ if (ring.length === 0) {
56
+ // nothing in the ring at/after the resume point — try the durable log
57
+ const log = state.topicLog;
58
+ if (log && fromSeq > 0) return log.range(topic, fromSeq).map((e) => e.frame);
59
+ return [];
60
+ }
61
+ const oldest = ring[0]!.seq;
62
+ if (oldest > fromSeq + 1 && oldest > 1) {
63
+ // hole between fromSeq and the ring window — hydrate the prefix
64
+ const log = state.topicLog;
65
+ if (log) {
66
+ const prefix = log.range(topic, fromSeq, oldest - fromSeq - 1);
67
+ return [...prefix.map((e) => e.frame), ...ring.map((e) => e.frame)];
68
+ }
69
+ }
70
+ return ring.map((e) => e.frame);
71
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Delivery-sequence + resume — gap-free delivery over the WS transport.
3
+ *
4
+ * Every frame the SERVER writes to a socket carries a per-connection,
5
+ * monotonically increasing delivery seq (envelope v2 `[flags:1][seq:u64 LE]`,
6
+ * stamped in place just before `ws.send` — Bun copies synchronously). Clients
7
+ * track the seq stream and detect loss (backpressure drops, reconnect
8
+ * boundaries) as gaps.
9
+ *
10
+ * Recovery has two halves:
11
+ * - SAME-connection gaps → client sends the `resume` control frame with its
12
+ * last contiguous seq; the server replays from the connection's bounded
13
+ * sent-history ring (`ws.data.history`), frames keeping their ORIGINAL
14
+ * seqs so the client's stream stays gap-free and duplicate-free.
15
+ * - RECONNECT → on close the ring + counter move to a per-client-id
16
+ * "graveyard" (bounded, TTL'd). A reconnecting socket whose auth-pinned id
17
+ * has a grave adopts it, so `hello { lastSeq }` resumes across sessions.
18
+ *
19
+ * The stamping helpers derive offsets from `bindings.wireHeaderLen`, so v1
20
+ * bindings (header 5, no seq field) simply never stamp and never resume.
21
+ */
22
+ import type { ServerWebSocket } from "bun";
23
+ import type { Bindings } from "../bindings/types";
24
+ import { RingBuffer } from "./ring";
25
+ import type { ServerState, WsData } from "./state";
26
+
27
+ /** One entry of a connection's sent-frame history (frame is an owned copy). */
28
+ export interface SentFrame {
29
+ seq: number;
30
+ frame: Uint8Array;
31
+ }
32
+
33
+ const FLAGS_OFFSET_BACK = 9; // [..][flags:1][seq:u64 LE] tail of the header
34
+ const SEQ_OFFSET_BACK = 8;
35
+
36
+ /**
37
+ * Stamp `seq` into `frame`'s delivery header IN PLACE (flags bit0 = 1).
38
+ * Callers must own or exclusively borrow the buffer at this instant:
39
+ * `ws.send` copies synchronously, and queued/replayed frames are stamped
40
+ * before their owned copy is taken. Returns false when the bindings have no
41
+ * delivery header (v1) — callers then skip resume bookkeeping.
42
+ */
43
+ export function stampSeq(bindings: Bindings, frame: Uint8Array, seq: number): boolean {
44
+ const len = bindings.wireHeaderLen;
45
+ if (len < FLAGS_OFFSET_BACK + 1 || frame.byteLength < len) return false;
46
+ frame[len - FLAGS_OFFSET_BACK] = (frame[len - FLAGS_OFFSET_BACK] ?? 0) | 1;
47
+ // u64 LE via two u32 halves — no BigInt/DataView allocation per frame
48
+ // (this runs once per app frame PER SOCKET on the fan-out loop)
49
+ const off = len - SEQ_OFFSET_BACK;
50
+ const lo = seq % 0x100000000;
51
+ const hi = Math.floor(seq / 0x100000000);
52
+ frame[off] = lo & 0xff;
53
+ frame[off + 1] = (lo >>> 8) & 0xff;
54
+ frame[off + 2] = (lo >>> 16) & 0xff;
55
+ frame[off + 3] = (lo >>> 24) & 0xff;
56
+ frame[off + 4] = hi & 0xff;
57
+ frame[off + 5] = (hi >>> 8) & 0xff;
58
+ frame[off + 6] = (hi >>> 16) & 0xff;
59
+ frame[off + 7] = (hi >>> 24) & 0xff;
60
+ return true;
61
+ }
62
+
63
+ /** Read a frame's delivery seq; null when unstamped / not present. */
64
+ export function readSeq(bindings: Bindings, frame: Uint8Array): number | null {
65
+ const len = bindings.wireHeaderLen;
66
+ if (len < FLAGS_OFFSET_BACK + 1 || frame.byteLength < len) return null;
67
+ if ((frame[len - FLAGS_OFFSET_BACK]! & 1) === 0) return null;
68
+ const off = len - SEQ_OFFSET_BACK;
69
+ const lo =
70
+ frame[off]! | (frame[off + 1]! << 8) | (frame[off + 2]! << 16) | (frame[off + 3]! << 24);
71
+ const hi =
72
+ frame[off + 4]! | (frame[off + 5]! << 8) | (frame[off + 6]! << 16) | (frame[off + 7]! << 24);
73
+ return (lo >>> 0) + hi * 0x100000000;
74
+ }
75
+
76
+ /** Lazily create the per-connection sent-history ring (resume enabled only). */
77
+ export function ensureHistory(state: ServerState, ws: ServerWebSocket<WsData>): RingBuffer<SentFrame> | undefined {
78
+ if (!state.resume) return undefined;
79
+ let h = ws.data.history;
80
+ if (h === undefined) {
81
+ h = new RingBuffer<SentFrame>(state.resume.historySize, true);
82
+ ws.data.history = h;
83
+ }
84
+ return h;
85
+ }
86
+
87
+ /**
88
+ * Record a stamped frame in the connection's history (owned copy — the caller
89
+ * may be handing a reused scratch view to `ws.send`). No-op when resume is off
90
+ * or the frame could not be stamped.
91
+ */
92
+ export function recordSent(
93
+ _state: ServerState,
94
+ ws: ServerWebSocket<WsData>,
95
+ frame: Uint8Array,
96
+ seq: number,
97
+ ): void {
98
+ const h = ws.data.history;
99
+ if (!h) return;
100
+ h.push({ seq, frame: frame.slice() });
101
+ }
102
+
103
+ // ── graveyard (cross-connection resume) ─────────────────────────────────────
104
+
105
+ const GRAVE_MAX = 1000;
106
+
107
+ function pruneGraves(state: ServerState): void {
108
+ const now = Date.now();
109
+ for (const [id, g] of state.graves) {
110
+ if (g.expiresAt <= now) state.graves.delete(id);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * On disconnect (resume enabled): park the connection's history under its
116
+ * client id so a future session can adopt it. Bounded: beyond `GRAVE_MAX`
117
+ * entries (or past TTL) the OLDEST grave is dropped first.
118
+ */
119
+ export function burySession(state: ServerState, ws: ServerWebSocket<WsData>): void {
120
+ const ttlMs = state.resume?.ttlMs ?? 0;
121
+ const history = ws.data.history;
122
+ if (!state.resume || !history || history.length === 0) return;
123
+ pruneGraves(state);
124
+ if (state.graves.size >= GRAVE_MAX) {
125
+ // drop the soonest-expiring grave (Map preserves insertion order)
126
+ const oldest = state.graves.keys().next();
127
+ if (!oldest.done) state.graves.delete(oldest.value);
128
+ }
129
+ state.graves.set(ws.data.id, {
130
+ history,
131
+ nextSeq: ws.data.sendSeq,
132
+ expiresAt: Date.now() + ttlMs,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * On open (resume enabled): adopt a parked history for this client id, if any.
138
+ * Returns true when the connection now continues a previous seq stream.
139
+ */
140
+ export function adoptGrave(state: ServerState, ws: ServerWebSocket<WsData>): boolean {
141
+ if (!state.resume) return false;
142
+ const g = state.graves.get(ws.data.id);
143
+ if (!g) return false;
144
+ if (g.expiresAt <= Date.now()) {
145
+ state.graves.delete(ws.data.id);
146
+ return false;
147
+ }
148
+ state.graves.delete(ws.data.id);
149
+ ws.data.history = g.history;
150
+ ws.data.sendSeq = g.nextSeq;
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Replay every retained frame strictly after `lastSeq` to `ws` (original seqs
156
+ * preserved). Returns `{ ok, replayed, from }`; `ok=false` means the requested
157
+ * hole is older than the ring (partial recovery — the client should
158
+ * resubscribe topics for a fresh snapshot).
159
+ */
160
+ export function replayAfter(
161
+ _state: ServerState,
162
+ ws: ServerWebSocket<WsData>,
163
+ lastSeq: number,
164
+ ): { ok: boolean; replayed: number; from: number } {
165
+ const h = ws.data.history;
166
+ if (!h || h.length === 0) return { ok: false, replayed: 0, from: 0 };
167
+ let ok = true;
168
+ let replayed = 0;
169
+ let from = 0;
170
+ for (const e of h) {
171
+ if (e.seq <= lastSeq) continue;
172
+ if (from === 0) from = e.seq;
173
+ // a hole before/at the oldest retained frame cannot be filled
174
+ if (e.seq !== lastSeq + replayed + 1) ok = false;
175
+ // direct write — bypasses stamping (frames keep their original seq) and
176
+ // backpressure (dropping a resume into a saturated queue defeats it)
177
+ ws.send(e.frame);
178
+ replayed++;
179
+ }
180
+ return { ok, replayed, from };
181
+ }
package/src/core/rooms.ts CHANGED
@@ -4,12 +4,17 @@
4
4
  * (`publishToRoom` fans a frame out to every member).
5
5
  */
6
6
  import type { ServerWebSocket } from "bun";
7
- import { doSend, sendFrame } from "./outbound";
7
+ import { sendFrame } from "./outbound";
8
8
  import { recordReplay, replayFrames } from "./replay";
9
9
  import type { ServerState, WsData } from "./state";
10
10
 
11
11
  /** Join `ws` to `topic`, replaying any recorded history (oldest → newest). */
12
12
  export function joinRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic: string): void {
13
+ // every join path (control frames, programmatic, auth-seeded) is gated
14
+ if (state.authorizeTopic !== undefined && !state.authorizeTopic(topic, ws)) {
15
+ state.metrics.rejectedJoins++;
16
+ return;
17
+ }
13
18
  ws.data.topics.add(topic);
14
19
  let set = state.rooms.get(topic);
15
20
  if (!set) {
@@ -17,8 +22,10 @@ export function joinRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic:
17
22
  state.rooms.set(topic, set);
18
23
  }
19
24
  set.add(ws);
20
- // replay uses a direct send (not backpressure-gated) matches the original
21
- for (const frame of replayFrames(state, topic)) doSend(state, ws, frame);
25
+ // snapshot replay goes through the normal outbound path so (with resume
26
+ // enabled) the frames are stamped into this connection's seq stream and
27
+ // recorded in its history — clients can gap-recover across them too.
28
+ for (const frame of replayFrames(state, topic)) sendFrame(state, ws, frame);
22
29
  }
23
30
 
24
31
  /** Leave `topic`; prune the room when it becomes empty. */
@@ -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
+ }