@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
@@ -4,43 +4,63 @@
4
4
  * ONLY place that knows how the pieces fit together; `connect` lives here
5
5
  * because it owns the socket lifecycle.
6
6
  *
7
+ * Generic over the wire stack: `createClient(url, { bindings })` with your own
8
+ * generated bindings types `on` / `send` / ... against YOUR events. The default
9
+ * is the built-in registry, so existing code keeps working unchanged.
10
+ *
7
11
  * Public entry: `public/client.ts` re-exports `createClient` + the types.
8
12
  */
9
- import { WIRE_VERSION } from "../generated/registry";
10
- import { encodeEventFrame } from "../generated/ts-ser";
11
- import type { Events, EventName } from "../schema";
13
+ import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
12
14
  import { createClientState, setStatus, type ClientState, type ClientStatus, type IgnClientOptions } from "./client-state";
13
- import { handleMessage, sendControl, sendFrame } from "./client-wire";
15
+ import { handleMessage, emitError, sendControl, sendFrame, flushPending } from "./client-wire";
14
16
  import { startHeartbeat, stopHeartbeat } from "./client-heartbeat";
15
17
  import { scheduleReconnect } from "./client-reconnect";
18
+ import { failAllPending, createRpcRequest } from "./client-rpc";
19
+
20
+ /** Result of a request/response round-trip. */
21
+ export interface RpcResult<P> {
22
+ payload: P;
23
+ }
16
24
 
17
25
  /** 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;
26
+ export interface IgnClient<B extends Bindings = DefaultBindings> {
27
+ on<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
28
+ off<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
21
29
  /** 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;
30
+ once<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
23
31
  /** 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;
32
+ onAny(cb: (name: EventNameOf<B>, payload: unknown) => void): IgnClient<B>;
33
+ offAny(cb: (name: EventNameOf<B>, payload: unknown) => void): IgnClient<B>;
26
34
  /** Names that currently have at least one handler. */
27
- events(): EventName[];
35
+ events(): EventNameOf<B>[];
28
36
  /** Remove all handlers (optionally just for one event). */
29
- removeAllListeners(name?: EventName): IgnClient;
37
+ removeAllListeners(name?: EventNameOf<B>): IgnClient<B>;
30
38
  /** Register an error callback (decode failures, wire-version mismatch). */
31
- onError(cb: (err: Error) => void): IgnClient;
32
- offError(cb: (err: Error) => void): IgnClient;
39
+ onError(cb: (err: Error) => void): IgnClient<B>;
40
+ offError(cb: (err: Error) => void): IgnClient<B>;
33
41
  /** Watch connection lifecycle: "connecting" | "connected" | "disconnected" | "reconnecting" | "closed". */
34
- onStatus(cb: (status: ClientStatus) => void): IgnClient;
35
- offStatus(cb: (status: ClientStatus) => void): IgnClient;
42
+ onStatus(cb: (status: ClientStatus) => void): IgnClient<B>;
43
+ offStatus(cb: (status: ClientStatus) => void): IgnClient<B>;
36
44
  readonly currentStatus: ClientStatus;
37
- connect(): IgnClient;
45
+ connect(): IgnClient<B>;
38
46
  close(): void;
39
47
  /** 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;
48
+ send<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
49
+ /**
50
+ * Request/response: send `name` and await the responder's payload (encoded
51
+ * with the SAME event schema). Rejects on timeout (`timeoutMs`, default
52
+ * `requestTimeoutMs`) or when no responder is registered server-side.
53
+ */
54
+ request<K extends EventNameOf<B>>(
55
+ name: K,
56
+ payload: EventsOf<B>[K],
57
+ opts?: { readonly timeoutMs?: number },
58
+ ): Promise<RpcResult<EventsOf<B>[K]>>;
41
59
  /** Ask the server to subscribe this socket to a topic (room membership + replay). */
42
60
  subscribe(topic: string): void;
43
61
  unsubscribe(topic: string): void;
62
+ /** Ask the server to re-send recorded topic history strictly after `fromSeq`. */
63
+ snapshotRequest(topic: string, fromSeq?: number): void;
44
64
  /** Ask the server to add this socket to a server-side group. */
45
65
  joinGroup(group: string): void;
46
66
  leaveGroup(group: string): void;
@@ -50,26 +70,61 @@ export interface IgnClient {
50
70
  readonly groups: string[];
51
71
  }
52
72
 
53
- export function createClient(url: string, opts: IgnClientOptions = {}): IgnClient {
73
+ export function createClient<B extends Bindings = DefaultBindings>(
74
+ url: string,
75
+ opts: IgnClientOptions<B> = {},
76
+ ): IgnClient<B> {
54
77
  const state: ClientState = createClientState(url, opts);
55
78
 
56
- function connect(): IgnClient {
79
+ function connect(): IgnClient<B> {
80
+ // idempotent: an already-open / opening socket is never leaked or replaced
81
+ if (state.ws !== null) {
82
+ const ready = state.ws.readyState;
83
+ if (ready === WebSocket.OPEN || ready === WebSocket.CONNECTING) return api;
84
+ }
57
85
  state.closed = false;
58
86
  setStatus(state, state.attempts === 0 ? "connecting" : "reconnecting");
59
87
  const ws = new WebSocket(url);
60
88
  ws.binaryType = "arraybuffer";
89
+ // ownership guard: once a NEWER socket occupies `state.ws`, callbacks from
90
+ // this (stale) socket must not touch shared state or reschedule reconnects
91
+ const ownsState = (): boolean => state.ws === null || state.ws === ws;
61
92
  ws.onopen = () => {
93
+ if (!ownsState()) return;
62
94
  state.attempts = 0;
63
95
  setStatus(state, "connected");
64
- sendControl(state, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
96
+ state.resumeInFlight = false;
97
+ if (state.gapTimer !== null) {
98
+ clearTimeout(state.gapTimer);
99
+ state.gapTimer = null;
100
+ }
101
+ // carry the last contiguous delivery seq so a resume-capable server can
102
+ // re-send what this session missed (cross-session resume)
103
+ sendControl(state, "hello", {
104
+ version: state.bindings.wireVersion,
105
+ caps: [],
106
+ lastSeq: state.resume.enabled ? state.rxSeq : 0,
107
+ });
65
108
  // re-subscribe topics from before the disconnect (server cleared them)
66
109
  for (const t of state.subscribedTopics) sendControl(state, "subscribe", { topic: t });
67
110
  startHeartbeat(state);
68
111
  };
69
- ws.onmessage = (ev) => handleMessage(state, ev.data as ArrayBuffer | string);
112
+ ws.onmessage = (ev) => {
113
+ if (!ownsState()) return;
114
+ handleMessage(state, ev.data as ArrayBuffer | string);
115
+ };
116
+ ws.onerror = () => {
117
+ // surface refused upgrades / transport failures (onclose follows and
118
+ // drives the reconnect state machine — no double scheduling here)
119
+ if (!ownsState()) return;
120
+ emitError(state, new Error("ignex: connection error"));
121
+ };
70
122
  ws.onclose = () => {
123
+ if (!ownsState()) return; // replaced by a newer socket — its lifecycle wins
71
124
  stopHeartbeat(state);
72
125
  state.ws = null;
126
+ state.resumeInFlight = false;
127
+ flushPending(state); // accept in-flight gap loss; hello carries rxSeq on reconnect
73
128
  if (state.closed) {
74
129
  setStatus(state, "closed");
75
130
  return;
@@ -80,38 +135,38 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
80
135
  return api;
81
136
  }
82
137
 
83
- const api: IgnClient = {
138
+ const api: IgnClient<B> = {
84
139
  on(name, handler) {
85
140
  let set = state.handlers.get(name);
86
141
  if (!set) {
87
142
  set = new Set();
88
143
  state.handlers.set(name, set);
89
144
  }
90
- set.add(handler as never);
145
+ set.add(handler as (payload: unknown) => void);
91
146
  return api;
92
147
  },
93
148
  off(name, handler) {
94
- state.handlers.get(name)?.delete(handler as never);
149
+ state.handlers.get(name)?.delete(handler as (payload: unknown) => void);
95
150
  return api;
96
151
  },
97
152
  once(name, handler) {
98
- const wrap = (payload: never): void => {
153
+ const wrap = (payload: unknown): void => {
99
154
  api.off(name, wrap as never);
100
- handler(payload);
155
+ (handler as (payload: unknown) => void)(payload);
101
156
  };
102
157
  api.on(name, wrap as never);
103
158
  return api;
104
159
  },
105
160
  onAny(cb) {
106
- state.anyHandlers.add(cb);
161
+ state.anyHandlers.add(cb as (name: string, payload: unknown) => void);
107
162
  return api;
108
163
  },
109
164
  offAny(cb) {
110
- state.anyHandlers.delete(cb);
165
+ state.anyHandlers.delete(cb as (name: string, payload: unknown) => void);
111
166
  return api;
112
167
  },
113
168
  events() {
114
- return [...state.handlers.keys()];
169
+ return [...state.handlers.keys()] as EventNameOf<B>[];
115
170
  },
116
171
  removeAllListeners(name) {
117
172
  if (name) state.handlers.delete(name);
@@ -143,12 +198,28 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
143
198
  stopHeartbeat(state);
144
199
  if (state.reconnectTimer) clearTimeout(state.reconnectTimer);
145
200
  state.reconnectTimer = null;
201
+ if (state.gapTimer !== null) {
202
+ clearTimeout(state.gapTimer);
203
+ state.gapTimer = null;
204
+ }
205
+ state.pending.clear();
206
+ state.pendingFrom = 0;
207
+ failAllPending(state, new Error("ignex: client closed"));
146
208
  state.ws?.close();
147
209
  state.ws = null;
148
210
  setStatus(state, "closed");
149
211
  },
150
212
  send(name, payload) {
151
- sendFrame(state, encodeEventFrame(name, payload));
213
+ sendFrame(state, state.bindings.encodeFrame(name, payload));
214
+ },
215
+ request(name, payload, opts) {
216
+ return createRpcRequest(
217
+ state,
218
+ name,
219
+ payload,
220
+ opts,
221
+ state.bindings,
222
+ ) as Promise<RpcResult<EventsOf<B>[typeof name]>>;
152
223
  },
153
224
  subscribe(topic) {
154
225
  state.subscribedTopics.add(topic);
@@ -158,6 +229,9 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
158
229
  state.subscribedTopics.delete(topic);
159
230
  sendControl(state, "unsubscribe", { topic });
160
231
  },
232
+ snapshotRequest(topic, fromSeq = 0) {
233
+ sendControl(state, "snapshotRequest", { topic, fromSeq });
234
+ },
161
235
  joinGroup(group) {
162
236
  sendControl(state, "joinGroup", { group });
163
237
  },
@@ -14,6 +14,11 @@ import type { ServerState, WsData } from "./state";
14
14
 
15
15
  /** Add `ws` to `group` (idempotent) and index it in `state.groups`. */
16
16
  export function joinGroup(state: ServerState, ws: ServerWebSocket<WsData>, group: string): void {
17
+ // every join path (control frames, programmatic, auth-seeded) is gated
18
+ if (state.authorizeGroup !== undefined && !state.authorizeGroup(group, ws)) {
19
+ state.metrics.rejectedJoins++;
20
+ return;
21
+ }
17
22
  ws.data.groups.add(group);
18
23
  let set = state.groups.get(group);
19
24
  if (!set) {
@@ -21,6 +26,8 @@ export function joinGroup(state: ServerState, ws: ServerWebSocket<WsData>, group
21
26
  state.groups.set(group, set);
22
27
  }
23
28
  set.add(ws);
29
+ // events-layer hook (auth seed, control frames, programmatic joins all pass here)
30
+ state.onGroupChange?.(group, ws, true);
24
31
  }
25
32
 
26
33
  /** Remove `ws` from `group`; prune the group when it becomes empty. */
@@ -30,6 +37,7 @@ export function leaveGroup(state: ServerState, ws: ServerWebSocket<WsData>, grou
30
37
  if (!set) return;
31
38
  set.delete(ws);
32
39
  if (set.size === 0) state.groups.delete(group);
40
+ state.onGroupChange?.(group, ws, false);
33
41
  }
34
42
 
35
43
  /** Fan `frame` out to every member of `group` (no replay). */
@@ -1,14 +1,19 @@
1
1
  /**
2
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).
3
+ * counter object (no class, no `this`). Zero dependencies and zero allocation
4
+ * in the steady state (every counter is a plain number field).
5
+ *
6
+ * Per-event encode-path counts (`pathCounts`) are NOT tracked here: they live
7
+ * once, cumulatively, in the transport's per-event records and are derived at
8
+ * read time by `server.getMetrics()` — polling can never double-count.
6
9
  */
7
10
  export interface PathCounts {
8
11
  /** encodes that used the zero-alloc Rust FFI direct fast path */
9
12
  direct: number;
10
13
  /** encodes that fell back to the JSON path (nested/unions or disabled symbol) */
11
14
  json: number;
15
+ /** encodes via the pure-JS encoder (user schema without a native addon) */
16
+ js: number;
12
17
  }
13
18
 
14
19
  export interface MetricsSnapshot {
@@ -28,8 +33,20 @@ export interface MetricsSnapshot {
28
33
  inboundControl: number;
29
34
  /** undecodable / version-mismatched / unknown-id frames received */
30
35
  protocolErrors: number;
36
+ /** inbound frames shed by the per-connection rate limiter */
37
+ rateLimited: number;
38
+ /** topic/group joins rejected by `authorizeTopic` / `authorizeGroup` */
39
+ rejectedJoins: number;
31
40
  bytesSent: number;
32
- /** per-event encode path counts (direct vs json) */
41
+ /** frames stamped with a per-connection delivery seq (envelope v2, resume on) */
42
+ stampedSeq: number;
43
+ /** `resume` control frames served from a connection's history ring */
44
+ resumesServed: number;
45
+ /** frames re-delivered by resume replays */
46
+ framesReplayed: number;
47
+ /** resume requests that could not fully fill the requested hole */
48
+ resumeMisses: number;
49
+ /** per-event encode path counts (derived from the transport at read time) */
33
50
  pathCounts: Record<string, PathCounts>;
34
51
  connectedClients: number;
35
52
  uptimeMs: number;
@@ -41,6 +58,8 @@ export interface MetricsSnapshot {
41
58
  bridgeInboundErrors?: number;
42
59
  /** "connected" | "connecting" | "closed" (undefined when no bridge) */
43
60
  natsStatus?: string;
61
+ /** events-layer counters (present only when `createServer({ events })` is used) */
62
+ events?: import("../events/types").EventsMetricsSnapshot;
44
63
  }
45
64
 
46
65
  export interface Metrics {
@@ -52,16 +71,17 @@ export interface Metrics {
52
71
  inbound: number;
53
72
  inboundControl: number;
54
73
  protocolErrors: number;
74
+ rateLimited: number;
75
+ rejectedJoins: number;
55
76
  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;
77
+ stampedSeq: number;
78
+ resumesServed: number;
79
+ framesReplayed: number;
80
+ resumeMisses: number;
59
81
  snapshot(connectedClients: number): MetricsSnapshot;
60
82
  }
61
83
 
62
84
  export function createMetrics(startedAt = Date.now()): Metrics {
63
- const pathCounts = new Map<string, PathCounts>();
64
-
65
85
  const m: Metrics = {
66
86
  published: 0,
67
87
  sent: 0,
@@ -71,19 +91,14 @@ export function createMetrics(startedAt = Date.now()): Metrics {
71
91
  inbound: 0,
72
92
  inboundControl: 0,
73
93
  protocolErrors: 0,
94
+ rateLimited: 0,
95
+ rejectedJoins: 0,
74
96
  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
- },
97
+ stampedSeq: 0,
98
+ resumesServed: 0,
99
+ framesReplayed: 0,
100
+ resumeMisses: 0,
84
101
  snapshot(connectedClients) {
85
- const pathCountsObj: Record<string, PathCounts> = {};
86
- for (const [name, pc] of pathCounts) pathCountsObj[name] = { ...pc };
87
102
  return {
88
103
  published: m.published,
89
104
  sent: m.sent,
@@ -93,8 +108,14 @@ export function createMetrics(startedAt = Date.now()): Metrics {
93
108
  inbound: m.inbound,
94
109
  inboundControl: m.inboundControl,
95
110
  protocolErrors: m.protocolErrors,
111
+ rateLimited: m.rateLimited,
112
+ rejectedJoins: m.rejectedJoins,
96
113
  bytesSent: m.bytesSent,
97
- pathCounts: pathCountsObj,
114
+ stampedSeq: m.stampedSeq,
115
+ resumesServed: m.resumesServed,
116
+ framesReplayed: m.framesReplayed,
117
+ resumeMisses: m.resumeMisses,
118
+ pathCounts: {},
98
119
  connectedClients,
99
120
  uptimeMs: Date.now() - startedAt,
100
121
  };
@@ -4,11 +4,11 @@
4
4
  * `decide()` result; `doSend` is the single accounting point.
5
5
  */
6
6
  import type { ServerWebSocket } from "bun";
7
- import { encodeToScratch } from "../transport/transport";
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
- import type { ControlEventName, ControlEvents } from "../schema";
12
12
 
13
13
  /** Actual socket write + counters (single accounting point). */
14
14
  export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
@@ -18,13 +18,52 @@ export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: U
18
18
  }
19
19
 
20
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).
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).
26
33
  */
27
- export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
34
+ export function sendFrame(
35
+ state: ServerState,
36
+ ws: ServerWebSocket<WsData>,
37
+ frame: Uint8Array,
38
+ opts?: { readonly seq?: number },
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
+ }
28
67
  const bp = state.bp;
29
68
  if (!bp) {
30
69
  doSend(state, ws, frame);
@@ -44,8 +83,12 @@ export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame
44
83
  return;
45
84
  case "enqueue": {
46
85
  // 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
86
+ let q = ws.data.queue;
87
+ if (q === undefined) {
88
+ q = new RingBuffer<Uint8Array>();
89
+ ws.data.queue = q;
90
+ }
91
+ q.push(frame.slice()); // owned copy for the queue (already seq-stamped)
49
92
  for (let i = 0; i < d.dropHead; i++) {
50
93
  q.shift();
51
94
  state.metrics.droppedOldest++;
@@ -55,6 +98,14 @@ export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame
55
98
  }
56
99
  }
57
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
+
58
109
  /** Flush a slow socket's drop-oldest queue as the OS buffers drain. */
59
110
  export function drainSocket(state: ServerState, ws: ServerWebSocket<WsData>): void {
60
111
  const q = ws.data.queue;
@@ -72,5 +123,5 @@ export function sendControl<K extends ControlEventName>(
72
123
  name: K,
73
124
  payload: ControlEvents[K],
74
125
  ): void {
75
- sendFrame(state, ws, encodeToScratch(name, payload));
126
+ sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
76
127
  }
@@ -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
+ }