@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
package/src/core/auth.ts CHANGED
@@ -7,9 +7,64 @@
7
7
  * `authenticate` may return a `ClientMeta` (`{id, groups, meta}`) to pin the
8
8
  * client's identity for targeted sends / grouping; otherwise a UUID is
9
9
  * auto-assigned. A duplicate explicit id rejects the new connection (409).
10
+ *
11
+ * Literal bearer tokens are compared in CONSTANT TIME (no length or prefix
12
+ * oracle for a brute-forcing caller). The same gate protects the HTTP admin
13
+ * surface (`GET /clients`) via `authorizeHttp` — introspection endpoints must
14
+ * never be wider than the WebSocket they introspect.
10
15
  */
16
+ import { timingSafeEqual } from "node:crypto";
11
17
  import type { ClientMeta, ServerState, WsData } from "./state";
12
18
 
19
+ /** Constant-time string equality (UTF-8 compared; length-safe). */
20
+ export function safeEqual(a: string, b: string): boolean {
21
+ const ab = Buffer.from(a, "utf8");
22
+ const bb = Buffer.from(b, "utf8");
23
+ if (ab.byteLength !== bb.byteLength) {
24
+ // burn comparable time so mismatched-length guesses aren't cheaper
25
+ timingSafeEqual(ab, ab);
26
+ return false;
27
+ }
28
+ return timingSafeEqual(ab, bb);
29
+ }
30
+
31
+ /** Evaluate the server's token gate against a raw Bearer value. */
32
+ export function tokenOk(state: ServerState, bearer: string): boolean {
33
+ if (state.token === undefined) return true;
34
+ return typeof state.token === "function" ? state.token(bearer) : safeEqual(bearer, state.token);
35
+ }
36
+
37
+ function bearerOf(req: Request): string {
38
+ const auth = req.headers.get("authorization") ?? "";
39
+ return auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
40
+ }
41
+
42
+ /**
43
+ * HTTP admin gate (defense-in-depth for non-WebSocket routes): when a `token`
44
+ * is configured the request MUST carry a valid Bearer; otherwise, when an
45
+ * `authenticate` hook exists it must accept the request. Unauthenticated
46
+ * servers stay unauthenticated (documented dev behavior).
47
+ */
48
+ export async function authorizeHttp(
49
+ state: ServerState,
50
+ req: Request,
51
+ ): Promise<Response | undefined> {
52
+ if (state.token !== undefined || state.authenticate !== undefined) {
53
+ if (!tokenOk(state, bearerOf(req))) return new Response("unauthorized", { status: 401 });
54
+ if (state.token === undefined && state.authenticate !== undefined) {
55
+ // a failing auth backend must deny, not blow up the serve loop
56
+ let allowed: unknown;
57
+ try {
58
+ allowed = await state.authenticate(req);
59
+ } catch {
60
+ return new Response("unauthorized", { status: 401 });
61
+ }
62
+ if (!allowed) return new Response("unauthorized", { status: 401 });
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
13
68
  export async function checkUpgrade(
14
69
  state: ServerState,
15
70
  req: Request,
@@ -25,14 +80,19 @@ export async function checkUpgrade(
25
80
  }
26
81
  }
27
82
  if (state.token) {
28
- const auth = req.headers.get("authorization") ?? "";
29
- const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
30
- const ok = typeof state.token === "function" ? state.token(bearer) : bearer === state.token;
83
+ const ok = tokenOk(state, bearerOf(req));
31
84
  if (!ok) return new Response("unauthorized", { status: 401 });
32
85
  }
33
86
  let authMeta: ClientMeta | undefined;
34
87
  if (state.authenticate) {
35
- const res = await state.authenticate(req);
88
+ // a throwing / rejecting hook denies the upgrade cleanly (401) instead of
89
+ // surfacing an unhandled error through Bun.serve's fetch loop
90
+ let res: Awaited<ReturnType<typeof state.authenticate>>;
91
+ try {
92
+ res = await state.authenticate(req);
93
+ } catch {
94
+ return new Response("unauthorized", { status: 401 });
95
+ }
36
96
  if (!res) return new Response("unauthorized", { status: 401 });
37
97
  if (typeof res === "object") authMeta = res;
38
98
  }
@@ -44,6 +104,7 @@ export async function checkUpgrade(
44
104
  }
45
105
  const data: WsData = {
46
106
  lastSeq: 0,
107
+ sendSeq: 1,
47
108
  topics: new Set(),
48
109
  groups: new Set(authMeta?.groups ?? []),
49
110
  id,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Client RPC — the request/response half of `client.request(name, payload)`.
3
+ *
4
+ * Extracted from the client composition root so the wire-level plumbing
5
+ * (base64 framing, pending-call registry, timeout bookkeeping) lives in one
6
+ * small module. The reply side (`rpcResult` handling) is in `client-wire.ts`.
7
+ */
8
+ import type { Bindings } from "../bindings/types";
9
+ import { sendControl } from "./client-wire";
10
+ import type { ClientState } from "./client-state";
11
+
12
+ /** Encode bytes as base64 without pulling in a Buffer dependency (browser-safe). */
13
+ export function bytesToB64(bytes: Uint8Array): string {
14
+ let s = "";
15
+ for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]!);
16
+ return btoa(s);
17
+ }
18
+
19
+ /** Reject every pending rpc call (client close / socket loss). */
20
+ export function failAllPending(
21
+ state: ClientState,
22
+ err: Error,
23
+ ): void {
24
+ for (const [id, call] of state.rpcPending) {
25
+ clearTimeout(call.timer);
26
+ call.reject(err);
27
+ state.rpcPending.delete(id);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Run a request/response round-trip: encode `payload` with the event's own
33
+ * schema, send it inside an `rpcCall` control frame, and settle with
34
+ * `{ payload }` from the responder. Rejects on timeout, transport loss, or a
35
+ * server-side error — never leaves a pending entry behind.
36
+ */
37
+ export function createRpcRequest<B extends Bindings>(
38
+ state: ClientState,
39
+ name: string,
40
+ payload: unknown,
41
+ opts: { readonly timeoutMs?: number } | undefined,
42
+ bindings: B,
43
+ ): Promise<unknown> {
44
+ const ws = state.ws;
45
+ if (!ws || ws.readyState !== WebSocket.OPEN)
46
+ return Promise.reject(new Error("ignex: client is not connected"));
47
+
48
+ const id = crypto.randomUUID();
49
+ const inner = bindings.encodeFrame(name, payload);
50
+ const b64 = bytesToB64(inner);
51
+ const timeoutMs = opts?.timeoutMs ?? state.requestTimeoutMs;
52
+
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ state.rpcPending.delete(id);
56
+ reject(new Error(`ignex rpc "${name}" timed out after ${timeoutMs}ms`));
57
+ }, timeoutMs);
58
+
59
+ // registered BEFORE the send so a fast reply can never race the insert
60
+ state.rpcPending.set(id, {
61
+ name,
62
+ // the wire resolves with the bare payload — wrap it into RpcResult here
63
+ resolve: (out) => resolve({ payload: out }),
64
+ reject,
65
+ timer,
66
+ });
67
+ try {
68
+ sendControl(state, "rpcCall", { id, name, payloadB64: b64 });
69
+ } catch (err) {
70
+ clearTimeout(timer);
71
+ state.rpcPending.delete(id);
72
+ reject(err instanceof Error ? err : new Error(String(err)));
73
+ }
74
+ });
75
+ }
@@ -32,6 +32,16 @@ export interface IgnClientOptions<B extends Bindings = DefaultBindings> {
32
32
  heartbeatMs?: number;
33
33
  /** miss this many heartbeats before assuming the connection is dead, default 2 */
34
34
  heartbeatMisses?: number;
35
+ /**
36
+ * Gap-free delivery (requires the server started with `resume`): track the
37
+ * server's per-connection delivery seqs, detect gaps, and automatically
38
+ * request re-delivery via the `resume` control frame. Buffered out-of-order
39
+ * frames are bounded by `maxPending`. Default: true (no-op against servers
40
+ * that don't stamp seqs).
41
+ */
42
+ resume?: boolean | { maxPending?: number; timeoutMs?: number };
43
+ /** request/response default timeout (ms), default 10_000 */
44
+ requestTimeoutMs?: number;
35
45
  }
36
46
 
37
47
  export interface ClientState {
@@ -55,6 +65,24 @@ export interface ClientState {
55
65
  clientId: string;
56
66
  /** server-side groups this client belongs to (from `welcome`; [] until known) */
57
67
  groups: string[];
68
+
69
+ // ── delivery-seq tracking / gap recovery (envelope v2) ──────────────────
70
+ /** resolved resume options (enabled: false when off) */
71
+ resume: { enabled: boolean; maxPending: number; timeoutMs: number };
72
+ /** last CONTIGUOUS delivery seq processed (0 = none yet) */
73
+ rxSeq: number;
74
+ /** frames held out-of-order while a gap is being filled (seq → frame bytes) */
75
+ pending: Map<number, Uint8Array[]>;
76
+ /** seq the pending buffer is waiting to fill from */
77
+ pendingFrom: number;
78
+ /** in-flight resume request flag (throttles re-asks) */
79
+ resumeInFlight: boolean;
80
+ /** force-flush timer for an unfillable gap */
81
+ gapTimer: ReturnType<typeof setTimeout> | null;
82
+ /** request/response: correlation id → pending call */
83
+ rpcPending: Map<string, { resolve: (payload: unknown) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout>; name: string }>;
84
+ /** default request timeout (ms) */
85
+ requestTimeoutMs: number;
58
86
  }
59
87
 
60
88
  export function createClientState<B extends Bindings = DefaultBindings>(
@@ -79,6 +107,20 @@ export function createClientState<B extends Bindings = DefaultBindings>(
79
107
  lastPong: 0,
80
108
  clientId: "",
81
109
  groups: [],
110
+ resume: {
111
+ enabled: opts.resume !== false,
112
+ maxPending:
113
+ (typeof opts.resume === "object" ? opts.resume.maxPending : undefined) ?? 1024,
114
+ timeoutMs:
115
+ (typeof opts.resume === "object" ? opts.resume.timeoutMs : undefined) ?? 5_000,
116
+ },
117
+ rxSeq: 0,
118
+ pending: new Map(),
119
+ pendingFrom: 0,
120
+ resumeInFlight: false,
121
+ gapTimer: null,
122
+ rpcPending: new Map(),
123
+ requestTimeoutMs: opts.requestTimeoutMs ?? 10_000,
82
124
  };
83
125
  }
84
126
 
@@ -3,6 +3,12 @@
3
3
  * `handleMessage` decodes the envelope, filters control frames, and fans app
4
4
  * events out to the registered handlers. All decode/encode goes through
5
5
  * `state.bindings`, so a client speaks whatever wire stack it was given.
6
+ *
7
+ * With envelope v2 (delivery seqs) and `resume` enabled, app frames pass
8
+ * through a small ordering gate: contiguous seqs deliver immediately; a GAP
9
+ * buffers out-of-order frames and asks the server to re-send the hole
10
+ * (`resume` control frame); replayed hole-fills keep their original seqs so
11
+ * delivery stays in-order and duplicate-free.
6
12
  */
7
13
  import type { ControlEventName, ControlEvents } from "../schema";
8
14
  import type { ClientState } from "./client-state";
@@ -27,6 +33,103 @@ export function emitError(state: ClientState, err: Error): void {
27
33
  for (const cb of state.errorCbs) cb(err);
28
34
  }
29
35
 
36
+ /** Delivery header offsets derived from the bindings' header length. */
37
+ const flagsAt = (state: ClientState): number => state.bindings.wireHeaderLen - 9;
38
+ const seqAt = (state: ClientState): number => state.bindings.wireHeaderLen - 8;
39
+
40
+ function readDeliverySeq(state: ClientState, bytes: Uint8Array): number | null {
41
+ const len = state.bindings.wireHeaderLen;
42
+ if (len < 14 || bytes.byteLength < len) return null;
43
+ if ((bytes[flagsAt(state)]! & 1) === 0) return null;
44
+ return Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(seqAt(state), true));
45
+ }
46
+
47
+ /** Ask the server to re-send everything after `state.rxSeq` (throttled). */
48
+ export function requestResume(state: ClientState): void {
49
+ if (!state.resume.enabled || state.resumeInFlight) return;
50
+ try {
51
+ sendControl(state, "resume", { lastSeq: state.rxSeq });
52
+ state.resumeInFlight = true;
53
+ } catch {
54
+ // not connected — reconnect flow will carry lastSeq in `hello`
55
+ }
56
+ }
57
+
58
+ /** Force-drain the pending buffer after an unfillable gap (accept loss). */
59
+ export function flushPending(state: ClientState): void {
60
+ if (state.pending.size === 0) return;
61
+ const seqs = [...state.pending.keys()].sort((a, b) => a - b);
62
+ for (const s of seqs) {
63
+ for (const f of state.pending.get(s)!) dispatchAppFrame(state, f);
64
+ state.pending.delete(s);
65
+ }
66
+ if (seqs.length > 0 && seqs[seqs.length - 1]! > state.rxSeq) {
67
+ state.rxSeq = seqs[seqs.length - 1]!;
68
+ }
69
+ state.pendingFrom = 0;
70
+ }
71
+
72
+ /** Dispatch one decoded APP frame to handlers (no ordering). */
73
+ function dispatchAppFrame(state: ClientState, bytes: Uint8Array): void {
74
+ const frame = state.bindings.decodeFrame(bytes);
75
+ if (!frame) return;
76
+ const set = state.handlers.get(frame.name);
77
+ if (set) for (const cb of set) cb(frame.payload);
78
+ for (const cb of state.anyHandlers) cb(frame.name, frame.payload);
79
+ }
80
+
81
+ /**
82
+ * Ordered delivery of one raw app frame. Returns true when the frame was
83
+ * consumed here (contiguous / buffered / replayed); false = not tracking.
84
+ */
85
+ function orderedDeliver(state: ClientState, bytes: Uint8Array, seq: number): boolean {
86
+ if (!state.resume.enabled) return false;
87
+ if (seq === state.rxSeq + 1) {
88
+ state.rxSeq = seq;
89
+ dispatchAppFrame(state, bytes);
90
+ // drain anything the gap-fill delivered contiguously behind us
91
+ while (state.pending.has(state.rxSeq + 1)) {
92
+ const next = state.rxSeq + 1;
93
+ for (const f of state.pending.get(next)!) dispatchAppFrame(state, f);
94
+ state.pending.delete(next);
95
+ state.rxSeq = next;
96
+ }
97
+ if (state.pending.size === 0) state.pendingFrom = 0;
98
+ return true;
99
+ }
100
+ if (seq <= state.rxSeq) {
101
+ // Duplicate of an already-dispatched frame. Every seq ≤ rxSeq has been
102
+ // delivered (contiguously or via the pending drain), so a late replay of
103
+ // it must NOT be dispatched again. This happens when a `resume`/`hello`
104
+ // replay range overlaps frames the client already received live — e.g.
105
+ // the server processed our `hello { lastSeq }` AFTER the frames were sent
106
+ // (reconnect race), or a gap-fill replay re-sends frames that were already
107
+ // drained out of `pending`. Redelivering here would break the duplicate-
108
+ // free contract, so we consume and drop.
109
+ return true;
110
+ }
111
+ // seq > rxSeq + 1 → GAP: buffer, then ask the server to fill it
112
+ let list = state.pending.get(seq);
113
+ if (!list) {
114
+ if (state.pending.size >= state.resume.maxPending) {
115
+ flushPending(state); // accept loss rather than grow unbounded
116
+ }
117
+ list = [];
118
+ state.pending.set(seq, list);
119
+ }
120
+ list.push(bytes);
121
+ if (state.pendingFrom === 0) state.pendingFrom = state.rxSeq;
122
+ requestResume(state);
123
+ if (state.gapTimer === null) {
124
+ state.gapTimer = setTimeout(() => {
125
+ state.gapTimer = null;
126
+ // server could not fill the hole (resume-miss / offline) — accept loss
127
+ flushPending(state);
128
+ }, state.resume.timeoutMs);
129
+ }
130
+ return true;
131
+ }
132
+
30
133
  export function handleControl(state: ClientState, name: ControlEventName, payload: unknown): void {
31
134
  switch (name) {
32
135
  case "hello": {
@@ -44,6 +147,37 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
44
147
  state.groups = [...p.groups];
45
148
  break;
46
149
  }
150
+ case "resumed": {
151
+ state.resumeInFlight = false;
152
+ const p = payload as ControlEvents["resumed"];
153
+ if (!p.ok && p.from === 0) {
154
+ // server has nothing after our seq (fresh process / grave evicted) —
155
+ // accept the loss and continue from the buffered frames
156
+ flushPending(state);
157
+ }
158
+ // ok=true: replayed frames follow with their original seqs and slot
159
+ // into the ordering gate automatically.
160
+ break;
161
+ }
162
+ case "rpcResult": {
163
+ const p = payload as ControlEvents["rpcResult"];
164
+ const call = state.rpcPending.get(p.id);
165
+ if (!call) break;
166
+ state.rpcPending.delete(p.id);
167
+ clearTimeout(call.timer);
168
+ if (!p.ok) {
169
+ call.reject(new Error(`ignex rpc "${call.name}" failed: ${p.err}`));
170
+ break;
171
+ }
172
+ try {
173
+ const bytes = Uint8Array.from(atob(p.payloadB64), (c) => c.charCodeAt(0));
174
+ const frame = state.bindings.decodeFrame(bytes);
175
+ call.resolve(frame?.payload);
176
+ } catch (err) {
177
+ call.reject(err instanceof Error ? err : new Error("ignex rpc: bad result"));
178
+ }
179
+ break;
180
+ }
47
181
  case "pong":
48
182
  state.lastPong = Date.now();
49
183
  break;
@@ -55,17 +189,17 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
55
189
  export function handleMessage(state: ClientState, data: ArrayBuffer | string): void {
56
190
  if (typeof data === "string") return; // ignore text frames
57
191
  const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : (data as Uint8Array);
58
- const frame = state.bindings.decodeFrame(bytes);
59
- if (!frame) {
192
+ // fast envelope check before any decode
193
+ const header = state.bindings.readFrameHeader(bytes);
194
+ if (!header) {
60
195
  emitError(state, new Error("ignex: undecodable / version-mismatched frame dropped"));
61
196
  return;
62
197
  }
63
- if (state.bindings.isControlId(frame.id)) {
64
- handleControl(state, frame.name as ControlEventName, frame.payload);
198
+ if (state.bindings.isControlId(header.id)) {
199
+ handleControl(state, header.name as ControlEventName, state.bindings.decodePayload(header.id, bytes));
65
200
  return;
66
201
  }
67
- const name = frame.name;
68
- const set = state.handlers.get(name);
69
- if (set) for (const cb of set) cb(frame.payload);
70
- for (const cb of state.anyHandlers) cb(name, frame.payload);
202
+ const seq = readDeliverySeq(state, bytes);
203
+ if (seq !== null && orderedDeliver(state, bytes, seq)) return;
204
+ dispatchAppFrame(state, bytes);
71
205
  }
@@ -12,9 +12,15 @@
12
12
  */
13
13
  import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
14
14
  import { createClientState, setStatus, type ClientState, type ClientStatus, type IgnClientOptions } from "./client-state";
15
- import { handleMessage, sendControl, sendFrame } from "./client-wire";
15
+ import { handleMessage, emitError, sendControl, sendFrame, flushPending } from "./client-wire";
16
16
  import { startHeartbeat, stopHeartbeat } from "./client-heartbeat";
17
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
+ }
18
24
 
19
25
  /** The public client API (returned by `createClient`). */
20
26
  export interface IgnClient<B extends Bindings = DefaultBindings> {
@@ -40,9 +46,21 @@ export interface IgnClient<B extends Bindings = DefaultBindings> {
40
46
  close(): void;
41
47
  /** Send a typed app event to the server (server must allow it via `inbound`). */
42
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]>>;
43
59
  /** Ask the server to subscribe this socket to a topic (room membership + replay). */
44
60
  subscribe(topic: string): void;
45
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;
46
64
  /** Ask the server to add this socket to a server-side group. */
47
65
  joinGroup(group: string): void;
48
66
  leaveGroup(group: string): void;
@@ -59,22 +77,54 @@ export function createClient<B extends Bindings = DefaultBindings>(
59
77
  const state: ClientState = createClientState(url, opts);
60
78
 
61
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
+ }
62
85
  state.closed = false;
63
86
  setStatus(state, state.attempts === 0 ? "connecting" : "reconnecting");
64
87
  const ws = new WebSocket(url);
65
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;
66
92
  ws.onopen = () => {
93
+ if (!ownsState()) return;
67
94
  state.attempts = 0;
68
95
  setStatus(state, "connected");
69
- sendControl(state, "hello", { version: state.bindings.wireVersion, 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
+ });
70
108
  // re-subscribe topics from before the disconnect (server cleared them)
71
109
  for (const t of state.subscribedTopics) sendControl(state, "subscribe", { topic: t });
72
110
  startHeartbeat(state);
73
111
  };
74
- 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
+ };
75
122
  ws.onclose = () => {
123
+ if (!ownsState()) return; // replaced by a newer socket — its lifecycle wins
76
124
  stopHeartbeat(state);
77
125
  state.ws = null;
126
+ state.resumeInFlight = false;
127
+ flushPending(state); // accept in-flight gap loss; hello carries rxSeq on reconnect
78
128
  if (state.closed) {
79
129
  setStatus(state, "closed");
80
130
  return;
@@ -148,6 +198,13 @@ export function createClient<B extends Bindings = DefaultBindings>(
148
198
  stopHeartbeat(state);
149
199
  if (state.reconnectTimer) clearTimeout(state.reconnectTimer);
150
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"));
151
208
  state.ws?.close();
152
209
  state.ws = null;
153
210
  setStatus(state, "closed");
@@ -155,6 +212,15 @@ export function createClient<B extends Bindings = DefaultBindings>(
155
212
  send(name, payload) {
156
213
  sendFrame(state, state.bindings.encodeFrame(name, payload));
157
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]>>;
223
+ },
158
224
  subscribe(topic) {
159
225
  state.subscribedTopics.add(topic);
160
226
  sendControl(state, "subscribe", { topic });
@@ -163,6 +229,9 @@ export function createClient<B extends Bindings = DefaultBindings>(
163
229
  state.subscribedTopics.delete(topic);
164
230
  sendControl(state, "unsubscribe", { topic });
165
231
  },
232
+ snapshotRequest(topic, fromSeq = 0) {
233
+ sendControl(state, "snapshotRequest", { topic, fromSeq });
234
+ },
166
235
  joinGroup(group) {
167
236
  sendControl(state, "joinGroup", { group });
168
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) {
@@ -1,8 +1,11 @@
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 */
@@ -30,8 +33,20 @@ export interface MetricsSnapshot {
30
33
  inboundControl: number;
31
34
  /** undecodable / version-mismatched / unknown-id frames received */
32
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;
33
40
  bytesSent: number;
34
- /** per-event encode path counts (direct vs json vs js) */
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) */
35
50
  pathCounts: Record<string, PathCounts>;
36
51
  connectedClients: number;
37
52
  uptimeMs: number;
@@ -56,16 +71,17 @@ export interface Metrics {
56
71
  inbound: number;
57
72
  inboundControl: number;
58
73
  protocolErrors: number;
74
+ rateLimited: number;
75
+ rejectedJoins: number;
59
76
  bytesSent: number;
60
- readonly pathCounts: Map<string, PathCounts>;
61
- /** Count an encode on a given path for an event (direct = zero-alloc FFI). */
62
- countPath(name: string, path: "direct" | "json" | "js"): void;
77
+ stampedSeq: number;
78
+ resumesServed: number;
79
+ framesReplayed: number;
80
+ resumeMisses: number;
63
81
  snapshot(connectedClients: number): MetricsSnapshot;
64
82
  }
65
83
 
66
84
  export function createMetrics(startedAt = Date.now()): Metrics {
67
- const pathCounts = new Map<string, PathCounts>();
68
-
69
85
  const m: Metrics = {
70
86
  published: 0,
71
87
  sent: 0,
@@ -75,19 +91,14 @@ export function createMetrics(startedAt = Date.now()): Metrics {
75
91
  inbound: 0,
76
92
  inboundControl: 0,
77
93
  protocolErrors: 0,
94
+ rateLimited: 0,
95
+ rejectedJoins: 0,
78
96
  bytesSent: 0,
79
- pathCounts,
80
- countPath(name, path) {
81
- let pc = pathCounts.get(name);
82
- if (!pc) {
83
- pc = { direct: 0, json: 0, js: 0 };
84
- pathCounts.set(name, pc);
85
- }
86
- pc[path]++;
87
- },
97
+ stampedSeq: 0,
98
+ resumesServed: 0,
99
+ framesReplayed: 0,
100
+ resumeMisses: 0,
88
101
  snapshot(connectedClients) {
89
- const pathCountsObj: Record<string, PathCounts> = {};
90
- for (const [name, pc] of pathCounts) pathCountsObj[name] = { ...pc };
91
102
  return {
92
103
  published: m.published,
93
104
  sent: m.sent,
@@ -97,8 +108,14 @@ export function createMetrics(startedAt = Date.now()): Metrics {
97
108
  inbound: m.inbound,
98
109
  inboundControl: m.inboundControl,
99
110
  protocolErrors: m.protocolErrors,
111
+ rateLimited: m.rateLimited,
112
+ rejectedJoins: m.rejectedJoins,
100
113
  bytesSent: m.bytesSent,
101
- pathCounts: pathCountsObj,
114
+ stampedSeq: m.stampedSeq,
115
+ resumesServed: m.resumesServed,
116
+ framesReplayed: m.framesReplayed,
117
+ resumeMisses: m.resumeMisses,
118
+ pathCounts: {},
102
119
  connectedClients,
103
120
  uptimeMs: Date.now() - startedAt,
104
121
  };