@ignex/nova 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -0,0 +1,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. */
@@ -10,11 +10,14 @@
10
10
  * going to discard.
11
11
  */
12
12
  import type { ServerWebSocket } from "bun";
13
- import { decodePayload, isControlId, readFrameHeader, WIRE_VERSION } from "../generated/registry";
14
- import type { ControlEventName, ControlEvents, EventName } from "../schema";
15
- import { sendControl } from "./outbound";
13
+ import type { ControlEventName, ControlEvents } from "../schema";
14
+ import { capturePayload } from "../events/trace";
15
+ import { sendControl, doSend } from "./outbound";
16
+ import { replayAfter } from "./resume";
16
17
  import { joinRoom, leaveRoom } from "./rooms";
18
+ import { topicHistoryFrom } from "./replay";
17
19
  import { joinGroup, leaveGroup } from "./groups";
20
+ import { createRateLimiter } from "./rate-limit";
18
21
  import type { ServerState, WsData } from "./state";
19
22
 
20
23
  // allocated once per process, reused for every inbound text frame
@@ -25,26 +28,84 @@ export function handleMessage(
25
28
  ws: ServerWebSocket<WsData>,
26
29
  raw: string | Buffer,
27
30
  ): void {
28
- 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);
29
48
  if (state.maxMessageSize !== undefined && bytes.byteLength > state.maxMessageSize) {
49
+ // multi-byte text frames can exceed the lower bound checked above
30
50
  state.metrics.protocolErrors++;
31
51
  ws.close(1009, "message too big");
32
52
  return;
33
53
  }
34
- const header = readFrameHeader(bytes);
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
+ }
69
+ const header = state.bindings.readFrameHeader(bytes);
35
70
  if (!header) {
36
71
  state.metrics.protocolErrors++;
37
72
  return; // undecodable / wrong version / unknown id — drop
38
73
  }
39
- if (isControlId(header.id)) {
74
+ if (state.bindings.isControlId(header.id)) {
40
75
  state.metrics.inboundControl++;
41
- handleControl(state, ws, header.name as ControlEventName, decodePayload(header.id, bytes) as never);
76
+ handleControl(state, ws, header.name as ControlEventName, state.bindings.decodePayload(header.id, bytes) as never);
42
77
  return;
43
78
  }
44
- const name = header.name as EventName;
79
+ const name = header.name;
45
80
  if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
46
81
  state.metrics.inbound++;
47
- state.inboundHandlers.get(name)?.(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);
99
+ // Horizontal scaling: when the bridge is configured with `bridgeClientEvents`,
100
+ // every accepted client event is re-published to `{prefix}.inbound.<event>` so
101
+ // OTHER server instances (and BE consumers) receive it. NATS-inbound frames
102
+ // arrive via `onInbound` → `fanOutAll` (never through this path), so there is
103
+ // no loop; this server's own clients are re-delivered exactly once through its
104
+ // own inbound subscription (the app handler should therefore not ALSO
105
+ // broadcast, or the event would be delivered twice locally).
106
+ if (state.bridge?.clientEvents) {
107
+ state.bridge.publish(state.bridge.subjects.inboundEvent(name), bytes);
108
+ }
48
109
  }
49
110
 
50
111
  export function handleControl(
@@ -58,12 +119,33 @@ export function handleControl(
58
119
  const p = payload as ControlEvents["hello"];
59
120
  ws.data.version = p.version;
60
121
  ws.data.lastSeq = p.lastSeq;
61
- if (p.version !== WIRE_VERSION) {
122
+ if (p.version !== state.bindings.wireVersion) {
62
123
  // protocol version mismatch — refuse this client
63
124
  ws.close(1002, "wire version mismatch");
125
+ break;
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 });
64
135
  }
65
136
  break;
66
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 });
147
+ break;
148
+ }
67
149
  case "subscribe": {
68
150
  joinRoom(state, ws, (payload as ControlEvents["subscribe"]).topic);
69
151
  break;
@@ -87,8 +169,58 @@ export function handleControl(
87
169
  case "pong":
88
170
  // client keepalive reply — tracked on the client side (heartbeat)
89
171
  break;
90
- case "snapshotRequest":
91
- // 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);
92
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
+ })();
204
+ break;
205
+ }
93
206
  }
94
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
+ }