@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.
- package/README.md +136 -33
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +292 -0
- package/docs/architecture.md +101 -28
- package/docs/events.md +252 -0
- package/docs/generic-bindings.md +207 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +74 -20
- package/index.ts +75 -27
- package/package.json +13 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +510 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +52 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +377 -17
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +28 -0
- package/src/codegen/direct-gen.ts +564 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +246 -0
- package/src/codegen/rust-glue-gen.ts +552 -0
- package/src/codegen/schema-model.ts +363 -0
- package/src/codegen/ts-ser-gen.ts +230 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +67 -5
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +63 -8
- package/src/core/client-wire.ts +148 -15
- package/src/core/client.ts +105 -31
- package/src/core/groups.ts +8 -0
- package/src/core/metrics.ts +42 -21
- package/src/core/outbound.ts +62 -11
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +144 -12
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/server/index.ts +360 -0
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +124 -14
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +174 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/data.ts +38 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +173 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +123 -0
- package/src/events/registry.ts +214 -0
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +148 -60
- package/src/generated/fbs/backend.fbs +24 -1
- package/src/generated/registry.ts +94 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +110 -17
- package/src/generated/wire-registry.json +7 -2
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +49 -2
- package/src/server.ts +7 -3
- package/src/transport/transport.ts +200 -79
- package/src/bridge/nats.ts +0 -269
- package/src/core/server.ts +0 -294
- package/src/transport/stats.ts +0 -44
package/src/core/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
20
|
-
off<K extends
|
|
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
|
|
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:
|
|
25
|
-
offAny(cb: (name:
|
|
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():
|
|
35
|
+
events(): EventNameOf<B>[];
|
|
28
36
|
/** Remove all handlers (optionally just for one event). */
|
|
29
|
-
removeAllListeners(name?:
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
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
|
},
|
package/src/core/groups.ts
CHANGED
|
@@ -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). */
|
package/src/core/metrics.ts
CHANGED
|
@@ -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
|
|
4
|
-
* the steady state (
|
|
5
|
-
*
|
|
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-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/core/outbound.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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(
|
|
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
|
-
|
|
48
|
-
q
|
|
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
|
+
}
|
package/src/core/replay.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|