@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
|
@@ -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. */
|
package/src/core/routing.ts
CHANGED
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* going to discard.
|
|
11
11
|
*/
|
|
12
12
|
import type { ServerWebSocket } from "bun";
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import { sendControl } from "./outbound";
|
|
13
|
+
import type { ControlEventName, ControlEvents } from "../schema";
|
|
14
|
+
import { capturePayload } from "../events/trace";
|
|
15
|
+
import { sendControl, doSend } from "./outbound";
|
|
16
|
+
import { replayAfter } from "./resume";
|
|
16
17
|
import { joinRoom, leaveRoom } from "./rooms";
|
|
18
|
+
import { topicHistoryFrom } from "./replay";
|
|
17
19
|
import { joinGroup, leaveGroup } from "./groups";
|
|
20
|
+
import { createRateLimiter } from "./rate-limit";
|
|
18
21
|
import type { ServerState, WsData } from "./state";
|
|
19
22
|
|
|
20
23
|
// allocated once per process, reused for every inbound text frame
|
|
@@ -25,26 +28,84 @@ export function handleMessage(
|
|
|
25
28
|
ws: ServerWebSocket<WsData>,
|
|
26
29
|
raw: string | Buffer,
|
|
27
30
|
): void {
|
|
28
|
-
|
|
31
|
+
// cheap oversize pre-check BEFORE any conversion work (string length is a
|
|
32
|
+
// lower bound of its UTF-8 size, so `>` here can never false-positive)
|
|
33
|
+
if (state.maxMessageSize !== undefined) {
|
|
34
|
+
const approx = typeof raw === "string" ? raw.length : raw.byteLength;
|
|
35
|
+
if (approx > state.maxMessageSize) {
|
|
36
|
+
state.metrics.protocolErrors++;
|
|
37
|
+
ws.close(1009, "message too big");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// binary frames are viewed ZERO-COPY (decode is synchronous and every
|
|
42
|
+
// downstream holder — replay history, backpressure queue, NATS bridge —
|
|
43
|
+
// takes its own owned copy); only text frames pay one transcode.
|
|
44
|
+
const bytes =
|
|
45
|
+
typeof raw === "string"
|
|
46
|
+
? textEncoder.encode(raw)
|
|
47
|
+
: new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
29
48
|
if (state.maxMessageSize !== undefined && bytes.byteLength > state.maxMessageSize) {
|
|
49
|
+
// multi-byte text frames can exceed the lower bound checked above
|
|
30
50
|
state.metrics.protocolErrors++;
|
|
31
51
|
ws.close(1009, "message too big");
|
|
32
52
|
return;
|
|
33
53
|
}
|
|
34
|
-
|
|
54
|
+
// per-connection token bucket — evaluated before ANY decode work so a
|
|
55
|
+
// flooding client is shed at ~constant cost (app AND control frames).
|
|
56
|
+
const rl = state.rateLimit;
|
|
57
|
+
if (rl !== null) {
|
|
58
|
+
let limiter = ws.data.rate;
|
|
59
|
+
if (limiter === undefined) {
|
|
60
|
+
limiter = createRateLimiter(rl);
|
|
61
|
+
ws.data.rate = limiter;
|
|
62
|
+
}
|
|
63
|
+
if (!limiter.allow(Date.now())) {
|
|
64
|
+
state.metrics.rateLimited++;
|
|
65
|
+
if (limiter.policy === "close") ws.close(1008, "rate limit exceeded");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const header = state.bindings.readFrameHeader(bytes);
|
|
35
70
|
if (!header) {
|
|
36
71
|
state.metrics.protocolErrors++;
|
|
37
72
|
return; // undecodable / wrong version / unknown id — drop
|
|
38
73
|
}
|
|
39
|
-
if (isControlId(header.id)) {
|
|
74
|
+
if (state.bindings.isControlId(header.id)) {
|
|
40
75
|
state.metrics.inboundControl++;
|
|
41
|
-
handleControl(state, ws, header.name as ControlEventName, decodePayload(header.id, bytes) as never);
|
|
76
|
+
handleControl(state, ws, header.name as ControlEventName, state.bindings.decodePayload(header.id, bytes) as never);
|
|
42
77
|
return;
|
|
43
78
|
}
|
|
44
|
-
const name = header.name
|
|
79
|
+
const name = header.name;
|
|
45
80
|
if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
|
|
46
81
|
state.metrics.inbound++;
|
|
47
|
-
|
|
82
|
+
// trace: one row per accepted client event (the debugger's "what came in").
|
|
83
|
+
// The payload itself is materialized only when somebody consumes it (a
|
|
84
|
+
// handler exists or capture is on) — never allocated otherwise.
|
|
85
|
+
const handler = state.inboundHandlers.get(name);
|
|
86
|
+
const payload =
|
|
87
|
+
handler !== undefined || state.trace.captures
|
|
88
|
+
? state.bindings.decodePayload(header.id, bytes)
|
|
89
|
+
: undefined;
|
|
90
|
+
state.trace.record(
|
|
91
|
+
"in.client",
|
|
92
|
+
name,
|
|
93
|
+
undefined,
|
|
94
|
+
ws.data.id,
|
|
95
|
+
bytes.byteLength,
|
|
96
|
+
state.trace.captures ? capturePayload(payload, 2000) : undefined,
|
|
97
|
+
);
|
|
98
|
+
handler?.(payload, ws);
|
|
99
|
+
// Horizontal scaling: when the bridge is configured with `bridgeClientEvents`,
|
|
100
|
+
// every accepted client event is re-published to `{prefix}.inbound.<event>` so
|
|
101
|
+
// OTHER server instances (and BE consumers) receive it. NATS-inbound frames
|
|
102
|
+
// arrive via `onInbound` → `fanOutAll` (never through this path), so there is
|
|
103
|
+
// no loop; this server's own clients are re-delivered exactly once through its
|
|
104
|
+
// own inbound subscription (the app handler should therefore not ALSO
|
|
105
|
+
// broadcast, or the event would be delivered twice locally).
|
|
106
|
+
if (state.bridge?.clientEvents) {
|
|
107
|
+
state.bridge.publish(state.bridge.subjects.inboundEvent(name), bytes);
|
|
108
|
+
}
|
|
48
109
|
}
|
|
49
110
|
|
|
50
111
|
export function handleControl(
|
|
@@ -58,12 +119,33 @@ export function handleControl(
|
|
|
58
119
|
const p = payload as ControlEvents["hello"];
|
|
59
120
|
ws.data.version = p.version;
|
|
60
121
|
ws.data.lastSeq = p.lastSeq;
|
|
61
|
-
if (p.version !==
|
|
122
|
+
if (p.version !== state.bindings.wireVersion) {
|
|
62
123
|
// protocol version mismatch — refuse this client
|
|
63
124
|
ws.close(1002, "wire version mismatch");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// cross-session resume: the client reconnects with `lastSeq > 0` and a
|
|
128
|
+
// parked history was adopted for its id (server.open wires adoption).
|
|
129
|
+
if (p.lastSeq > 0 && ws.data.history !== undefined && ws.data.history.length > 0) {
|
|
130
|
+
const r = replayAfter(state, ws, p.lastSeq);
|
|
131
|
+
state.metrics.resumesServed++;
|
|
132
|
+
state.metrics.framesReplayed += r.replayed;
|
|
133
|
+
if (!r.ok) state.metrics.resumeMisses++;
|
|
134
|
+
sendControl(state, ws, "resumed", { ok: r.ok, from: r.from });
|
|
64
135
|
}
|
|
65
136
|
break;
|
|
66
137
|
}
|
|
138
|
+
case "resume": {
|
|
139
|
+
// same-connection gap recovery: replay everything after the client's
|
|
140
|
+
// last contiguous seq (original seqs preserved — no re-stamping)
|
|
141
|
+
const lastSeq = (payload as ControlEvents["resume"]).lastSeq;
|
|
142
|
+
const r = replayAfter(state, ws, lastSeq);
|
|
143
|
+
state.metrics.resumesServed++;
|
|
144
|
+
state.metrics.framesReplayed += r.replayed;
|
|
145
|
+
if (!r.ok) state.metrics.resumeMisses++;
|
|
146
|
+
sendControl(state, ws, "resumed", { ok: r.ok, from: r.from });
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
67
149
|
case "subscribe": {
|
|
68
150
|
joinRoom(state, ws, (payload as ControlEvents["subscribe"]).topic);
|
|
69
151
|
break;
|
|
@@ -87,8 +169,58 @@ export function handleControl(
|
|
|
87
169
|
case "pong":
|
|
88
170
|
// client keepalive reply — tracked on the client side (heartbeat)
|
|
89
171
|
break;
|
|
90
|
-
case "snapshotRequest":
|
|
91
|
-
// per-topic replay from the ring buffer (
|
|
172
|
+
case "snapshotRequest": {
|
|
173
|
+
// per-topic replay from the ring buffer (+ topic log when configured),
|
|
174
|
+
// strictly after `fromSeq` (0 = from the beginning of retained history)
|
|
175
|
+
const p = payload as ControlEvents["snapshotRequest"];
|
|
176
|
+
for (const frame of topicHistoryFrom(state, p.topic, p.fromSeq)) doSend(state, ws, frame);
|
|
92
177
|
break;
|
|
178
|
+
}
|
|
179
|
+
case "rpcCall": {
|
|
180
|
+
const p = payload as ControlEvents["rpcCall"];
|
|
181
|
+
const responder = state.rpcHandlers.get(p.name);
|
|
182
|
+
const reply = (ok: boolean, payloadB64: string, err = ""): void => {
|
|
183
|
+
sendControl(state, ws, "rpcResult", { id: p.id, ok, err, payloadB64 });
|
|
184
|
+
};
|
|
185
|
+
if (!responder) {
|
|
186
|
+
reply(false, "", `no handler for "${p.name}"`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const inner = decodeB64Frame(state, p.payloadB64);
|
|
190
|
+
if (!inner) {
|
|
191
|
+
reply(false, "", "undecodable request payload");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
void (async () => {
|
|
195
|
+
try {
|
|
196
|
+
const out = await responder(inner.payload, ws);
|
|
197
|
+
// encode with the SAME event name (request/response share a schema)
|
|
198
|
+
const frame = state.transport.encodeToScratch(p.name, out);
|
|
199
|
+
reply(true, b64(frame));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
reply(false, "", err instanceof Error ? err.message : String(err));
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
93
206
|
}
|
|
94
207
|
}
|
|
208
|
+
|
|
209
|
+
/** Decode a base64 wire frame (rpcCall payload) into { name, payload }. */
|
|
210
|
+
function decodeB64Frame(
|
|
211
|
+
state: ServerState,
|
|
212
|
+
b64: string,
|
|
213
|
+
): { name: string; payload: unknown } | null {
|
|
214
|
+
try {
|
|
215
|
+
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
216
|
+
return state.bindings.decodeFrame(bytes) as { name: string; payload: unknown } | null;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const b64 = (bytes: Uint8Array): string => {
|
|
223
|
+
let s = "";
|
|
224
|
+
for (let i = 0; i < bytes.byteLength; i++) s += String.fromCharCode(bytes[i]!);
|
|
225
|
+
return btoa(s);
|
|
226
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client introspection view — the snapshot shape returned by
|
|
3
|
+
* `server.getClient` / `server.getClients` and the GET /clients endpoint.
|
|
4
|
+
*
|
|
5
|
+
* `toClientInfo` is a PURE mapper from live socket state to the plain object.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerWebSocket } from "bun";
|
|
8
|
+
import type { WsData } from "../state";
|
|
9
|
+
|
|
10
|
+
/** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
|
|
11
|
+
export interface ClientInfo {
|
|
12
|
+
id: string;
|
|
13
|
+
/** identity this connection acts on behalf of (undefined if none) */
|
|
14
|
+
userId?: string;
|
|
15
|
+
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
16
|
+
meta?: Record<string, unknown>;
|
|
17
|
+
/** server-side groups this client belongs to */
|
|
18
|
+
groups: string[];
|
|
19
|
+
/** topics/rooms this client has joined */
|
|
20
|
+
topics: string[];
|
|
21
|
+
/** epoch ms the socket connected */
|
|
22
|
+
connectedAt: number;
|
|
23
|
+
/** remote IP (from the socket) */
|
|
24
|
+
ip: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
|
|
28
|
+
return {
|
|
29
|
+
id: ws.data.id,
|
|
30
|
+
...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
|
|
31
|
+
...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
|
|
32
|
+
groups: [...ws.data.groups],
|
|
33
|
+
topics: [...ws.data.topics],
|
|
34
|
+
connectedAt: ws.data.connectedAt,
|
|
35
|
+
ip: ws.remoteAddress,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes — the non-WebSocket side of `Bun.serve`'s fetch: the upgrade
|
|
3
|
+
* path, a JSON `/health` probe, an auth-gated `/clients` introspection
|
|
4
|
+
* endpoint, and the user-supplied fallback.
|
|
5
|
+
*
|
|
6
|
+
* Each route is a small pure-ish function `(state, req) => Response | null`
|
|
7
|
+
* (`null` = "not my route"); `handleHttpRequest` composes them in order.
|
|
8
|
+
*/
|
|
9
|
+
import type { ServerState, WsData } from "../state";
|
|
10
|
+
import { authorizeHttp, checkUpgrade } from "../auth";
|
|
11
|
+
import { toClientInfo } from "./client-info";
|
|
12
|
+
|
|
13
|
+
/** The Bun server handle (needed by `checkUpgrade` for `srv.upgrade`). */
|
|
14
|
+
type BunServer = ReturnType<typeof Bun.serve<WsData>>;
|
|
15
|
+
|
|
16
|
+
/** JSON response helper (the only shape these routes return). */
|
|
17
|
+
const json = (body: unknown, status = 200): Response =>
|
|
18
|
+
new Response(JSON.stringify(body), {
|
|
19
|
+
...(status === 200 ? {} : { status }),
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** GET /health — liveness + basic counters (never gated; safe for probes). */
|
|
24
|
+
function healthRoute(state: ServerState): Response {
|
|
25
|
+
const h = state.metrics.snapshot(state.sockets.size);
|
|
26
|
+
return json({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** GET /clients — active-client introspection. */
|
|
30
|
+
async function clientsRoute(state: ServerState, req: Request): Promise<Response> {
|
|
31
|
+
// gated whenever the server has an auth surface (token or authenticate);
|
|
32
|
+
// public only for unsecured servers
|
|
33
|
+
const denied = await authorizeHttp(state, req);
|
|
34
|
+
if (denied) return denied;
|
|
35
|
+
return json([...state.clients.values()].map(toClientInfo));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Route an HTTP request. Order matters:
|
|
40
|
+
* 1. the WS upgrade path (`state.path`),
|
|
41
|
+
* 2. built-in introspection endpoints,
|
|
42
|
+
* 3. the user's custom `fetch` (when provided),
|
|
43
|
+
* 4. 404.
|
|
44
|
+
*/
|
|
45
|
+
export async function handleHttpRequest(
|
|
46
|
+
state: ServerState,
|
|
47
|
+
req: Request,
|
|
48
|
+
srv: BunServer,
|
|
49
|
+
customFetch?: (req: Request) => Response | Promise<Response>,
|
|
50
|
+
): Promise<Response> {
|
|
51
|
+
const url = new URL(req.url);
|
|
52
|
+
if (url.pathname === state.path) {
|
|
53
|
+
return (await checkUpgrade(state, req, srv)) ?? new Response("upgrade failed", { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
if (url.pathname === "/health") return healthRoute(state);
|
|
56
|
+
if (url.pathname === "/clients") return clientsRoute(state, req);
|
|
57
|
+
if (customFetch) return customFetch(req);
|
|
58
|
+
return new Response("not found", { status: 404 });
|
|
59
|
+
}
|