@ignex/nova 0.1.3 → 0.1.6
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 +4 -1
- package/docs/ai/TREE.md +69 -9
- package/docs/architecture.md +75 -27
- package/docs/events.md +83 -1
- package/docs/generic-bindings.md +10 -0
- package/docs/wire-format.md +65 -18
- package/package.json +2 -1
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/generate.ts +97 -3
- package/public/server.ts +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +376 -17
- 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/codegen/constants.ts +14 -4
- package/src/codegen/direct-gen.ts +20 -6
- package/src/codegen/registry-gen.ts +10 -6
- package/src/codegen/rust-glue-gen.ts +10 -3
- package/src/codegen/schema-model.ts +28 -3
- package/src/codegen/ts-ser-gen.ts +12 -3
- package/src/core/auth.ts +65 -4
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +53 -0
- package/src/core/client-wire.ts +183 -8
- package/src/core/client.ts +84 -4
- package/src/core/groups.ts +5 -0
- package/src/core/metrics.ts +38 -21
- package/src/core/outbound.ts +50 -6
- 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 +128 -5
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/{server.ts → server/index.ts} +112 -120
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +73 -1
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +18 -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/delivery.ts +83 -0
- package/src/events/emit.ts +57 -11
- 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/queue.ts +36 -9
- package/src/events/registry.ts +90 -54
- 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 +146 -59
- package/src/generated/fbs/backend.fbs +23 -0
- package/src/generated/registry.ts +92 -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 +109 -16
- package/src/generated/wire-registry.json +7 -3
- package/src/schema/index.ts +45 -1
- package/src/transport/transport.ts +117 -77
- package/src/bridge/nats.ts +0 -309
- package/src/events/cluster.ts +0 -732
- package/src/events/hub.ts +0 -481
- package/src/events/types.ts +0 -378
- package/src/transport/stats.ts +0 -48
package/src/core/metrics.ts
CHANGED
|
@@ -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
|
|
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 */
|
|
@@ -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-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/core/outbound.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ServerWebSocket } from "bun";
|
|
|
7
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
12
|
|
|
12
13
|
/** Actual socket write + counters (single accounting point). */
|
|
@@ -17,17 +18,52 @@ export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: U
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* Send one frame to a socket, honoring the configured backpressure policy
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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).
|
|
25
33
|
*/
|
|
26
34
|
export function sendFrame(
|
|
27
35
|
state: ServerState,
|
|
28
36
|
ws: ServerWebSocket<WsData>,
|
|
29
37
|
frame: Uint8Array,
|
|
38
|
+
opts?: { readonly seq?: number },
|
|
30
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
|
+
}
|
|
31
67
|
const bp = state.bp;
|
|
32
68
|
if (!bp) {
|
|
33
69
|
doSend(state, ws, frame);
|
|
@@ -52,7 +88,7 @@ export function sendFrame(
|
|
|
52
88
|
q = new RingBuffer<Uint8Array>();
|
|
53
89
|
ws.data.queue = q;
|
|
54
90
|
}
|
|
55
|
-
q.push(frame.slice()); // owned copy for the queue
|
|
91
|
+
q.push(frame.slice()); // owned copy for the queue (already seq-stamped)
|
|
56
92
|
for (let i = 0; i < d.dropHead; i++) {
|
|
57
93
|
q.shift();
|
|
58
94
|
state.metrics.droppedOldest++;
|
|
@@ -62,6 +98,14 @@ export function sendFrame(
|
|
|
62
98
|
}
|
|
63
99
|
}
|
|
64
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
|
+
|
|
65
109
|
/** Flush a slow socket's drop-oldest queue as the OS buffers drain. */
|
|
66
110
|
export function drainSocket(state: ServerState, ws: ServerWebSocket<WsData>): void {
|
|
67
111
|
const q = ws.data.queue;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery-sequence + resume — gap-free delivery over the WS transport.
|
|
3
|
+
*
|
|
4
|
+
* Every frame the SERVER writes to a socket carries a per-connection,
|
|
5
|
+
* monotonically increasing delivery seq (envelope v2 `[flags:1][seq:u64 LE]`,
|
|
6
|
+
* stamped in place just before `ws.send` — Bun copies synchronously). Clients
|
|
7
|
+
* track the seq stream and detect loss (backpressure drops, reconnect
|
|
8
|
+
* boundaries) as gaps.
|
|
9
|
+
*
|
|
10
|
+
* Recovery has two halves:
|
|
11
|
+
* - SAME-connection gaps → client sends the `resume` control frame with its
|
|
12
|
+
* last contiguous seq; the server replays from the connection's bounded
|
|
13
|
+
* sent-history ring (`ws.data.history`), frames keeping their ORIGINAL
|
|
14
|
+
* seqs so the client's stream stays gap-free and duplicate-free.
|
|
15
|
+
* - RECONNECT → on close the ring + counter move to a per-client-id
|
|
16
|
+
* "graveyard" (bounded, TTL'd). A reconnecting socket whose auth-pinned id
|
|
17
|
+
* has a grave adopts it, so `hello { lastSeq }` resumes across sessions.
|
|
18
|
+
*
|
|
19
|
+
* The stamping helpers derive offsets from `bindings.wireHeaderLen`, so v1
|
|
20
|
+
* bindings (header 5, no seq field) simply never stamp and never resume.
|
|
21
|
+
*/
|
|
22
|
+
import type { ServerWebSocket } from "bun";
|
|
23
|
+
import type { Bindings } from "../bindings/types";
|
|
24
|
+
import { RingBuffer } from "./ring";
|
|
25
|
+
import type { ServerState, WsData } from "./state";
|
|
26
|
+
|
|
27
|
+
/** One entry of a connection's sent-frame history (frame is an owned copy). */
|
|
28
|
+
export interface SentFrame {
|
|
29
|
+
seq: number;
|
|
30
|
+
frame: Uint8Array;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FLAGS_OFFSET_BACK = 9; // [..][flags:1][seq:u64 LE] tail of the header
|
|
34
|
+
const SEQ_OFFSET_BACK = 8;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stamp `seq` into `frame`'s delivery header IN PLACE (flags bit0 = 1).
|
|
38
|
+
* Callers must own or exclusively borrow the buffer at this instant:
|
|
39
|
+
* `ws.send` copies synchronously, and queued/replayed frames are stamped
|
|
40
|
+
* before their owned copy is taken. Returns false when the bindings have no
|
|
41
|
+
* delivery header (v1) — callers then skip resume bookkeeping.
|
|
42
|
+
*/
|
|
43
|
+
export function stampSeq(bindings: Bindings, frame: Uint8Array, seq: number): boolean {
|
|
44
|
+
const len = bindings.wireHeaderLen;
|
|
45
|
+
if (len < FLAGS_OFFSET_BACK + 1 || frame.byteLength < len) return false;
|
|
46
|
+
frame[len - FLAGS_OFFSET_BACK] = (frame[len - FLAGS_OFFSET_BACK] ?? 0) | 1;
|
|
47
|
+
// u64 LE via two u32 halves — no BigInt/DataView allocation per frame
|
|
48
|
+
// (this runs once per app frame PER SOCKET on the fan-out loop)
|
|
49
|
+
const off = len - SEQ_OFFSET_BACK;
|
|
50
|
+
const lo = seq % 0x100000000;
|
|
51
|
+
const hi = Math.floor(seq / 0x100000000);
|
|
52
|
+
frame[off] = lo & 0xff;
|
|
53
|
+
frame[off + 1] = (lo >>> 8) & 0xff;
|
|
54
|
+
frame[off + 2] = (lo >>> 16) & 0xff;
|
|
55
|
+
frame[off + 3] = (lo >>> 24) & 0xff;
|
|
56
|
+
frame[off + 4] = hi & 0xff;
|
|
57
|
+
frame[off + 5] = (hi >>> 8) & 0xff;
|
|
58
|
+
frame[off + 6] = (hi >>> 16) & 0xff;
|
|
59
|
+
frame[off + 7] = (hi >>> 24) & 0xff;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read a frame's delivery seq; null when unstamped / not present. */
|
|
64
|
+
export function readSeq(bindings: Bindings, frame: Uint8Array): number | null {
|
|
65
|
+
const len = bindings.wireHeaderLen;
|
|
66
|
+
if (len < FLAGS_OFFSET_BACK + 1 || frame.byteLength < len) return null;
|
|
67
|
+
if ((frame[len - FLAGS_OFFSET_BACK]! & 1) === 0) return null;
|
|
68
|
+
const off = len - SEQ_OFFSET_BACK;
|
|
69
|
+
const lo =
|
|
70
|
+
frame[off]! | (frame[off + 1]! << 8) | (frame[off + 2]! << 16) | (frame[off + 3]! << 24);
|
|
71
|
+
const hi =
|
|
72
|
+
frame[off + 4]! | (frame[off + 5]! << 8) | (frame[off + 6]! << 16) | (frame[off + 7]! << 24);
|
|
73
|
+
return (lo >>> 0) + hi * 0x100000000;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lazily create the per-connection sent-history ring (resume enabled only). */
|
|
77
|
+
export function ensureHistory(state: ServerState, ws: ServerWebSocket<WsData>): RingBuffer<SentFrame> | undefined {
|
|
78
|
+
if (!state.resume) return undefined;
|
|
79
|
+
let h = ws.data.history;
|
|
80
|
+
if (h === undefined) {
|
|
81
|
+
h = new RingBuffer<SentFrame>(state.resume.historySize, true);
|
|
82
|
+
ws.data.history = h;
|
|
83
|
+
}
|
|
84
|
+
return h;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Record a stamped frame in the connection's history (owned copy — the caller
|
|
89
|
+
* may be handing a reused scratch view to `ws.send`). No-op when resume is off
|
|
90
|
+
* or the frame could not be stamped.
|
|
91
|
+
*/
|
|
92
|
+
export function recordSent(
|
|
93
|
+
_state: ServerState,
|
|
94
|
+
ws: ServerWebSocket<WsData>,
|
|
95
|
+
frame: Uint8Array,
|
|
96
|
+
seq: number,
|
|
97
|
+
): void {
|
|
98
|
+
const h = ws.data.history;
|
|
99
|
+
if (!h) return;
|
|
100
|
+
h.push({ seq, frame: frame.slice() });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── graveyard (cross-connection resume) ─────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const GRAVE_MAX = 1000;
|
|
106
|
+
|
|
107
|
+
function pruneGraves(state: ServerState): void {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const [id, g] of state.graves) {
|
|
110
|
+
if (g.expiresAt <= now) state.graves.delete(id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* On disconnect (resume enabled): park the connection's history under its
|
|
116
|
+
* client id so a future session can adopt it. Bounded: beyond `GRAVE_MAX`
|
|
117
|
+
* entries (or past TTL) the OLDEST grave is dropped first.
|
|
118
|
+
*/
|
|
119
|
+
export function burySession(state: ServerState, ws: ServerWebSocket<WsData>): void {
|
|
120
|
+
const ttlMs = state.resume?.ttlMs ?? 0;
|
|
121
|
+
const history = ws.data.history;
|
|
122
|
+
if (!state.resume || !history || history.length === 0) return;
|
|
123
|
+
pruneGraves(state);
|
|
124
|
+
if (state.graves.size >= GRAVE_MAX) {
|
|
125
|
+
// drop the soonest-expiring grave (Map preserves insertion order)
|
|
126
|
+
const oldest = state.graves.keys().next();
|
|
127
|
+
if (!oldest.done) state.graves.delete(oldest.value);
|
|
128
|
+
}
|
|
129
|
+
state.graves.set(ws.data.id, {
|
|
130
|
+
history,
|
|
131
|
+
nextSeq: ws.data.sendSeq,
|
|
132
|
+
expiresAt: Date.now() + ttlMs,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* On open (resume enabled): adopt a parked history for this client id, if any.
|
|
138
|
+
* Returns true when the connection now continues a previous seq stream.
|
|
139
|
+
*/
|
|
140
|
+
export function adoptGrave(state: ServerState, ws: ServerWebSocket<WsData>): boolean {
|
|
141
|
+
if (!state.resume) return false;
|
|
142
|
+
const g = state.graves.get(ws.data.id);
|
|
143
|
+
if (!g) return false;
|
|
144
|
+
if (g.expiresAt <= Date.now()) {
|
|
145
|
+
state.graves.delete(ws.data.id);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
state.graves.delete(ws.data.id);
|
|
149
|
+
ws.data.history = g.history;
|
|
150
|
+
ws.data.sendSeq = g.nextSeq;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Replay every retained frame strictly after `lastSeq` to `ws` (original seqs
|
|
156
|
+
* preserved). Returns `{ ok, replayed, from }`; `ok=false` means the requested
|
|
157
|
+
* hole is older than the ring (partial recovery — the client should
|
|
158
|
+
* resubscribe topics for a fresh snapshot).
|
|
159
|
+
*/
|
|
160
|
+
export function replayAfter(
|
|
161
|
+
_state: ServerState,
|
|
162
|
+
ws: ServerWebSocket<WsData>,
|
|
163
|
+
lastSeq: number,
|
|
164
|
+
): { ok: boolean; replayed: number; from: number } {
|
|
165
|
+
const h = ws.data.history;
|
|
166
|
+
if (!h || h.length === 0) return { ok: false, replayed: 0, from: 0 };
|
|
167
|
+
let ok = true;
|
|
168
|
+
let replayed = 0;
|
|
169
|
+
let from = 0;
|
|
170
|
+
for (const e of h) {
|
|
171
|
+
if (e.seq <= lastSeq) continue;
|
|
172
|
+
if (from === 0) from = e.seq;
|
|
173
|
+
// a hole before/at the oldest retained frame cannot be filled
|
|
174
|
+
if (e.seq !== lastSeq + replayed + 1) ok = false;
|
|
175
|
+
// direct write — bypasses stamping (frames keep their original seq) and
|
|
176
|
+
// backpressure (dropping a resume into a saturated queue defeats it)
|
|
177
|
+
ws.send(e.frame);
|
|
178
|
+
replayed++;
|
|
179
|
+
}
|
|
180
|
+
return { ok, replayed, from };
|
|
181
|
+
}
|
package/src/core/rooms.ts
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
* (`publishToRoom` fans a frame out to every member).
|
|
5
5
|
*/
|
|
6
6
|
import type { ServerWebSocket } from "bun";
|
|
7
|
-
import {
|
|
7
|
+
import { sendFrame } from "./outbound";
|
|
8
8
|
import { recordReplay, replayFrames } from "./replay";
|
|
9
9
|
import type { ServerState, WsData } from "./state";
|
|
10
10
|
|
|
11
11
|
/** Join `ws` to `topic`, replaying any recorded history (oldest → newest). */
|
|
12
12
|
export function joinRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic: string): void {
|
|
13
|
+
// every join path (control frames, programmatic, auth-seeded) is gated
|
|
14
|
+
if (state.authorizeTopic !== undefined && !state.authorizeTopic(topic, ws)) {
|
|
15
|
+
state.metrics.rejectedJoins++;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
13
18
|
ws.data.topics.add(topic);
|
|
14
19
|
let set = state.rooms.get(topic);
|
|
15
20
|
if (!set) {
|
|
@@ -17,8 +22,10 @@ export function joinRoom(state: ServerState, ws: ServerWebSocket<WsData>, topic:
|
|
|
17
22
|
state.rooms.set(topic, set);
|
|
18
23
|
}
|
|
19
24
|
set.add(ws);
|
|
20
|
-
// replay
|
|
21
|
-
|
|
25
|
+
// snapshot replay goes through the normal outbound path so (with resume
|
|
26
|
+
// enabled) the frames are stamped into this connection's seq stream and
|
|
27
|
+
// recorded in its history — clients can gap-recover across them too.
|
|
28
|
+
for (const frame of replayFrames(state, topic)) sendFrame(state, ws, frame);
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/** Leave `topic`; prune the room when it becomes empty. */
|