@ignex/nova 0.1.1
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/LICENSE +21 -0
- package/README.md +313 -0
- package/docs/architecture.md +146 -0
- package/docs/publishing.md +119 -0
- package/docs/wire-format.md +170 -0
- package/index.ts +61 -0
- package/package.json +89 -0
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/client.ts +23 -0
- package/public/nats.ts +19 -0
- package/public/server.ts +35 -0
- package/rust/Cargo.toml +19 -0
- package/rust/src/ffi.rs +135 -0
- package/rust/src/generated/backend.rs +2817 -0
- package/rust/src/generated/mod.rs +2 -0
- package/rust/src/lib.rs +9 -0
- package/rust/src/transcode/generated.rs +1352 -0
- package/rust/src/transcode/mod.rs +2 -0
- package/src/bridge/nats.ts +269 -0
- package/src/bridge/subjects.ts +30 -0
- package/src/core/auth.ts +56 -0
- package/src/core/backpressure.ts +39 -0
- package/src/core/client-heartbeat.ts +27 -0
- package/src/core/client-reconnect.ts +35 -0
- package/src/core/client-state.ts +76 -0
- package/src/core/client-wire.ts +72 -0
- package/src/core/client.ts +176 -0
- package/src/core/groups.ts +52 -0
- package/src/core/int64-guard.ts +44 -0
- package/src/core/metrics.ts +105 -0
- package/src/core/outbound.ts +76 -0
- package/src/core/replay.ts +31 -0
- package/src/core/ring.ts +85 -0
- package/src/core/rooms.ts +44 -0
- package/src/core/routing.ts +94 -0
- package/src/core/server.ts +294 -0
- package/src/core/state.ts +179 -0
- package/src/generated/direct-ser.ts +495 -0
- package/src/generated/fbs/backend.fbs +139 -0
- package/src/generated/registry.ts +341 -0
- package/src/generated/rust/backend_generated.rs +2817 -0
- package/src/generated/ts/backend.ts +25 -0
- package/src/generated/ts/big-val.ts +106 -0
- package/src/generated/ts/complex.ts +303 -0
- package/src/generated/ts/customer.ts +137 -0
- package/src/generated/ts/hello.ts +123 -0
- package/src/generated/ts/join-group.ts +78 -0
- package/src/generated/ts/leave-group.ts +78 -0
- package/src/generated/ts/order-billing.ts +137 -0
- package/src/generated/ts/order-line.ts +144 -0
- package/src/generated/ts/order.ts +236 -0
- package/src/generated/ts/ping.ts +74 -0
- package/src/generated/ts/pong.ts +74 -0
- package/src/generated/ts/portfolio-position.ts +120 -0
- package/src/generated/ts/portfolio-snapshot.ts +170 -0
- package/src/generated/ts/quote.ts +148 -0
- package/src/generated/ts/side.ts +8 -0
- package/src/generated/ts/snapshot-request.ts +78 -0
- package/src/generated/ts/subscribe.ts +78 -0
- package/src/generated/ts/tags.ts +9 -0
- package/src/generated/ts/trade.ts +135 -0
- package/src/generated/ts/unsubscribe.ts +78 -0
- package/src/generated/ts/welcome.ts +112 -0
- package/src/generated/ts-ser.ts +465 -0
- package/src/generated/wire-registry.json +20 -0
- package/src/native/codec.ts +35 -0
- package/src/native/ffi.ts +214 -0
- package/src/native/loader.ts +55 -0
- package/src/schema/index.ts +217 -0
- package/src/server.ts +87 -0
- package/src/transport/byte-buffer-pool.ts +63 -0
- package/src/transport/scratch.ts +48 -0
- package/src/transport/stats.ts +44 -0
- package/src/transport/transport.ts +106 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound routing — table-driven dispatch for app + control frames received
|
|
3
|
+
* from a socket. Parses the envelope, enforces `maxMessageSize` + the inbound
|
|
4
|
+
* allowlist, and routes to handlers / room operations.
|
|
5
|
+
*
|
|
6
|
+
* Decode is HEADER-FIRST: only the cheap envelope (version + event id) is
|
|
7
|
+
* parsed before deciding what to do, so an event that isn't in the inbound
|
|
8
|
+
* allowlist (or is a control frame) never pays the cost of a full `.unpack()`
|
|
9
|
+
* payload decode — a client can't force us to fully materialize a frame we're
|
|
10
|
+
* going to discard.
|
|
11
|
+
*/
|
|
12
|
+
import type { ServerWebSocket } from "bun";
|
|
13
|
+
import { decodePayload, isControlId, readFrameHeader, WIRE_VERSION } from "../generated/registry";
|
|
14
|
+
import type { ControlEventName, ControlEvents, EventName } from "../schema";
|
|
15
|
+
import { sendControl } from "./outbound";
|
|
16
|
+
import { joinRoom, leaveRoom } from "./rooms";
|
|
17
|
+
import { joinGroup, leaveGroup } from "./groups";
|
|
18
|
+
import type { ServerState, WsData } from "./state";
|
|
19
|
+
|
|
20
|
+
// allocated once per process, reused for every inbound text frame
|
|
21
|
+
const textEncoder = new TextEncoder();
|
|
22
|
+
|
|
23
|
+
export function handleMessage(
|
|
24
|
+
state: ServerState,
|
|
25
|
+
ws: ServerWebSocket<WsData>,
|
|
26
|
+
raw: string | Buffer,
|
|
27
|
+
): void {
|
|
28
|
+
const bytes = typeof raw === "string" ? textEncoder.encode(raw) : new Uint8Array(raw as Buffer);
|
|
29
|
+
if (state.maxMessageSize !== undefined && bytes.byteLength > state.maxMessageSize) {
|
|
30
|
+
state.metrics.protocolErrors++;
|
|
31
|
+
ws.close(1009, "message too big");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const header = readFrameHeader(bytes);
|
|
35
|
+
if (!header) {
|
|
36
|
+
state.metrics.protocolErrors++;
|
|
37
|
+
return; // undecodable / wrong version / unknown id — drop
|
|
38
|
+
}
|
|
39
|
+
if (isControlId(header.id)) {
|
|
40
|
+
state.metrics.inboundControl++;
|
|
41
|
+
handleControl(state, ws, header.name as ControlEventName, decodePayload(header.id, bytes) as never);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const name = header.name as EventName;
|
|
45
|
+
if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
|
|
46
|
+
state.metrics.inbound++;
|
|
47
|
+
state.inboundHandlers.get(name)?.(decodePayload(header.id, bytes), ws);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function handleControl(
|
|
51
|
+
state: ServerState,
|
|
52
|
+
ws: ServerWebSocket<WsData>,
|
|
53
|
+
name: ControlEventName,
|
|
54
|
+
payload: unknown,
|
|
55
|
+
): void {
|
|
56
|
+
switch (name) {
|
|
57
|
+
case "hello": {
|
|
58
|
+
const p = payload as ControlEvents["hello"];
|
|
59
|
+
ws.data.version = p.version;
|
|
60
|
+
ws.data.lastSeq = p.lastSeq;
|
|
61
|
+
if (p.version !== WIRE_VERSION) {
|
|
62
|
+
// protocol version mismatch — refuse this client
|
|
63
|
+
ws.close(1002, "wire version mismatch");
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "subscribe": {
|
|
68
|
+
joinRoom(state, ws, (payload as ControlEvents["subscribe"]).topic);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "unsubscribe": {
|
|
72
|
+
leaveRoom(state, ws, (payload as ControlEvents["unsubscribe"]).topic);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "joinGroup": {
|
|
76
|
+
joinGroup(state, ws, (payload as ControlEvents["joinGroup"]).group);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "leaveGroup": {
|
|
80
|
+
leaveGroup(state, ws, (payload as ControlEvents["leaveGroup"]).group);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "ping": {
|
|
84
|
+
sendControl(state, ws, "pong", { ts: (payload as ControlEvents["ping"]).ts });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "pong":
|
|
88
|
+
// client keepalive reply — tracked on the client side (heartbeat)
|
|
89
|
+
break;
|
|
90
|
+
case "snapshotRequest":
|
|
91
|
+
// per-topic replay from the ring buffer (see replay.ts)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server composition root — wires `createServerState` + the action modules
|
|
3
|
+
* (auth / rooms / outbound / routing) into `Bun.serve`, and returns a plain
|
|
4
|
+
* `IgnServer` API object (no class, no `this`). This is the ONLY place that
|
|
5
|
+
* knows how the pieces fit together.
|
|
6
|
+
*
|
|
7
|
+
* Public entry: `public/server.ts` re-exports `createServer` + the types.
|
|
8
|
+
*/
|
|
9
|
+
import type { ServerWebSocket } from "bun";
|
|
10
|
+
import { WIRE_VERSION } from "../generated/registry";
|
|
11
|
+
import type { Events, EventName } from "../schema";
|
|
12
|
+
import { getEncodeStats, encodeToScratch } from "../transport/transport";
|
|
13
|
+
import { setInt64GuardMode } from "./int64-guard";
|
|
14
|
+
import type { MetricsSnapshot } from "./metrics";
|
|
15
|
+
import { checkUpgrade } from "./auth";
|
|
16
|
+
import { drainSocket, sendControl, sendFrame } from "./outbound";
|
|
17
|
+
import { handleMessage } from "./routing";
|
|
18
|
+
import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "./rooms";
|
|
19
|
+
import {
|
|
20
|
+
activeGroups,
|
|
21
|
+
groupMembers as groupMemberIds,
|
|
22
|
+
joinGroup as addToGroup,
|
|
23
|
+
leaveGroup as removeFromGroup,
|
|
24
|
+
publishToGroup as publishToGroupState,
|
|
25
|
+
} from "./groups";
|
|
26
|
+
import { createServerState, type IgnServerOptions, type WsData } from "./state";
|
|
27
|
+
import { createNatsBridge } from "../bridge/nats";
|
|
28
|
+
|
|
29
|
+
/** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
|
|
30
|
+
export interface ClientInfo {
|
|
31
|
+
id: string;
|
|
32
|
+
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
33
|
+
meta?: Record<string, unknown>;
|
|
34
|
+
/** server-side groups this client belongs to */
|
|
35
|
+
groups: string[];
|
|
36
|
+
/** topics/rooms this client has joined */
|
|
37
|
+
topics: string[];
|
|
38
|
+
/** epoch ms the socket connected */
|
|
39
|
+
connectedAt: number;
|
|
40
|
+
/** remote IP (from the socket) */
|
|
41
|
+
ip: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
|
|
45
|
+
return {
|
|
46
|
+
id: ws.data.id,
|
|
47
|
+
meta: ws.data.meta,
|
|
48
|
+
groups: [...ws.data.groups],
|
|
49
|
+
topics: [...ws.data.topics],
|
|
50
|
+
connectedAt: ws.data.connectedAt,
|
|
51
|
+
ip: ws.remoteAddress,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The public server API (returned by `createServer`). */
|
|
56
|
+
export interface IgnServer {
|
|
57
|
+
readonly port: number;
|
|
58
|
+
readonly clientCount: number;
|
|
59
|
+
getMetrics(): MetricsSnapshot;
|
|
60
|
+
/** Broadcast a typed event to every connected client (zero-alloc on the happy path). */
|
|
61
|
+
publish<K extends EventName>(name: K, payload: Events[K]): void;
|
|
62
|
+
/** Send a typed event to a single socket (zero-alloc on the happy path). */
|
|
63
|
+
publishTo<K extends EventName>(ws: ServerWebSocket<WsData>, name: K, payload: Events[K]): void;
|
|
64
|
+
/** Publish a typed event to every socket subscribed to `topic`. */
|
|
65
|
+
publishToTopic<K extends EventName>(topic: string, name: K, payload: Events[K]): void;
|
|
66
|
+
/** Send a typed event to a specific client by id. Returns false if that client is offline. */
|
|
67
|
+
publishToClient<K extends EventName>(id: string, name: K, payload: Events[K]): boolean;
|
|
68
|
+
/** Programmatic room membership (clients can also join via subscribe frames). */
|
|
69
|
+
join(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
70
|
+
leave(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
71
|
+
/** Live topic names (with at least one subscriber). */
|
|
72
|
+
topics(): string[];
|
|
73
|
+
/** Server-side group membership (targeted fan-out, no replay). */
|
|
74
|
+
joinGroup(id: string, group: string): void;
|
|
75
|
+
leaveGroup(id: string, group: string): void;
|
|
76
|
+
/** Publish a typed event to every client in a server-side group. */
|
|
77
|
+
publishToGroup<K extends EventName>(group: string, name: K, payload: Events[K]): void;
|
|
78
|
+
/** Live server-side group names (with at least one member). */
|
|
79
|
+
groups(): string[];
|
|
80
|
+
/** Client ids currently in `group`. */
|
|
81
|
+
groupMembers(group: string): string[];
|
|
82
|
+
/** Groups a client belongs to ([] if offline/unknown). */
|
|
83
|
+
clientGroups(id: string): string[];
|
|
84
|
+
/** Active-client introspection. */
|
|
85
|
+
getClient(id: string): ClientInfo | undefined;
|
|
86
|
+
getClients(): ClientInfo[];
|
|
87
|
+
/** Force-close a specific client socket (e.g. from a policy / admin action). */
|
|
88
|
+
closeClient(ws: ServerWebSocket<WsData>): void;
|
|
89
|
+
/** Disconnect a client by id. Returns false if that client is offline. */
|
|
90
|
+
disconnectClient(id: string): boolean;
|
|
91
|
+
/** Register a handler for an inbound app event (must be in `options.inbound`). */
|
|
92
|
+
on<K extends EventName>(name: K, handler: (payload: Events[K], ws: ServerWebSocket<WsData>) => void): IgnServer;
|
|
93
|
+
off<K extends EventName>(name: K): IgnServer;
|
|
94
|
+
/** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
|
|
95
|
+
drain(timeoutMs?: number): Promise<void>;
|
|
96
|
+
stop(force?: boolean): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createServer(options: IgnServerOptions): IgnServer {
|
|
100
|
+
const state = createServerState(options);
|
|
101
|
+
setInt64GuardMode(options.int64Guard ?? "off");
|
|
102
|
+
|
|
103
|
+
// NATS bridge (optional, best-effort — created eagerly, connects in the background)
|
|
104
|
+
const natsOpt = options.nats;
|
|
105
|
+
if (natsOpt) state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt);
|
|
106
|
+
|
|
107
|
+
// Encode once + broadcast to every connected client (NO bridge) — the shared
|
|
108
|
+
// hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
|
|
109
|
+
// events reach clients but are never re-bridged to NATS.
|
|
110
|
+
function fanOutAll(name: Parameters<typeof encodeToScratch>[0], payload: unknown): Uint8Array {
|
|
111
|
+
const frame = encodeToScratch(name, payload);
|
|
112
|
+
state.metrics.published++;
|
|
113
|
+
for (const ws of state.sockets) sendFrame(state, ws, frame);
|
|
114
|
+
return frame;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (state.bridge) {
|
|
118
|
+
state.bridge.setOnInbound((name, payload) => {
|
|
119
|
+
fanOutAll(name, payload);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const bun = Bun.serve<WsData>({
|
|
124
|
+
port: options.port,
|
|
125
|
+
hostname: options.hostname,
|
|
126
|
+
idleTimeout: options.idleTimeout ?? 30,
|
|
127
|
+
tls: options.tls,
|
|
128
|
+
fetch: (req, srv) => {
|
|
129
|
+
const url = new URL(req.url);
|
|
130
|
+
if (url.pathname === state.path) return checkUpgrade(state, req, srv);
|
|
131
|
+
if (url.pathname === "/health") {
|
|
132
|
+
const h = state.metrics.snapshot(state.sockets.size);
|
|
133
|
+
return new Response(
|
|
134
|
+
JSON.stringify({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs }),
|
|
135
|
+
{ headers: { "content-type": "application/json" } },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (url.pathname === "/clients") {
|
|
139
|
+
return new Response(JSON.stringify([...state.clients.values()].map(toClientInfo)), {
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (options.fetch) return options.fetch(req);
|
|
144
|
+
return new Response("not found", { status: 404 });
|
|
145
|
+
},
|
|
146
|
+
websocket: {
|
|
147
|
+
open: (ws) => {
|
|
148
|
+
state.sockets.add(ws);
|
|
149
|
+
// belt-and-suspenders: an auth race could double-register an id — kick the stale session
|
|
150
|
+
const existing = state.clients.get(ws.data.id);
|
|
151
|
+
if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
|
|
152
|
+
state.clients.set(ws.data.id, ws);
|
|
153
|
+
for (const g of ws.data.groups) addToGroup(state, ws, g);
|
|
154
|
+
// announce our wire version + capabilities so clients can negotiate
|
|
155
|
+
sendControl(state, ws, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
|
|
156
|
+
// then assign identity so the client knows its id + server-side groups
|
|
157
|
+
sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
|
|
158
|
+
},
|
|
159
|
+
close: (ws) => {
|
|
160
|
+
state.sockets.delete(ws);
|
|
161
|
+
state.clients.delete(ws.data.id);
|
|
162
|
+
for (const g of ws.data.groups) removeFromGroup(state, ws, g);
|
|
163
|
+
ws.data.groups.clear();
|
|
164
|
+
for (const t of ws.data.topics) leaveRoom(state, ws, t);
|
|
165
|
+
ws.data.topics.clear();
|
|
166
|
+
delete ws.data.queue;
|
|
167
|
+
},
|
|
168
|
+
message: (ws, msg) => handleMessage(state, ws, msg),
|
|
169
|
+
drain: (ws) => drainSocket(state, ws),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const api: IgnServer = {
|
|
174
|
+
get port(): number {
|
|
175
|
+
return bun.port ?? 0;
|
|
176
|
+
},
|
|
177
|
+
get clientCount(): number {
|
|
178
|
+
return state.sockets.size;
|
|
179
|
+
},
|
|
180
|
+
getMetrics(): MetricsSnapshot {
|
|
181
|
+
const stats = getEncodeStats();
|
|
182
|
+
for (const [name, n] of Object.entries(stats.direct)) {
|
|
183
|
+
if (n > 0) state.metrics.countPath(name, "direct");
|
|
184
|
+
}
|
|
185
|
+
for (const [name, n] of Object.entries(stats.json)) {
|
|
186
|
+
if (n > 0) state.metrics.countPath(name, "json");
|
|
187
|
+
}
|
|
188
|
+
const snapshot = state.metrics.snapshot(state.sockets.size);
|
|
189
|
+
const b = state.bridge;
|
|
190
|
+
if (b) {
|
|
191
|
+
snapshot.bridged = b.stats.bridged;
|
|
192
|
+
snapshot.bridgedBytes = b.stats.bridgedBytes;
|
|
193
|
+
snapshot.bridgeErrors = b.stats.bridgeErrors;
|
|
194
|
+
snapshot.bridgeInbound = b.stats.bridgeInbound;
|
|
195
|
+
snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
|
|
196
|
+
snapshot.natsStatus = b.status;
|
|
197
|
+
}
|
|
198
|
+
return snapshot;
|
|
199
|
+
},
|
|
200
|
+
publish(name, payload) {
|
|
201
|
+
const frame = fanOutAll(name, payload);
|
|
202
|
+
state.bridge?.publish(state.bridge.subjects.broadcast(name), frame);
|
|
203
|
+
},
|
|
204
|
+
publishTo(ws, name, payload) {
|
|
205
|
+
state.metrics.published++;
|
|
206
|
+
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
207
|
+
},
|
|
208
|
+
publishToTopic(topic, name, payload) {
|
|
209
|
+
const frame = encodeToScratch(name, payload);
|
|
210
|
+
state.metrics.published++;
|
|
211
|
+
publishToRoom(state, topic, frame);
|
|
212
|
+
state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
|
|
213
|
+
},
|
|
214
|
+
publishToClient(id, name, payload) {
|
|
215
|
+
const ws = state.clients.get(id);
|
|
216
|
+
if (!ws) return false;
|
|
217
|
+
state.metrics.published++;
|
|
218
|
+
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
join(topic, ws) {
|
|
222
|
+
joinRoom(state, ws, topic);
|
|
223
|
+
},
|
|
224
|
+
leave(topic, ws) {
|
|
225
|
+
leaveRoom(state, ws, topic);
|
|
226
|
+
},
|
|
227
|
+
topics() {
|
|
228
|
+
return roomTopics(state);
|
|
229
|
+
},
|
|
230
|
+
joinGroup(id, group) {
|
|
231
|
+
const ws = state.clients.get(id);
|
|
232
|
+
if (ws) addToGroup(state, ws, group);
|
|
233
|
+
},
|
|
234
|
+
leaveGroup(id, group) {
|
|
235
|
+
const ws = state.clients.get(id);
|
|
236
|
+
if (ws) removeFromGroup(state, ws, group);
|
|
237
|
+
},
|
|
238
|
+
publishToGroup(group, name, payload) {
|
|
239
|
+
const frame = encodeToScratch(name, payload);
|
|
240
|
+
state.metrics.published++;
|
|
241
|
+
publishToGroupState(state, group, frame);
|
|
242
|
+
state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
|
|
243
|
+
},
|
|
244
|
+
groups() {
|
|
245
|
+
return activeGroups(state);
|
|
246
|
+
},
|
|
247
|
+
groupMembers(group) {
|
|
248
|
+
return groupMemberIds(state, group);
|
|
249
|
+
},
|
|
250
|
+
clientGroups(id) {
|
|
251
|
+
const ws = state.clients.get(id);
|
|
252
|
+
return ws ? [...ws.data.groups] : [];
|
|
253
|
+
},
|
|
254
|
+
getClient(id) {
|
|
255
|
+
const ws = state.clients.get(id);
|
|
256
|
+
return ws ? toClientInfo(ws) : undefined;
|
|
257
|
+
},
|
|
258
|
+
getClients() {
|
|
259
|
+
return [...state.clients.values()].map(toClientInfo);
|
|
260
|
+
},
|
|
261
|
+
closeClient(ws) {
|
|
262
|
+
ws.close(1000, "closed by server");
|
|
263
|
+
},
|
|
264
|
+
disconnectClient(id) {
|
|
265
|
+
const ws = state.clients.get(id);
|
|
266
|
+
if (!ws) return false;
|
|
267
|
+
ws.close(1000, "closed by server");
|
|
268
|
+
return true;
|
|
269
|
+
},
|
|
270
|
+
on(name, handler) {
|
|
271
|
+
state.inboundHandlers.set(name, handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void);
|
|
272
|
+
return api;
|
|
273
|
+
},
|
|
274
|
+
off(name) {
|
|
275
|
+
state.inboundHandlers.delete(name);
|
|
276
|
+
return api;
|
|
277
|
+
},
|
|
278
|
+
async drain(timeoutMs = 2000): Promise<void> {
|
|
279
|
+
bun.stop(false); // stop listening; keep active sockets draining
|
|
280
|
+
await state.bridge?.close();
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
while (state.sockets.size > 0 && Date.now() < deadline) {
|
|
283
|
+
await Bun.sleep(10);
|
|
284
|
+
}
|
|
285
|
+
bun.stop(true);
|
|
286
|
+
},
|
|
287
|
+
stop(force = true): void {
|
|
288
|
+
bun.stop(force);
|
|
289
|
+
void state.bridge?.close();
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return api;
|
|
294
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server state + public option types. `createServerState(options)` produces the
|
|
3
|
+
* single explicit state object that every server action function reads/mutates
|
|
4
|
+
* (functional composition — no class, no `this`).
|
|
5
|
+
*
|
|
6
|
+
* The option types (IgnServerOptions / IgnBackpressureOptions / WsData) are the
|
|
7
|
+
* PUBLIC surface and are re-exported by `public/server.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import type { ServerWebSocket } from "bun";
|
|
10
|
+
import type { EventName } from "../schema";
|
|
11
|
+
import type { Int64GuardMode } from "./int64-guard";
|
|
12
|
+
import { createMetrics, type Metrics } from "./metrics";
|
|
13
|
+
import { RingBuffer } from "./ring";
|
|
14
|
+
import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional identity metadata a client may carry for targeting / grouping.
|
|
18
|
+
* Returned from the `authenticate` hook (or filled in programmatically).
|
|
19
|
+
*/
|
|
20
|
+
export interface ClientMeta {
|
|
21
|
+
/** explicit client id; omitted → auto-assigned `crypto.randomUUID()` */
|
|
22
|
+
id?: string;
|
|
23
|
+
/** server-side groups this client belongs to on connect */
|
|
24
|
+
groups?: string[];
|
|
25
|
+
/** arbitrary app metadata (exposed via `getClient` / `getClients`) */
|
|
26
|
+
meta?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What `authenticate` may return: `true`/`false` (old behavior) or metadata. */
|
|
30
|
+
export type AuthResult = boolean | ClientMeta;
|
|
31
|
+
|
|
32
|
+
/** Per-socket metadata carried on the upgraded WebSocket (`ws.data`). */
|
|
33
|
+
export interface WsData {
|
|
34
|
+
/** wire version the client announced in its hello (undefined until then) */
|
|
35
|
+
version?: number;
|
|
36
|
+
/** last sequence the client has seen (0 = none) — used for replay */
|
|
37
|
+
lastSeq: number;
|
|
38
|
+
/** topics this socket has joined (client-joinable rooms) */
|
|
39
|
+
topics: Set<string>;
|
|
40
|
+
/** server-side groups this socket belongs to (auth-seeded + programmatic) */
|
|
41
|
+
groups: Set<string>;
|
|
42
|
+
/** stable client id (auth metadata or auto-generated UUID) */
|
|
43
|
+
id: string;
|
|
44
|
+
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
45
|
+
meta?: Record<string, unknown>;
|
|
46
|
+
/** epoch ms when the socket opened (for `getClients` ordering/uptime) */
|
|
47
|
+
connectedAt: number;
|
|
48
|
+
/** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
|
|
49
|
+
queue?: RingBuffer<Uint8Array>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Slow-consumer policy (see `IgnBackpressureOptions`). */
|
|
53
|
+
export type BackpressurePolicy = "drop-oldest" | "drop-newest" | "disconnect";
|
|
54
|
+
|
|
55
|
+
export interface IgnBackpressureOptions {
|
|
56
|
+
/** per-socket buffered bytes that trigger backpressure, default 1 MiB */
|
|
57
|
+
highWaterMark?: number;
|
|
58
|
+
/** what to do with a slow consumer, default "drop-oldest" */
|
|
59
|
+
policy?: BackpressurePolicy;
|
|
60
|
+
/** max queued frames per slow socket (drop-oldest only), default 256 */
|
|
61
|
+
maxQueue?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IgnServerOptions {
|
|
65
|
+
port: number;
|
|
66
|
+
hostname?: string;
|
|
67
|
+
/** seconds; 0 = no timeout */
|
|
68
|
+
idleTimeout?: number;
|
|
69
|
+
/** websocket path, default "/ws" */
|
|
70
|
+
path?: string;
|
|
71
|
+
/** app events clients are ALLOWED to send; control events are always allowed. default [] */
|
|
72
|
+
inbound?: EventName[];
|
|
73
|
+
/** slow-consumer protection. default: off (unbounded buffering) */
|
|
74
|
+
backpressure?: IgnBackpressureOptions;
|
|
75
|
+
/**
|
|
76
|
+
* Per-topic history for last-value snapshots on subscribe (and reconnect
|
|
77
|
+
* replay). When set, every `publishToTopic` records an owned copy of the
|
|
78
|
+
* frame (bounded to `historySize`), and a socket that joins the topic
|
|
79
|
+
* immediately receives the recorded frames (oldest → newest) before live
|
|
80
|
+
* traffic. Off by default to keep the hot path allocation-free.
|
|
81
|
+
*/
|
|
82
|
+
replay?: { historySize?: number };
|
|
83
|
+
/**
|
|
84
|
+
* Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
|
|
85
|
+
* the connection (401). Return `true` to allow it (client gets an auto-
|
|
86
|
+
* generated id), or a `ClientMeta` object to pin the client id / seed its
|
|
87
|
+
* server-side groups / attach metadata. Inspect `req` as needed.
|
|
88
|
+
*/
|
|
89
|
+
authenticate?: (req: Request) => boolean | ClientMeta | Promise<boolean | ClientMeta>;
|
|
90
|
+
/** if set, only these exact `Origin` header values may connect (403 otherwise) */
|
|
91
|
+
allowedOrigins?: string[];
|
|
92
|
+
/**
|
|
93
|
+
* Optional built-in bearer-token auth: either a literal token, or a
|
|
94
|
+
* predicate `(token) => boolean` checking the `Authorization: Bearer` header.
|
|
95
|
+
*/
|
|
96
|
+
token?: string | ((token: string) => boolean);
|
|
97
|
+
/** maximum concurrent WebSocket clients (reject with 503 beyond) */
|
|
98
|
+
maxConnections?: number;
|
|
99
|
+
/** maximum inbound frame size in bytes (close 1009 beyond) */
|
|
100
|
+
maxMessageSize?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Lossless-int64 guard for plain `number` int64 fields: values outside the
|
|
103
|
+
* safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
|
|
104
|
+
* no overhead; the exact fix is `Type.Integer({ bigint: true })` fields).
|
|
105
|
+
*/
|
|
106
|
+
int64Guard?: Int64GuardMode;
|
|
107
|
+
/** Bun.serve TLS options (keyFile/certFile) — enables `wss://` */
|
|
108
|
+
tls?: Bun.ServeOptions<unknown>["tls"];
|
|
109
|
+
/**
|
|
110
|
+
* Optional NATS bridge — either `NatsBridgeOptions` (a built-in bridge is
|
|
111
|
+
* created eagerly and connects in the background) or a pre-built
|
|
112
|
+
* `NatsBridge` (e.g. `createNatsBridge(opts, transport)` with a custom /
|
|
113
|
+
* fake transport — handy for tests). Every broadcast / topic / group
|
|
114
|
+
* publish is ALSO published (as the same FlatBuffer wire frame) to a NATS
|
|
115
|
+
* subject for other applications, and (when `inbound` is set) NATS subjects
|
|
116
|
+
* are forwarded to clients. Best-effort: the WS hot path never blocks on
|
|
117
|
+
* NATS.
|
|
118
|
+
*/
|
|
119
|
+
nats?: NatsBridgeOptions | NatsBridge;
|
|
120
|
+
/** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
|
|
121
|
+
fetch?: (req: Request) => Response | Promise<Response>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type InboundHandler = (payload: unknown, ws: ServerWebSocket<WsData>) => void;
|
|
125
|
+
|
|
126
|
+
/** The full, explicit server state — created once per server, passed to actions. */
|
|
127
|
+
export interface ServerState {
|
|
128
|
+
path: string;
|
|
129
|
+
inbound: ReadonlySet<EventName>;
|
|
130
|
+
bp: Required<IgnBackpressureOptions> | null;
|
|
131
|
+
metrics: Metrics;
|
|
132
|
+
startedAt: number;
|
|
133
|
+
authenticate?: (req: Request) => boolean | ClientMeta | Promise<boolean | ClientMeta>;
|
|
134
|
+
allowedOrigins?: string[];
|
|
135
|
+
token?: string | ((token: string) => boolean);
|
|
136
|
+
maxConnections?: number;
|
|
137
|
+
maxMessageSize?: number;
|
|
138
|
+
replay: { historySize: number } | null;
|
|
139
|
+
sockets: Set<ServerWebSocket<WsData>>;
|
|
140
|
+
/** id → live socket (client registry for targeted sends / introspection) */
|
|
141
|
+
clients: Map<string, ServerWebSocket<WsData>>;
|
|
142
|
+
rooms: Map<string, Set<ServerWebSocket<WsData>>>;
|
|
143
|
+
/** group → member sockets (server-side targeting dimension, no replay) */
|
|
144
|
+
groups: Map<string, Set<ServerWebSocket<WsData>>>;
|
|
145
|
+
/** optional NATS bridge (wired in createServer when `options.nats` is set) */
|
|
146
|
+
bridge?: NatsBridge;
|
|
147
|
+
inboundHandlers: Map<EventName, InboundHandler>;
|
|
148
|
+
topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
|
|
149
|
+
replaySeq: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createServerState(options: IgnServerOptions): ServerState {
|
|
153
|
+
return {
|
|
154
|
+
path: options.path ?? "/ws",
|
|
155
|
+
inbound: new Set(options.inbound ?? []),
|
|
156
|
+
bp: options.backpressure
|
|
157
|
+
? {
|
|
158
|
+
highWaterMark: options.backpressure.highWaterMark ?? 1024 * 1024,
|
|
159
|
+
policy: options.backpressure.policy ?? "drop-oldest",
|
|
160
|
+
maxQueue: options.backpressure.maxQueue ?? 256,
|
|
161
|
+
}
|
|
162
|
+
: null,
|
|
163
|
+
metrics: createMetrics(),
|
|
164
|
+
startedAt: Date.now(),
|
|
165
|
+
authenticate: options.authenticate,
|
|
166
|
+
allowedOrigins: options.allowedOrigins,
|
|
167
|
+
token: options.token,
|
|
168
|
+
maxConnections: options.maxConnections,
|
|
169
|
+
maxMessageSize: options.maxMessageSize,
|
|
170
|
+
replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
|
|
171
|
+
sockets: new Set(),
|
|
172
|
+
clients: new Map(),
|
|
173
|
+
rooms: new Map(),
|
|
174
|
+
groups: new Map(),
|
|
175
|
+
inboundHandlers: new Map(),
|
|
176
|
+
topicHistory: new Map(),
|
|
177
|
+
replaySeq: 0,
|
|
178
|
+
};
|
|
179
|
+
}
|