@ignex/nova 0.1.1

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/docs/architecture.md +146 -0
  4. package/docs/publishing.md +119 -0
  5. package/docs/wire-format.md +170 -0
  6. package/index.ts +61 -0
  7. package/package.json +89 -0
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/client.ts +23 -0
  10. package/public/nats.ts +19 -0
  11. package/public/server.ts +35 -0
  12. package/rust/Cargo.toml +19 -0
  13. package/rust/src/ffi.rs +135 -0
  14. package/rust/src/generated/backend.rs +2817 -0
  15. package/rust/src/generated/mod.rs +2 -0
  16. package/rust/src/lib.rs +9 -0
  17. package/rust/src/transcode/generated.rs +1352 -0
  18. package/rust/src/transcode/mod.rs +2 -0
  19. package/src/bridge/nats.ts +269 -0
  20. package/src/bridge/subjects.ts +30 -0
  21. package/src/core/auth.ts +56 -0
  22. package/src/core/backpressure.ts +39 -0
  23. package/src/core/client-heartbeat.ts +27 -0
  24. package/src/core/client-reconnect.ts +35 -0
  25. package/src/core/client-state.ts +76 -0
  26. package/src/core/client-wire.ts +72 -0
  27. package/src/core/client.ts +176 -0
  28. package/src/core/groups.ts +52 -0
  29. package/src/core/int64-guard.ts +44 -0
  30. package/src/core/metrics.ts +105 -0
  31. package/src/core/outbound.ts +76 -0
  32. package/src/core/replay.ts +31 -0
  33. package/src/core/ring.ts +85 -0
  34. package/src/core/rooms.ts +44 -0
  35. package/src/core/routing.ts +94 -0
  36. package/src/core/server.ts +294 -0
  37. package/src/core/state.ts +179 -0
  38. package/src/generated/direct-ser.ts +495 -0
  39. package/src/generated/fbs/backend.fbs +139 -0
  40. package/src/generated/registry.ts +341 -0
  41. package/src/generated/rust/backend_generated.rs +2817 -0
  42. package/src/generated/ts/backend.ts +25 -0
  43. package/src/generated/ts/big-val.ts +106 -0
  44. package/src/generated/ts/complex.ts +303 -0
  45. package/src/generated/ts/customer.ts +137 -0
  46. package/src/generated/ts/hello.ts +123 -0
  47. package/src/generated/ts/join-group.ts +78 -0
  48. package/src/generated/ts/leave-group.ts +78 -0
  49. package/src/generated/ts/order-billing.ts +137 -0
  50. package/src/generated/ts/order-line.ts +144 -0
  51. package/src/generated/ts/order.ts +236 -0
  52. package/src/generated/ts/ping.ts +74 -0
  53. package/src/generated/ts/pong.ts +74 -0
  54. package/src/generated/ts/portfolio-position.ts +120 -0
  55. package/src/generated/ts/portfolio-snapshot.ts +170 -0
  56. package/src/generated/ts/quote.ts +148 -0
  57. package/src/generated/ts/side.ts +8 -0
  58. package/src/generated/ts/snapshot-request.ts +78 -0
  59. package/src/generated/ts/subscribe.ts +78 -0
  60. package/src/generated/ts/tags.ts +9 -0
  61. package/src/generated/ts/trade.ts +135 -0
  62. package/src/generated/ts/unsubscribe.ts +78 -0
  63. package/src/generated/ts/welcome.ts +112 -0
  64. package/src/generated/ts-ser.ts +465 -0
  65. package/src/generated/wire-registry.json +20 -0
  66. package/src/native/codec.ts +35 -0
  67. package/src/native/ffi.ts +214 -0
  68. package/src/native/loader.ts +55 -0
  69. package/src/schema/index.ts +217 -0
  70. package/src/server.ts +87 -0
  71. package/src/transport/byte-buffer-pool.ts +63 -0
  72. package/src/transport/scratch.ts +48 -0
  73. package/src/transport/stats.ts +44 -0
  74. package/src/transport/transport.ts +106 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Client composition root — wires client-state + wire / reconnect / heartbeat
3
+ * into the public `IgnClient` API object (no class, no `this`). This is the
4
+ * ONLY place that knows how the pieces fit together; `connect` lives here
5
+ * because it owns the socket lifecycle.
6
+ *
7
+ * Public entry: `public/client.ts` re-exports `createClient` + the types.
8
+ */
9
+ import { WIRE_VERSION } from "../generated/registry";
10
+ import { encodeEventFrame } from "../generated/ts-ser";
11
+ import type { Events, EventName } from "../schema";
12
+ import { createClientState, setStatus, type ClientState, type ClientStatus, type IgnClientOptions } from "./client-state";
13
+ import { handleMessage, sendControl, sendFrame } from "./client-wire";
14
+ import { startHeartbeat, stopHeartbeat } from "./client-heartbeat";
15
+ import { scheduleReconnect } from "./client-reconnect";
16
+
17
+ /** The public client API (returned by `createClient`). */
18
+ export interface IgnClient {
19
+ on<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
20
+ off<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
21
+ /** Register a handler that fires once for the event, then removes itself. */
22
+ once<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
23
+ /** Register a handler for EVERY incoming app event (name + payload). */
24
+ onAny(cb: (name: EventName, payload: unknown) => void): IgnClient;
25
+ offAny(cb: (name: EventName, payload: unknown) => void): IgnClient;
26
+ /** Names that currently have at least one handler. */
27
+ events(): EventName[];
28
+ /** Remove all handlers (optionally just for one event). */
29
+ removeAllListeners(name?: EventName): IgnClient;
30
+ /** Register an error callback (decode failures, wire-version mismatch). */
31
+ onError(cb: (err: Error) => void): IgnClient;
32
+ offError(cb: (err: Error) => void): IgnClient;
33
+ /** Watch connection lifecycle: "connecting" | "connected" | "disconnected" | "reconnecting" | "closed". */
34
+ onStatus(cb: (status: ClientStatus) => void): IgnClient;
35
+ offStatus(cb: (status: ClientStatus) => void): IgnClient;
36
+ readonly currentStatus: ClientStatus;
37
+ connect(): IgnClient;
38
+ close(): void;
39
+ /** Send a typed app event to the server (server must allow it via `inbound`). */
40
+ send<K extends EventName>(name: K, payload: Events[K]): void;
41
+ /** Ask the server to subscribe this socket to a topic (room membership + replay). */
42
+ subscribe(topic: string): void;
43
+ unsubscribe(topic: string): void;
44
+ /** Ask the server to add this socket to a server-side group. */
45
+ joinGroup(group: string): void;
46
+ leaveGroup(group: string): void;
47
+ /** The id the server assigned this connection ("" until `welcome` arrives). */
48
+ readonly clientId: string;
49
+ /** Server-side groups this client belongs to ([] until `welcome` arrives). */
50
+ readonly groups: string[];
51
+ }
52
+
53
+ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClient {
54
+ const state: ClientState = createClientState(url, opts);
55
+
56
+ function connect(): IgnClient {
57
+ state.closed = false;
58
+ setStatus(state, state.attempts === 0 ? "connecting" : "reconnecting");
59
+ const ws = new WebSocket(url);
60
+ ws.binaryType = "arraybuffer";
61
+ ws.onopen = () => {
62
+ state.attempts = 0;
63
+ setStatus(state, "connected");
64
+ sendControl(state, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
65
+ // re-subscribe topics from before the disconnect (server cleared them)
66
+ for (const t of state.subscribedTopics) sendControl(state, "subscribe", { topic: t });
67
+ startHeartbeat(state);
68
+ };
69
+ ws.onmessage = (ev) => handleMessage(state, ev.data as ArrayBuffer | string);
70
+ ws.onclose = () => {
71
+ stopHeartbeat(state);
72
+ state.ws = null;
73
+ if (state.closed) {
74
+ setStatus(state, "closed");
75
+ return;
76
+ }
77
+ scheduleReconnect(state, connect);
78
+ };
79
+ state.ws = ws;
80
+ return api;
81
+ }
82
+
83
+ const api: IgnClient = {
84
+ on(name, handler) {
85
+ let set = state.handlers.get(name);
86
+ if (!set) {
87
+ set = new Set();
88
+ state.handlers.set(name, set);
89
+ }
90
+ set.add(handler as never);
91
+ return api;
92
+ },
93
+ off(name, handler) {
94
+ state.handlers.get(name)?.delete(handler as never);
95
+ return api;
96
+ },
97
+ once(name, handler) {
98
+ const wrap = (payload: never): void => {
99
+ api.off(name, wrap as never);
100
+ handler(payload);
101
+ };
102
+ api.on(name, wrap as never);
103
+ return api;
104
+ },
105
+ onAny(cb) {
106
+ state.anyHandlers.add(cb);
107
+ return api;
108
+ },
109
+ offAny(cb) {
110
+ state.anyHandlers.delete(cb);
111
+ return api;
112
+ },
113
+ events() {
114
+ return [...state.handlers.keys()];
115
+ },
116
+ removeAllListeners(name) {
117
+ if (name) state.handlers.delete(name);
118
+ else state.handlers.clear();
119
+ return api;
120
+ },
121
+ onError(cb) {
122
+ state.errorCbs.add(cb);
123
+ return api;
124
+ },
125
+ offError(cb) {
126
+ state.errorCbs.delete(cb);
127
+ return api;
128
+ },
129
+ onStatus(cb) {
130
+ state.statusCbs.add(cb);
131
+ return api;
132
+ },
133
+ offStatus(cb) {
134
+ state.statusCbs.delete(cb);
135
+ return api;
136
+ },
137
+ get currentStatus(): ClientStatus {
138
+ return state.status;
139
+ },
140
+ connect,
141
+ close() {
142
+ state.closed = true;
143
+ stopHeartbeat(state);
144
+ if (state.reconnectTimer) clearTimeout(state.reconnectTimer);
145
+ state.reconnectTimer = null;
146
+ state.ws?.close();
147
+ state.ws = null;
148
+ setStatus(state, "closed");
149
+ },
150
+ send(name, payload) {
151
+ sendFrame(state, encodeEventFrame(name, payload));
152
+ },
153
+ subscribe(topic) {
154
+ state.subscribedTopics.add(topic);
155
+ sendControl(state, "subscribe", { topic });
156
+ },
157
+ unsubscribe(topic) {
158
+ state.subscribedTopics.delete(topic);
159
+ sendControl(state, "unsubscribe", { topic });
160
+ },
161
+ joinGroup(group) {
162
+ sendControl(state, "joinGroup", { group });
163
+ },
164
+ leaveGroup(group) {
165
+ sendControl(state, "leaveGroup", { group });
166
+ },
167
+ get clientId(): string {
168
+ return state.clientId;
169
+ },
170
+ get groups(): string[] {
171
+ return state.groups;
172
+ },
173
+ };
174
+
175
+ return api;
176
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Group membership + fan-out — a server-side targeting dimension (complements
3
+ * rooms: rooms are client-joinable with optional replay; groups are for
4
+ * server-side targeting, NO replay). Pure set operations over `state.groups`.
5
+ *
6
+ * A socket's groups live in `ws.data.groups`; `state.groups` is the reverse
7
+ * group → member-sockets index. Groups can be seeded from `authenticate`
8
+ * metadata, joined programmatically via `joinGroup`, or joined by the client
9
+ * via the `joinGroup` / `leaveGroup` control frames (routed in `routing.ts`).
10
+ */
11
+ import type { ServerWebSocket } from "bun";
12
+ import { sendFrame } from "./outbound";
13
+ import type { ServerState, WsData } from "./state";
14
+
15
+ /** Add `ws` to `group` (idempotent) and index it in `state.groups`. */
16
+ export function joinGroup(state: ServerState, ws: ServerWebSocket<WsData>, group: string): void {
17
+ ws.data.groups.add(group);
18
+ let set = state.groups.get(group);
19
+ if (!set) {
20
+ set = new Set();
21
+ state.groups.set(group, set);
22
+ }
23
+ set.add(ws);
24
+ }
25
+
26
+ /** Remove `ws` from `group`; prune the group when it becomes empty. */
27
+ export function leaveGroup(state: ServerState, ws: ServerWebSocket<WsData>, group: string): void {
28
+ ws.data.groups.delete(group);
29
+ const set = state.groups.get(group);
30
+ if (!set) return;
31
+ set.delete(ws);
32
+ if (set.size === 0) state.groups.delete(group);
33
+ }
34
+
35
+ /** Fan `frame` out to every member of `group` (no replay). */
36
+ export function publishToGroup(state: ServerState, group: string, frame: Uint8Array): void {
37
+ const set = state.groups.get(group);
38
+ if (!set) return;
39
+ for (const ws of set) sendFrame(state, ws, frame);
40
+ }
41
+
42
+ /** Live group names (with at least one member). */
43
+ export function activeGroups(state: ServerState): string[] {
44
+ return [...state.groups.keys()];
45
+ }
46
+
47
+ /** Client ids that are currently members of `group`. */
48
+ export function groupMembers(state: ServerState, group: string): string[] {
49
+ const set = state.groups.get(group);
50
+ if (!set) return [];
51
+ return [...set].map((ws) => ws.data.id);
52
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Lossless-int64 guard. Plain `number` int64 fields silently lose precision
3
+ * above ±2^53-1 (the value is coerced through a double). The real fix is
4
+ * `Type.Integer({ bigint: true })` (exact `bigint` fields); this module is the
5
+ * safety net that catches out-of-range NUMBERS at encode time.
6
+ *
7
+ * The generated direct encoders call `checkInt64` for every non-bigint int64
8
+ * field. Mode is configurable (server option `int64Guard`); when `"off"` (the
9
+ * default) the check is a cheap no-op so the zero-alloc hot path is untouched.
10
+ */
11
+ export type Int64GuardMode = "off" | "throw" | "warn";
12
+
13
+ let mode: Int64GuardMode = "off";
14
+ const warned = new Set<string>();
15
+
16
+ export function setInt64GuardMode(m: Int64GuardMode): void {
17
+ mode = m;
18
+ }
19
+
20
+ export function getInt64GuardMode(): Int64GuardMode {
21
+ return mode;
22
+ }
23
+
24
+ /**
25
+ * Assert `v` (a plain-number int64 field) is a safe integer. No-op in "off"
26
+ * mode. `label` is `"<event>.<field>"` for diagnostics.
27
+ */
28
+ export function checkInt64(label: string, v: unknown): void {
29
+ if (mode === "off") return;
30
+ if (typeof v === "bigint") return; // already exact
31
+ const n = v as number;
32
+ if (Number.isSafeInteger(n)) return;
33
+ if (mode === "throw") {
34
+ throw new RangeError(
35
+ `ignex: int64 field "${label}" value ${n} is outside the safe-integer range (±2^53-1) and would lose precision — annotate it with Type.Integer({ bigint: true })`,
36
+ );
37
+ }
38
+ if (!warned.has(label)) {
39
+ warned.add(label);
40
+ console.warn(
41
+ `ignex: int64 field "${label}" value ${n} is outside the safe-integer range (±2^53-1) and will lose precision — annotate it with Type.Integer({ bigint: true })`,
42
+ );
43
+ }
44
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Server-side metrics counters — a `createMetrics()` factory returning a plain
3
+ * counter object (no class, no `this`). Zero dependencies, no allocations in
4
+ * the steady state (counters are plain numbers; `countPath` only allocates on
5
+ * the first occurrence of an event name).
6
+ */
7
+ export interface PathCounts {
8
+ /** encodes that used the zero-alloc Rust FFI direct fast path */
9
+ direct: number;
10
+ /** encodes that fell back to the JSON path (nested/unions or disabled symbol) */
11
+ json: number;
12
+ }
13
+
14
+ export interface MetricsSnapshot {
15
+ /** encode calls (publish / publishTo / publishToTopic) */
16
+ published: number;
17
+ /** frames actually handed to a socket */
18
+ sent: number;
19
+ /** frames skipped because the socket was over its high-water mark (drop-newest) */
20
+ droppedNewest: number;
21
+ /** frames dropped from a slow socket's bounded queue (drop-oldest) */
22
+ droppedOldest: number;
23
+ /** slow consumers closed by the disconnect policy */
24
+ disconnectedSlow: number;
25
+ /** inbound app messages delivered to server.on() handlers */
26
+ inbound: number;
27
+ /** inbound control frames routed internally */
28
+ inboundControl: number;
29
+ /** undecodable / version-mismatched / unknown-id frames received */
30
+ protocolErrors: number;
31
+ bytesSent: number;
32
+ /** per-event encode path counts (direct vs json) */
33
+ pathCounts: Record<string, PathCounts>;
34
+ connectedClients: number;
35
+ uptimeMs: number;
36
+ /** NATS bridge counters (present only when a bridge is configured) */
37
+ bridged?: number;
38
+ bridgedBytes?: number;
39
+ bridgeErrors?: number;
40
+ bridgeInbound?: number;
41
+ bridgeInboundErrors?: number;
42
+ /** "connected" | "connecting" | "closed" (undefined when no bridge) */
43
+ natsStatus?: string;
44
+ }
45
+
46
+ export interface Metrics {
47
+ published: number;
48
+ sent: number;
49
+ droppedNewest: number;
50
+ droppedOldest: number;
51
+ disconnectedSlow: number;
52
+ inbound: number;
53
+ inboundControl: number;
54
+ protocolErrors: number;
55
+ bytesSent: number;
56
+ readonly pathCounts: Map<string, PathCounts>;
57
+ /** Count an encode on a given path for an event (direct = zero-alloc FFI). */
58
+ countPath(name: string, path: "direct" | "json"): void;
59
+ snapshot(connectedClients: number): MetricsSnapshot;
60
+ }
61
+
62
+ export function createMetrics(startedAt = Date.now()): Metrics {
63
+ const pathCounts = new Map<string, PathCounts>();
64
+
65
+ const m: Metrics = {
66
+ published: 0,
67
+ sent: 0,
68
+ droppedNewest: 0,
69
+ droppedOldest: 0,
70
+ disconnectedSlow: 0,
71
+ inbound: 0,
72
+ inboundControl: 0,
73
+ protocolErrors: 0,
74
+ bytesSent: 0,
75
+ pathCounts,
76
+ countPath(name, path) {
77
+ let pc = pathCounts.get(name);
78
+ if (!pc) {
79
+ pc = { direct: 0, json: 0 };
80
+ pathCounts.set(name, pc);
81
+ }
82
+ pc[path]++;
83
+ },
84
+ snapshot(connectedClients) {
85
+ const pathCountsObj: Record<string, PathCounts> = {};
86
+ for (const [name, pc] of pathCounts) pathCountsObj[name] = { ...pc };
87
+ return {
88
+ published: m.published,
89
+ sent: m.sent,
90
+ droppedNewest: m.droppedNewest,
91
+ droppedOldest: m.droppedOldest,
92
+ disconnectedSlow: m.disconnectedSlow,
93
+ inbound: m.inbound,
94
+ inboundControl: m.inboundControl,
95
+ protocolErrors: m.protocolErrors,
96
+ bytesSent: m.bytesSent,
97
+ pathCounts: pathCountsObj,
98
+ connectedClients,
99
+ uptimeMs: Date.now() - startedAt,
100
+ };
101
+ },
102
+ };
103
+
104
+ return m;
105
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Outbound write path — the ONLY place that talks to `ws.send` and mutates the
3
+ * per-socket backpressure queue / send counters. `sendFrame` executes the pure
4
+ * `decide()` result; `doSend` is the single accounting point.
5
+ */
6
+ import type { ServerWebSocket } from "bun";
7
+ import { encodeToScratch } from "../transport/transport";
8
+ import { decide } from "./backpressure";
9
+ import { RingBuffer } from "./ring";
10
+ import type { ServerState, WsData } from "./state";
11
+ import type { ControlEventName, ControlEvents } from "../schema";
12
+
13
+ /** Actual socket write + counters (single accounting point). */
14
+ export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
15
+ ws.send(frame);
16
+ state.metrics.sent++;
17
+ state.metrics.bytesSent += frame.byteLength;
18
+ }
19
+
20
+ /**
21
+ * Send one frame to a socket, honoring the configured backpressure policy.
22
+ * Happy path (no backpressure configured, or socket under the high-water mark)
23
+ * is a direct `ws.send` — zero allocations. Under pressure, `ws.send` is
24
+ * replaced by a bounded queue (drop-oldest) / skip (drop-newest) / close
25
+ * (disconnect).
26
+ */
27
+ export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
28
+ const bp = state.bp;
29
+ if (!bp) {
30
+ doSend(state, ws, frame);
31
+ return;
32
+ }
33
+ const d = decide(bp, ws);
34
+ switch (d.kind) {
35
+ case "send":
36
+ doSend(state, ws, frame);
37
+ return;
38
+ case "close":
39
+ state.metrics.disconnectedSlow++;
40
+ ws.close(1013, "slow consumer");
41
+ return;
42
+ case "drop-newest":
43
+ state.metrics.droppedNewest++;
44
+ return;
45
+ case "enqueue": {
46
+ // RingBuffer: O(1) push + drop-from-head (no array shift() memmove).
47
+ const q = (ws.data.queue ??= new RingBuffer<Uint8Array>());
48
+ q.push(frame.slice()); // owned copy for the queue
49
+ for (let i = 0; i < d.dropHead; i++) {
50
+ q.shift();
51
+ state.metrics.droppedOldest++;
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Flush a slow socket's drop-oldest queue as the OS buffers drain. */
59
+ export function drainSocket(state: ServerState, ws: ServerWebSocket<WsData>): void {
60
+ const q = ws.data.queue;
61
+ if (!q || q.length === 0 || !state.bp) return;
62
+ while (q.length > 0) {
63
+ if (ws.getBufferedAmount() > state.bp.highWaterMark) break; // still backed up
64
+ doSend(state, ws, q.shift()!);
65
+ }
66
+ }
67
+
68
+ /** Send a control frame (hello / subscribe / ping / ...) through the same outbound path. */
69
+ export function sendControl<K extends ControlEventName>(
70
+ state: ServerState,
71
+ ws: ServerWebSocket<WsData>,
72
+ name: K,
73
+ payload: ControlEvents[K],
74
+ ): void {
75
+ sendFrame(state, ws, encodeToScratch(name, payload));
76
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-topic replay history (last-value snapshots on subscribe / reconnect).
3
+ * Pure data operations on `state.topicHistory` — no socket writes here
4
+ * (`rooms.joinRoom` reads the recorded frames via `replayFrames`).
5
+ *
6
+ * History is a bounded `RingBuffer` (capacity = historySize): recording is O(1)
7
+ * and, once full, the OLDEST frame is overwritten automatically — same
8
+ * semantics as the old `push` + `shift`, but without the O(n) array shift.
9
+ */
10
+ import { RingBuffer } from "./ring";
11
+ import type { ServerState } from "./state";
12
+
13
+ /** Record an owned copy of `frame` for `topic`, bounded to historySize. */
14
+ export function recordReplay(state: ServerState, topic: string, frame: Uint8Array): void {
15
+ if (!state.replay) return;
16
+ let hist = state.topicHistory.get(topic);
17
+ if (!hist) {
18
+ hist = new RingBuffer<{ seq: number; frame: Uint8Array }>(state.replay.historySize, true);
19
+ state.topicHistory.set(topic, hist);
20
+ }
21
+ hist.push({ seq: ++state.replaySeq, frame: frame.slice() }); // owned copy for replay
22
+ }
23
+
24
+ /** The recorded frames for `topic`, oldest → newest (already owned copies). */
25
+ export function replayFrames(state: ServerState, topic: string): Uint8Array[] {
26
+ const hist = state.topicHistory.get(topic);
27
+ if (!hist || hist.length === 0) return [];
28
+ const out: Uint8Array[] = [];
29
+ for (const e of hist) out.push(e.frame);
30
+ return out;
31
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `RingBuffer<T>` — a fixed/auto-growing FIFO ring buffer with O(1) `push`
3
+ * and `shift` (no array `shift()` memmove). Used by the drop-oldest
4
+ * backpressure queue (`outbound.ts`) and the bounded replay history
5
+ * (`replay.ts`), both of which previously used `Array.prototype.push` +
6
+ * `shift` and were O(n) per operation while saturated.
7
+ *
8
+ * Two modes:
9
+ * - `bounded: true` — capacity is fixed at construction; once full, `push`
10
+ * OVERWRITES the oldest element (drop-oldest). Ideal for "keep the last N".
11
+ * - `bounded: false` (default) — capacity doubles on demand (FIFO queue).
12
+ *
13
+ * Iteration walks oldest → newest without consuming.
14
+ */
15
+ export class RingBuffer<T> {
16
+ private buf: (T | undefined)[];
17
+ private head = 0; // index of the oldest element
18
+ private len = 0;
19
+ private readonly bounded: boolean;
20
+
21
+ constructor(capacity = 16, bounded = false) {
22
+ this.buf = Array.from({ length: Math.max(1, capacity) }, () => undefined as T | undefined);
23
+ this.bounded = bounded;
24
+ }
25
+
26
+ /** Number of elements currently held. */
27
+ get length(): number {
28
+ return this.len;
29
+ }
30
+
31
+ /** Current backing capacity (grows on demand when unbounded). */
32
+ get capacity(): number {
33
+ return this.buf.length;
34
+ }
35
+
36
+ /**
37
+ * Append `v`. When `bounded` and full, the OLDEST element is overwritten
38
+ * (drop-oldest); otherwise the backing array grows.
39
+ */
40
+ push(v: T): void {
41
+ if (this.len === this.buf.length) {
42
+ if (this.bounded) {
43
+ // overwrite the oldest slot, then treat it as the new tail
44
+ this.buf[this.head] = v;
45
+ this.head = (this.head + 1) % this.buf.length;
46
+ return;
47
+ }
48
+ this.grow();
49
+ }
50
+ this.buf[(this.head + this.len) % this.buf.length] = v;
51
+ this.len++;
52
+ }
53
+
54
+ /** Remove + return the oldest element, or `undefined` when empty. */
55
+ shift(): T | undefined {
56
+ if (this.len === 0) return undefined;
57
+ const v = this.buf[this.head];
58
+ this.buf[this.head] = undefined;
59
+ this.head = (this.head + 1) % this.buf.length;
60
+ this.len--;
61
+ return v;
62
+ }
63
+
64
+ /** Iterate oldest → newest (non-consuming). */
65
+ *[Symbol.iterator](): Iterator<T> {
66
+ for (let i = 0; i < this.len; i++) {
67
+ yield this.buf[(this.head + i) % this.buf.length] as T;
68
+ }
69
+ }
70
+
71
+ clear(): void {
72
+ this.buf.fill(undefined);
73
+ this.head = 0;
74
+ this.len = 0;
75
+ }
76
+
77
+ private grow(): void {
78
+ const next = Array.from({ length: this.buf.length * 2 }, () => undefined as T | undefined);
79
+ for (let i = 0; i < this.len; i++) {
80
+ next[i] = this.buf[(this.head + i) % this.buf.length];
81
+ }
82
+ this.buf = next;
83
+ this.head = 0;
84
+ }
85
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Room (topic) membership + fan-out. Pure set operations over `state.rooms`,
3
+ * wired to replay (`joinRoom` sends recorded history) and the outbound path
4
+ * (`publishToRoom` fans a frame out to every member).
5
+ */
6
+ import type { ServerWebSocket } from "bun";
7
+ import { doSend, sendFrame } from "./outbound";
8
+ import { recordReplay, replayFrames } from "./replay";
9
+ import type { ServerState, WsData } from "./state";
10
+
11
+ /** Join `ws` to `topic`, replaying any recorded history (oldest → newest). */
12
+ export function joinRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic: string): void {
13
+ ws.data.topics.add(topic);
14
+ let set = state.rooms.get(topic);
15
+ if (!set) {
16
+ set = new Set();
17
+ state.rooms.set(topic, set);
18
+ }
19
+ 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);
22
+ }
23
+
24
+ /** Leave `topic`; prune the room when it becomes empty. */
25
+ export function leaveRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic: string): void {
26
+ ws.data.topics.delete(topic);
27
+ const set = state.rooms.get(topic);
28
+ if (!set) return;
29
+ set.delete(ws);
30
+ if (set.size === 0) state.rooms.delete(topic);
31
+ }
32
+
33
+ /** Live topic names (with at least one subscriber). */
34
+ export function roomTopics(state: ServerState): string[] {
35
+ return [...state.rooms.keys()];
36
+ }
37
+
38
+ /** Record replay (if enabled) then deliver `frame` to every room member. */
39
+ export function publishToRoom(state: ServerState, topic: string, frame: Uint8Array): void {
40
+ recordReplay(state, topic, frame);
41
+ const set = state.rooms.get(topic);
42
+ if (!set) return;
43
+ for (const ws of set) sendFrame(state, ws, frame);
44
+ }