@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/routing.ts
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { ServerWebSocket } from "bun";
|
|
13
13
|
import type { ControlEventName, ControlEvents } from "../schema";
|
|
14
|
-
import {
|
|
14
|
+
import { capturePayload } from "../events/trace";
|
|
15
|
+
import { sendControl, doSend } from "./outbound";
|
|
16
|
+
import { replayAfter } from "./resume";
|
|
15
17
|
import { joinRoom, leaveRoom } from "./rooms";
|
|
18
|
+
import { topicHistoryFrom } from "./replay";
|
|
16
19
|
import { joinGroup, leaveGroup } from "./groups";
|
|
20
|
+
import { createRateLimiter } from "./rate-limit";
|
|
17
21
|
import type { ServerState, WsData } from "./state";
|
|
18
22
|
|
|
19
23
|
// allocated once per process, reused for every inbound text frame
|
|
@@ -24,12 +28,44 @@ export function handleMessage(
|
|
|
24
28
|
ws: ServerWebSocket<WsData>,
|
|
25
29
|
raw: string | Buffer,
|
|
26
30
|
): void {
|
|
27
|
-
|
|
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);
|
|
28
48
|
if (state.maxMessageSize !== undefined && bytes.byteLength > state.maxMessageSize) {
|
|
49
|
+
// multi-byte text frames can exceed the lower bound checked above
|
|
29
50
|
state.metrics.protocolErrors++;
|
|
30
51
|
ws.close(1009, "message too big");
|
|
31
52
|
return;
|
|
32
53
|
}
|
|
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
|
+
}
|
|
33
69
|
const header = state.bindings.readFrameHeader(bytes);
|
|
34
70
|
if (!header) {
|
|
35
71
|
state.metrics.protocolErrors++;
|
|
@@ -43,7 +79,23 @@ export function handleMessage(
|
|
|
43
79
|
const name = header.name;
|
|
44
80
|
if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
|
|
45
81
|
state.metrics.inbound++;
|
|
46
|
-
|
|
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);
|
|
47
99
|
// Horizontal scaling: when the bridge is configured with `bridgeClientEvents`,
|
|
48
100
|
// every accepted client event is re-published to `{prefix}.inbound.<event>` so
|
|
49
101
|
// OTHER server instances (and BE consumers) receive it. NATS-inbound frames
|
|
@@ -70,7 +122,28 @@ export function handleControl(
|
|
|
70
122
|
if (p.version !== state.bindings.wireVersion) {
|
|
71
123
|
// protocol version mismatch — refuse this client
|
|
72
124
|
ws.close(1002, "wire version mismatch");
|
|
125
|
+
break;
|
|
73
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 });
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
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 });
|
|
74
147
|
break;
|
|
75
148
|
}
|
|
76
149
|
case "subscribe": {
|
|
@@ -96,8 +169,58 @@ export function handleControl(
|
|
|
96
169
|
case "pong":
|
|
97
170
|
// client keepalive reply — tracked on the client side (heartbeat)
|
|
98
171
|
break;
|
|
99
|
-
case "snapshotRequest":
|
|
100
|
-
// 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);
|
|
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
|
+
})();
|
|
101
204
|
break;
|
|
205
|
+
}
|
|
102
206
|
}
|
|
103
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
|
+
}
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* Server composition root — wires `createServerState` + the action modules
|
|
3
3
|
* (auth / rooms / outbound / routing) into `Bun.serve`, and returns a plain
|
|
4
4
|
* `IgnServer` API object (no class, no `this`). This is the ONLY place that
|
|
5
|
-
* knows how the pieces fit together
|
|
5
|
+
* knows how the pieces fit together; each concern lives in its own module:
|
|
6
|
+
*
|
|
7
|
+
* client-info.ts — ClientInfo snapshot type + pure mapper
|
|
8
|
+
* http-routes.ts — fetch handler (health / clients / fallback)
|
|
9
|
+
* socket-lifecycle.ts — open/close handlers as `(state, ws)` actions
|
|
10
|
+
* metrics-view.ts — pure MetricsSnapshot assembly
|
|
6
11
|
*
|
|
7
12
|
* Generic over the wire stack: `createServer({ bindings })` with your own
|
|
8
13
|
* generated bindings types `publish` / `on` / ... against YOUR events. The
|
|
@@ -11,53 +16,33 @@
|
|
|
11
16
|
* Public entry: `public/server.ts` re-exports `createServer` + the types.
|
|
12
17
|
*/
|
|
13
18
|
import type { ServerWebSocket } from "bun";
|
|
14
|
-
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "
|
|
15
|
-
import { createNatsBridge } from "
|
|
16
|
-
import { createEventsHub, type EventsHubInternal } from "
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../../bindings/types";
|
|
20
|
+
import { createNatsBridge } from "../../bridge/nats";
|
|
21
|
+
import { createEventsHub, type EventsHubInternal } from "../../events/hub";
|
|
22
|
+
import type {
|
|
23
|
+
EventTraceRow,
|
|
24
|
+
EventTraceStats,
|
|
25
|
+
TraceQueryOptions,
|
|
26
|
+
} from "../../events/trace";
|
|
27
|
+
import { joinGroup as addToGroup } from "../groups";
|
|
28
|
+
import { setInt64GuardMode } from "../int64-guard";
|
|
29
|
+
import type { MetricsSnapshot } from "../metrics";
|
|
30
|
+
import { drainSocket, sendFrame } from "../outbound";
|
|
19
31
|
import {
|
|
20
32
|
activeGroups,
|
|
21
|
-
joinGroup as addToGroup,
|
|
22
33
|
groupMembers as groupMemberIds,
|
|
23
34
|
publishToGroup as publishToGroupState,
|
|
24
35
|
leaveGroup as removeFromGroup,
|
|
25
|
-
} from "
|
|
26
|
-
import {
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
/** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
|
|
34
|
-
export interface ClientInfo {
|
|
35
|
-
id: string;
|
|
36
|
-
/** identity this connection acts on behalf of (undefined if none) */
|
|
37
|
-
userId?: string;
|
|
38
|
-
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
39
|
-
meta?: Record<string, unknown>;
|
|
40
|
-
/** server-side groups this client belongs to */
|
|
41
|
-
groups: string[];
|
|
42
|
-
/** topics/rooms this client has joined */
|
|
43
|
-
topics: string[];
|
|
44
|
-
/** epoch ms the socket connected */
|
|
45
|
-
connectedAt: number;
|
|
46
|
-
/** remote IP (from the socket) */
|
|
47
|
-
ip: string;
|
|
48
|
-
}
|
|
36
|
+
} from "../groups";
|
|
37
|
+
import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "../rooms";
|
|
38
|
+
import { handleMessage } from "../routing";
|
|
39
|
+
import { createServerState, type IgnServerOptions, type WsData } from "../state";
|
|
40
|
+
import { toClientInfo, type ClientInfo } from "./client-info";
|
|
41
|
+
import { handleHttpRequest } from "./http-routes";
|
|
42
|
+
import { buildServerMetrics } from "./metrics-view";
|
|
43
|
+
import { onSocketClose, onSocketOpen } from "./socket-lifecycle";
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
id: ws.data.id,
|
|
53
|
-
...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
|
|
54
|
-
...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
|
|
55
|
-
groups: [...ws.data.groups],
|
|
56
|
-
topics: [...ws.data.topics],
|
|
57
|
-
connectedAt: ws.data.connectedAt,
|
|
58
|
-
ip: ws.remoteAddress,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
45
|
+
export type { ClientInfo } from "./client-info";
|
|
61
46
|
|
|
62
47
|
/** The public server API (returned by `createServer`). */
|
|
63
48
|
export interface IgnServer<B extends Bindings = DefaultBindings> {
|
|
@@ -111,12 +96,34 @@ export interface IgnServer<B extends Bindings = DefaultBindings> {
|
|
|
111
96
|
handler: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => void,
|
|
112
97
|
): IgnServer<B>;
|
|
113
98
|
off<K extends EventNameOf<B>>(name: K): IgnServer<B>;
|
|
99
|
+
/**
|
|
100
|
+
* Register a request/response responder for `name`: clients call it via
|
|
101
|
+
* `client.request(name, payload)` and receive the returned payload (encoded
|
|
102
|
+
* with the SAME event schema). Request/response share the event's schema.
|
|
103
|
+
*/
|
|
104
|
+
handle<K extends EventNameOf<B>>(
|
|
105
|
+
name: K,
|
|
106
|
+
responder: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => Promise<EventsOf<B>[K]> | EventsOf<B>[K],
|
|
107
|
+
): IgnServer<B>;
|
|
108
|
+
/**
|
|
109
|
+
* The event trace ring — what fired recently (emitted / published /
|
|
110
|
+
* received), with per-event aggregates. Debugger-facing (ignex debugbar,
|
|
111
|
+
* MCP); rows are materialized on read only.
|
|
112
|
+
*/
|
|
113
|
+
getEventTrace(options?: TraceQueryOptions): {
|
|
114
|
+
enabled: boolean;
|
|
115
|
+
capacity: number;
|
|
116
|
+
stats: EventTraceStats;
|
|
117
|
+
recent: EventTraceRow[];
|
|
118
|
+
};
|
|
119
|
+
/** Drop all retained trace records (counters survive). */
|
|
120
|
+
clearEventTrace(): void;
|
|
114
121
|
/**
|
|
115
122
|
* The events hub — present when `createServer({ events: {...} })` is used:
|
|
116
123
|
* typed handlers (`server.events.on`), client records, groups, and the
|
|
117
124
|
* cluster-aware emit surface.
|
|
118
125
|
*/
|
|
119
|
-
readonly events: EventsHub<B> | undefined;
|
|
126
|
+
readonly events: import("../../events/types").EventsHub<B> | undefined;
|
|
120
127
|
/** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
|
|
121
128
|
drain(timeoutMs?: number): Promise<void>;
|
|
122
129
|
stop(force?: boolean): void;
|
|
@@ -128,27 +135,42 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
128
135
|
const state = createServerState(options);
|
|
129
136
|
setInt64GuardMode(options.int64Guard ?? "off");
|
|
130
137
|
const bindings = state.bindings;
|
|
138
|
+
const trace = state.trace; // hot-path local (one property load, ever)
|
|
131
139
|
|
|
132
140
|
// NATS bridge (optional, best-effort — created eagerly, connects in the background)
|
|
133
141
|
const natsOpt = options.nats;
|
|
134
142
|
if (natsOpt)
|
|
135
|
-
|
|
143
|
+
// discriminate on `subjects` (bridge-only surface): both bridges AND raw
|
|
144
|
+
// transports have `publish`, so a publish-based probe misclassifies
|
|
145
|
+
state.bridge = "subjects" in natsOpt ? natsOpt : createNatsBridge(natsOpt, undefined, bindings);
|
|
146
|
+
|
|
147
|
+
// Encode once. EXTERNAL copies (NATS bridge / cluster envelope) are taken
|
|
148
|
+
// BEFORE any per-socket delivery-seq stamping mutates the scratch header —
|
|
149
|
+
// external consumers must see pristine frames.
|
|
136
150
|
|
|
137
|
-
|
|
138
|
-
// hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
|
|
139
|
-
// events reach clients but are never re-bridged to NATS.
|
|
151
|
+
/** Encode + count + trace a broadcast frame (no delivery yet). */
|
|
140
152
|
function fanOutAll(name: string, payload: unknown): Uint8Array {
|
|
141
153
|
const frame = state.transport.encodeToScratch(name, payload);
|
|
142
154
|
state.metrics.published++;
|
|
143
|
-
|
|
155
|
+
trace.record("out.publish", name, "broadcast", undefined, frame.byteLength);
|
|
144
156
|
return frame;
|
|
145
157
|
}
|
|
146
158
|
|
|
159
|
+
/** Deliver an already-encoded frame to every connected socket. */
|
|
160
|
+
function fanOutAllLocal(frame: Uint8Array): void {
|
|
161
|
+
for (const ws of state.sockets) sendFrame(state, ws, frame);
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
let eventsHub: EventsHubInternal<B> | undefined;
|
|
148
165
|
|
|
149
166
|
if (state.bridge) {
|
|
150
167
|
state.bridge.setOnInbound((name, payload) => {
|
|
151
|
-
|
|
168
|
+
// trace: the event ARRIVED from the bridge, then is fanned out locally
|
|
169
|
+
// by fanOutAll below (which records its own out.publish row).
|
|
170
|
+
const id = bindings.anyEventNameToId[name];
|
|
171
|
+
if (id !== undefined) trace.record("in.bridge", name, undefined, undefined, 0);
|
|
172
|
+
const frame = fanOutAll(name, payload);
|
|
173
|
+
fanOutAllLocal(frame);
|
|
152
174
|
// server-side handling of externally-published events (the events layer)
|
|
153
175
|
eventsHub?.dispatchBridgeInbound(name, payload);
|
|
154
176
|
});
|
|
@@ -159,55 +181,17 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
159
181
|
...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
|
|
160
182
|
idleTimeout: options.idleTimeout ?? 30,
|
|
161
183
|
...(options.tls !== undefined ? { tls: options.tls } : {}),
|
|
162
|
-
fetch: (req, srv) =>
|
|
163
|
-
const url = new URL(req.url);
|
|
164
|
-
if (url.pathname === state.path) return checkUpgrade(state, req, srv);
|
|
165
|
-
if (url.pathname === "/health") {
|
|
166
|
-
const h = state.metrics.snapshot(state.sockets.size);
|
|
167
|
-
return new Response(
|
|
168
|
-
JSON.stringify({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs }),
|
|
169
|
-
{ headers: { "content-type": "application/json" } },
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
if (url.pathname === "/clients") {
|
|
173
|
-
return new Response(JSON.stringify([...state.clients.values()].map(toClientInfo)), {
|
|
174
|
-
headers: { "content-type": "application/json" },
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
if (options.fetch) return options.fetch(req);
|
|
178
|
-
return new Response("not found", { status: 404 });
|
|
179
|
-
},
|
|
184
|
+
fetch: (req, srv) => handleHttpRequest(state, req, srv, options.fetch),
|
|
180
185
|
websocket: {
|
|
181
|
-
open: (ws) =>
|
|
182
|
-
|
|
183
|
-
// belt-and-suspenders: an auth race could double-register an id — kick the stale session
|
|
184
|
-
const existing = state.clients.get(ws.data.id);
|
|
185
|
-
if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
|
|
186
|
-
state.clients.set(ws.data.id, ws);
|
|
187
|
-
// events-layer attach (client record + presence) BEFORE group seeding
|
|
188
|
-
state.onConnect?.(ws);
|
|
189
|
-
for (const g of ws.data.groups) addToGroup(state, ws, g);
|
|
190
|
-
// announce our wire version + capabilities so clients can negotiate
|
|
191
|
-
sendControl(state, ws, "hello", { version: bindings.wireVersion, caps: [], lastSeq: 0 });
|
|
192
|
-
// then assign identity so the client knows its id + server-side groups
|
|
193
|
-
sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
|
|
194
|
-
},
|
|
195
|
-
close: (ws) => {
|
|
196
|
-
// events-layer detach FIRST (client record still carries groups/topics)
|
|
197
|
-
state.onDisconnect?.(ws);
|
|
198
|
-
state.sockets.delete(ws);
|
|
199
|
-
state.clients.delete(ws.data.id);
|
|
200
|
-
for (const g of ws.data.groups) removeFromGroup(state, ws, g);
|
|
201
|
-
ws.data.groups.clear();
|
|
202
|
-
for (const t of ws.data.topics) leaveRoom(state, ws, t);
|
|
203
|
-
ws.data.topics.clear();
|
|
204
|
-
delete ws.data.queue;
|
|
205
|
-
},
|
|
186
|
+
open: (ws) => onSocketOpen(state, ws),
|
|
187
|
+
close: (ws) => onSocketClose(state, ws),
|
|
206
188
|
message: (ws, msg) => handleMessage(state, ws, msg),
|
|
207
189
|
drain: (ws) => drainSocket(state, ws),
|
|
208
190
|
},
|
|
209
191
|
});
|
|
210
192
|
|
|
193
|
+
// ── publish actions (encode once → bridge copy → local fan-out) ──────
|
|
194
|
+
|
|
211
195
|
const api: IgnServer<B> = {
|
|
212
196
|
get port(): number {
|
|
213
197
|
return bun.port ?? 0;
|
|
@@ -216,48 +200,45 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
216
200
|
return state.sockets.size;
|
|
217
201
|
},
|
|
218
202
|
getMetrics(): MetricsSnapshot {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (b) {
|
|
232
|
-
snapshot.bridged = b.stats.bridged;
|
|
233
|
-
snapshot.bridgedBytes = b.stats.bridgedBytes;
|
|
234
|
-
snapshot.bridgeErrors = b.stats.bridgeErrors;
|
|
235
|
-
snapshot.bridgeInbound = b.stats.bridgeInbound;
|
|
236
|
-
snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
|
|
237
|
-
snapshot.natsStatus = b.status;
|
|
238
|
-
}
|
|
239
|
-
if (eventsHub) snapshot.events = eventsHub.metrics();
|
|
240
|
-
return snapshot;
|
|
203
|
+
return buildServerMetrics(state, eventsHub?.metrics());
|
|
204
|
+
},
|
|
205
|
+
getEventTrace(options?: TraceQueryOptions) {
|
|
206
|
+
return {
|
|
207
|
+
enabled: state.trace.enabled,
|
|
208
|
+
capacity: state.trace.capacity,
|
|
209
|
+
stats: state.trace.stats(),
|
|
210
|
+
recent: state.trace.recent(options),
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
clearEventTrace() {
|
|
214
|
+
state.trace.clear();
|
|
241
215
|
},
|
|
242
216
|
publish(name, payload) {
|
|
243
217
|
const frame = fanOutAll(name, payload);
|
|
218
|
+
// pristine frame out FIRST (bridge copies), then seq-stamped local sends
|
|
244
219
|
state.bridge?.publish(state.bridge.subjects.broadcast(name), frame);
|
|
220
|
+
fanOutAllLocal(frame);
|
|
245
221
|
},
|
|
246
222
|
publishTo(ws, name, payload) {
|
|
247
223
|
state.metrics.published++;
|
|
248
|
-
|
|
224
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
225
|
+
trace.record("out.publish", name, "client", ws.data.id, frame.byteLength);
|
|
226
|
+
sendFrame(state, ws, frame);
|
|
249
227
|
},
|
|
250
228
|
publishToTopic(topic, name, payload) {
|
|
251
229
|
const frame = state.transport.encodeToScratch(name, payload);
|
|
252
230
|
state.metrics.published++;
|
|
253
|
-
|
|
231
|
+
trace.record("out.publish", name, "topic", topic, frame.byteLength);
|
|
254
232
|
state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
|
|
233
|
+
publishToRoom(state, topic, frame);
|
|
255
234
|
},
|
|
256
235
|
publishToClient(id, name, payload) {
|
|
257
236
|
const ws = state.clients.get(id);
|
|
258
237
|
if (!ws) return false;
|
|
259
238
|
state.metrics.published++;
|
|
260
|
-
|
|
239
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
240
|
+
trace.record("out.publish", name, "client", id, frame.byteLength);
|
|
241
|
+
sendFrame(state, ws, frame);
|
|
261
242
|
return true;
|
|
262
243
|
},
|
|
263
244
|
allowInbound(name) {
|
|
@@ -284,8 +265,9 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
284
265
|
publishToGroup(group, name, payload) {
|
|
285
266
|
const frame = state.transport.encodeToScratch(name, payload);
|
|
286
267
|
state.metrics.published++;
|
|
287
|
-
|
|
268
|
+
trace.record("out.publish", name, "group", group, frame.byteLength);
|
|
288
269
|
state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
|
|
270
|
+
publishToGroupState(state, group, frame);
|
|
289
271
|
},
|
|
290
272
|
groups() {
|
|
291
273
|
return activeGroups(state);
|
|
@@ -324,7 +306,17 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
324
306
|
state.inboundHandlers.delete(name);
|
|
325
307
|
return api;
|
|
326
308
|
},
|
|
327
|
-
|
|
309
|
+
handle(name, responder) {
|
|
310
|
+
state.rpcHandlers.set(
|
|
311
|
+
name,
|
|
312
|
+
responder as (
|
|
313
|
+
payload: unknown,
|
|
314
|
+
ws: ServerWebSocket<WsData>,
|
|
315
|
+
) => Promise<unknown>,
|
|
316
|
+
);
|
|
317
|
+
return api;
|
|
318
|
+
},
|
|
319
|
+
get events() {
|
|
328
320
|
return eventsHub;
|
|
329
321
|
},
|
|
330
322
|
async drain(timeoutMs = 2000): Promise<void> {
|
|
@@ -344,7 +336,7 @@ export function createServer<B extends Bindings = DefaultBindings>(
|
|
|
344
336
|
},
|
|
345
337
|
};
|
|
346
338
|
|
|
347
|
-
// ── events layer (opt-in)
|
|
339
|
+
// ── events layer (opt-in) ────────────────────────────────────────────
|
|
348
340
|
if (options.events) {
|
|
349
341
|
eventsHub = createEventsHub({
|
|
350
342
|
state,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics view — assembles the server-level `MetricsSnapshot` by folding the
|
|
3
|
+
* transport's cumulative per-path encode counters and the bridge stats into
|
|
4
|
+
* the core metrics snapshot. Pure assembly: no counter is re-accumulated, so
|
|
5
|
+
* polling `getMetrics()` repeatedly never inflates what it reports.
|
|
6
|
+
*/
|
|
7
|
+
import type { NatsBridge } from "../../bridge/nats";
|
|
8
|
+
import type { MetricsSnapshot } from "../metrics";
|
|
9
|
+
import type { ServerState } from "../state";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Derive per-event path counts from the transport's OWN cumulative counters
|
|
13
|
+
* (totals since start) — a pure projection of `{direct,json,js}` per name.
|
|
14
|
+
*/
|
|
15
|
+
function buildPathCounts(state: ServerState): MetricsSnapshot["pathCounts"] {
|
|
16
|
+
const encodeStats = state.transport.getEncodeStats();
|
|
17
|
+
const pathCountsObj: Record<string, { direct: number; json: number; js: number }> = {};
|
|
18
|
+
const names = new Set<string>([
|
|
19
|
+
...Object.keys(encodeStats.direct),
|
|
20
|
+
...Object.keys(encodeStats.json),
|
|
21
|
+
...Object.keys(encodeStats.js),
|
|
22
|
+
]);
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const direct = encodeStats.direct[name] ?? 0;
|
|
25
|
+
const json = encodeStats.json[name] ?? 0;
|
|
26
|
+
const js = encodeStats.js[name] ?? 0;
|
|
27
|
+
if (direct > 0 || json > 0 || js > 0) pathCountsObj[name] = { direct, json, js };
|
|
28
|
+
}
|
|
29
|
+
return pathCountsObj;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fold bridge counters into the snapshot (only when a bridge is wired). */
|
|
33
|
+
function foldBridgeStats(snapshot: MetricsSnapshot, bridge: NatsBridge | undefined): void {
|
|
34
|
+
if (!bridge) return;
|
|
35
|
+
snapshot.bridged = bridge.stats.bridged;
|
|
36
|
+
snapshot.bridgedBytes = bridge.stats.bridgedBytes;
|
|
37
|
+
snapshot.bridgeErrors = bridge.stats.bridgeErrors;
|
|
38
|
+
snapshot.bridgeInbound = bridge.stats.bridgeInbound;
|
|
39
|
+
snapshot.bridgeInboundErrors = bridge.stats.bridgeInboundErrors;
|
|
40
|
+
snapshot.natsStatus = bridge.status;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the complete server metrics snapshot for `getMetrics()`. */
|
|
44
|
+
export function buildServerMetrics(
|
|
45
|
+
state: ServerState,
|
|
46
|
+
eventsMetrics: MetricsSnapshot["events"],
|
|
47
|
+
): MetricsSnapshot {
|
|
48
|
+
const snapshot = state.metrics.snapshot(state.sockets.size);
|
|
49
|
+
snapshot.pathCounts = buildPathCounts(state);
|
|
50
|
+
foldBridgeStats(snapshot, state.bridge);
|
|
51
|
+
if (eventsMetrics !== undefined) snapshot.events = eventsMetrics;
|
|
52
|
+
return snapshot;
|
|
53
|
+
}
|