@ignex/nova 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +42 -0
- package/src/core/client-wire.ts +142 -8
- package/src/core/client.ts +72 -3
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket lifecycle — the server-side `open` / `close` WebSocket handlers as
|
|
3
|
+
* small `(state, ws)` actions (functional-composition style, like the other
|
|
4
|
+
* core action modules). The composition root (`index.ts`) wires them into
|
|
5
|
+
* `Bun.serve`'s websocket handlers.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerWebSocket } from "bun";
|
|
8
|
+
import type { Bindings } from "../../bindings/types";
|
|
9
|
+
import { joinGroup as addToGroup, leaveGroup as removeFromGroup } from "../groups";
|
|
10
|
+
import { sendControl } from "../outbound";
|
|
11
|
+
import { adoptGrave, burySession } from "../resume";
|
|
12
|
+
import { leaveRoom } from "../rooms";
|
|
13
|
+
import type { ServerState, WsData } from "../state";
|
|
14
|
+
|
|
15
|
+
/** A socket opened: register, adopt resume history, seed groups, greet. */
|
|
16
|
+
export function onSocketOpen(state: ServerState, ws: ServerWebSocket<WsData>): void {
|
|
17
|
+
state.sockets.add(ws);
|
|
18
|
+
// belt-and-suspenders: an auth race could double-register an id — kick the stale session
|
|
19
|
+
const existing = state.clients.get(ws.data.id);
|
|
20
|
+
if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
|
|
21
|
+
state.clients.set(ws.data.id, ws);
|
|
22
|
+
// resume: adopt a parked history for this client id (continues the
|
|
23
|
+
// previous session's delivery-seq stream) BEFORE anything is sent
|
|
24
|
+
adoptGrave(state, ws);
|
|
25
|
+
// events-layer attach (client record + presence) BEFORE group seeding
|
|
26
|
+
state.onConnect?.(ws);
|
|
27
|
+
for (const g of ws.data.groups) addToGroup(state, ws, g);
|
|
28
|
+
greetClient(state, ws);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Announce wire version + capabilities, then pin this client's identity. */
|
|
32
|
+
function greetClient(state: ServerState, ws: ServerWebSocket<WsData>): void {
|
|
33
|
+
// announce our wire version + capabilities so clients can negotiate
|
|
34
|
+
sendControl(state, ws, "hello", {
|
|
35
|
+
version: (state.bindings as Bindings).wireVersion,
|
|
36
|
+
caps: [],
|
|
37
|
+
lastSeq: 0,
|
|
38
|
+
});
|
|
39
|
+
// then assign identity so the client knows its id + server-side groups
|
|
40
|
+
sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A socket closed: detach everywhere and park the resume history. */
|
|
44
|
+
export function onSocketClose(state: ServerState, ws: ServerWebSocket<WsData>): void {
|
|
45
|
+
// events-layer detach FIRST (client record still carries groups/topics)
|
|
46
|
+
state.onDisconnect?.(ws);
|
|
47
|
+
state.sockets.delete(ws);
|
|
48
|
+
state.clients.delete(ws.data.id);
|
|
49
|
+
for (const g of ws.data.groups) removeFromGroup(state, ws, g);
|
|
50
|
+
ws.data.groups.clear();
|
|
51
|
+
for (const t of ws.data.topics) leaveRoom(state, ws, t);
|
|
52
|
+
ws.data.topics.clear();
|
|
53
|
+
delete ws.data.queue;
|
|
54
|
+
// resume: park the sent-history so a reconnect with the same id can
|
|
55
|
+
// pick up where this session left off (no-op when resume is off)
|
|
56
|
+
burySession(state, ws);
|
|
57
|
+
}
|
package/src/core/state.ts
CHANGED
|
@@ -10,9 +10,11 @@ import type { ServerWebSocket } from "bun";
|
|
|
10
10
|
import { defaultBindings } from "../bindings/default";
|
|
11
11
|
import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
|
|
12
12
|
import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
13
|
+
import { createEventTrace, type EventTrace, type EventTraceOptions } from "../events/trace";
|
|
13
14
|
import { createTransport, defaultTransport, type Transport } from "../transport/transport";
|
|
14
15
|
import type { Int64GuardMode } from "./int64-guard";
|
|
15
16
|
import { createMetrics, type Metrics } from "./metrics";
|
|
17
|
+
import { resolveRateLimit, type RateLimitOptions, type ResolvedRateLimit } from "./rate-limit";
|
|
16
18
|
import type { RingBuffer } from "./ring";
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -57,6 +59,15 @@ export interface WsData {
|
|
|
57
59
|
connectedAt: number;
|
|
58
60
|
/** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
|
|
59
61
|
queue?: RingBuffer<Uint8Array>;
|
|
62
|
+
/** per-connection inbound rate limiter (lazily created on first frame) */
|
|
63
|
+
rate?: import("./rate-limit").RateLimiter;
|
|
64
|
+
/**
|
|
65
|
+
* Next per-connection delivery seq to stamp (envelope v2). Starts at 1;
|
|
66
|
+
* continues a previous session's stream when a grave is adopted.
|
|
67
|
+
*/
|
|
68
|
+
sendSeq: number;
|
|
69
|
+
/** bounded sent-frame history for gap recovery (lazily created, resume only) */
|
|
70
|
+
history?: import("./ring").RingBuffer<import("./resume").SentFrame>;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
/** Slow-consumer policy (see `IgnBackpressureOptions`). */
|
|
@@ -97,9 +108,23 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
|
|
|
97
108
|
* traffic. Off by default to keep the hot path allocation-free.
|
|
98
109
|
*/
|
|
99
110
|
replay?: { historySize?: number };
|
|
111
|
+
/**
|
|
112
|
+
* Gap-free delivery (envelope v2 seq + resume). When set, every frame sent
|
|
113
|
+
* to a socket carries a per-connection delivery seq, the connection keeps a
|
|
114
|
+
* bounded sent-history ring, and closed sessions park that ring in a
|
|
115
|
+
* per-client-id graveyard so reconnects can resume missed frames.
|
|
116
|
+
*/
|
|
117
|
+
resume?: { historySize?: number; ttlMs?: number };
|
|
118
|
+
/**
|
|
119
|
+
* Durable topic log behind the replay ring (`src/core/topic-log.ts`). When
|
|
120
|
+
* set, every recorded topic frame is appended and `snapshotRequest`s older
|
|
121
|
+
* than the ring hydrate from the log. Default: none (ring-only).
|
|
122
|
+
*/
|
|
123
|
+
topicLog?: import("./topic-log").TopicLog;
|
|
100
124
|
/**
|
|
101
125
|
* Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
|
|
102
|
-
* the connection (401)
|
|
126
|
+
* the connection (401) — a hook that throws (or rejects) denies it too.
|
|
127
|
+
* Return `true` to allow it (client gets an auto-
|
|
103
128
|
* generated id), or a `ClientMeta` object to pin the client id / seed its
|
|
104
129
|
* server-side groups / attach metadata. Inspect `req` as needed.
|
|
105
130
|
*/
|
|
@@ -115,6 +140,22 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
|
|
|
115
140
|
maxConnections?: number;
|
|
116
141
|
/** maximum inbound frame size in bytes (close 1009 beyond) */
|
|
117
142
|
maxMessageSize?: number;
|
|
143
|
+
/**
|
|
144
|
+
* Per-connection inbound rate limiting (token bucket over ALL frames — app
|
|
145
|
+
* AND control). Default: off (zero hot-path overhead). Over-limit frames are
|
|
146
|
+
* dropped (default) or the socket is closed (`policy: "close"`, code 1008);
|
|
147
|
+
* either way the event is counted in `metrics.rateLimited`.
|
|
148
|
+
*/
|
|
149
|
+
rateLimit?: RateLimitOptions;
|
|
150
|
+
/**
|
|
151
|
+
* Authorize a client's topic (room) join — enforced for EVERY join path:
|
|
152
|
+
* `subscribe` control frames, programmatic `server.join`, and auth-seeded
|
|
153
|
+
* topics. Return false to reject (the frame/ call is ignored and counted in
|
|
154
|
+
* `metrics.rejectedJoins`). Default: allow all.
|
|
155
|
+
*/
|
|
156
|
+
authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
157
|
+
/** Authorize a server-side group join (same contract as `authorizeTopic`). */
|
|
158
|
+
authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
118
159
|
/**
|
|
119
160
|
* Lossless-int64 guard for plain `number` int64 fields: values outside the
|
|
120
161
|
* safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
|
|
@@ -141,6 +182,13 @@ export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
|
|
|
141
182
|
* (`ignex-nova/events`) is bound by default.
|
|
142
183
|
*/
|
|
143
184
|
events?: import("../events/types").EventsOptions<B>;
|
|
185
|
+
/**
|
|
186
|
+
* Event trace ring — records every fired event (emitted / published /
|
|
187
|
+
* received) into a pre-allocated structure-of-arrays buffer so a debugger
|
|
188
|
+
* (ignex debugbar, MCP) can see what fired without any hot-path allocation.
|
|
189
|
+
* Default: on with capacity 1024; `IGNEX_NOVA_TRACE=0` disables globally.
|
|
190
|
+
*/
|
|
191
|
+
trace?: EventTraceOptions;
|
|
144
192
|
/** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
|
|
145
193
|
fetch?: (req: Request) => Response | Promise<Response>;
|
|
146
194
|
}
|
|
@@ -163,7 +211,11 @@ export interface ServerState {
|
|
|
163
211
|
token?: string | ((token: string) => boolean);
|
|
164
212
|
maxConnections?: number;
|
|
165
213
|
maxMessageSize?: number;
|
|
214
|
+
rateLimit: ResolvedRateLimit | null;
|
|
215
|
+
authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
216
|
+
authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
166
217
|
replay: { historySize: number } | null;
|
|
218
|
+
resume: { historySize: number; ttlMs: number } | null;
|
|
167
219
|
sockets: Set<ServerWebSocket<WsData>>;
|
|
168
220
|
/** id → live socket (client registry for targeted sends / introspection) */
|
|
169
221
|
clients: Map<string, ServerWebSocket<WsData>>;
|
|
@@ -175,11 +227,23 @@ export interface ServerState {
|
|
|
175
227
|
inboundHandlers: Map<string, InboundHandler>;
|
|
176
228
|
topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
|
|
177
229
|
replaySeq: number;
|
|
230
|
+
/** optional durable topic log (wired in createServer when `options.topicLog` is set) */
|
|
231
|
+
topicLog?: import("./topic-log").TopicLog;
|
|
232
|
+
/**
|
|
233
|
+
* Responder registry for request/response (`rpcCall` control frames):
|
|
234
|
+
* inner event name → async responder. Registered via `server.handle` /
|
|
235
|
+
* `hub.onRequest`.
|
|
236
|
+
*/
|
|
237
|
+
rpcHandlers: Map<string, (payload: unknown, ws: ServerWebSocket<WsData>) => Promise<unknown>>;
|
|
238
|
+
/** parked sent-history rings of closed sessions (cross-connection resume) */
|
|
239
|
+
graves: Map<string, { history: RingBuffer<import("./resume").SentFrame>; nextSeq: number; expiresAt: number }>;
|
|
178
240
|
/** events-layer lifecycle hooks (wired by createServer when `events` is set) */
|
|
179
241
|
onConnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
180
242
|
onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
181
243
|
/** fired on ANY group membership change (auth seed, control frames, programmatic) */
|
|
182
244
|
onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
|
|
245
|
+
/** event trace ring (debugger visibility; pre-allocated, zero-GC writes) */
|
|
246
|
+
trace: EventTrace;
|
|
183
247
|
}
|
|
184
248
|
|
|
185
249
|
export function createServerState<B extends Bindings = DefaultBindings>(
|
|
@@ -205,7 +269,12 @@ export function createServerState<B extends Bindings = DefaultBindings>(
|
|
|
205
269
|
...(options.token !== undefined ? { token: options.token } : {}),
|
|
206
270
|
...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
|
|
207
271
|
...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
|
|
272
|
+
rateLimit: resolveRateLimit(options.rateLimit),
|
|
273
|
+
...(options.authorizeTopic !== undefined ? { authorizeTopic: options.authorizeTopic } : {}),
|
|
274
|
+
...(options.authorizeGroup !== undefined ? { authorizeGroup: options.authorizeGroup } : {}),
|
|
275
|
+
...(options.topicLog !== undefined ? { topicLog: options.topicLog } : {}),
|
|
208
276
|
replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
|
|
277
|
+
resume: options.resume ? { historySize: options.resume.historySize ?? 256, ttlMs: options.resume.ttlMs ?? 60_000 } : null,
|
|
209
278
|
sockets: new Set(),
|
|
210
279
|
clients: new Map(),
|
|
211
280
|
rooms: new Map(),
|
|
@@ -213,5 +282,8 @@ export function createServerState<B extends Bindings = DefaultBindings>(
|
|
|
213
282
|
inboundHandlers: new Map(),
|
|
214
283
|
topicHistory: new Map(),
|
|
215
284
|
replaySeq: 0,
|
|
285
|
+
rpcHandlers: new Map(),
|
|
286
|
+
graves: new Map(),
|
|
287
|
+
trace: createEventTrace(options.trace),
|
|
216
288
|
};
|
|
217
289
|
}
|