@ignex/nova 0.1.1 → 0.1.3
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 +132 -32
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +232 -0
- package/docs/architecture.md +35 -10
- package/docs/events.md +170 -0
- package/docs/generic-bindings.md +197 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +9 -2
- package/index.ts +75 -27
- package/package.json +12 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +416 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +42 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/transcode/generated.rs +2 -1
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats.ts +53 -13
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +18 -0
- package/src/codegen/direct-gen.ts +550 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +242 -0
- package/src/codegen/rust-glue-gen.ts +545 -0
- package/src/codegen/schema-model.ts +338 -0
- package/src/codegen/ts-ser-gen.ts +221 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +2 -1
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-state.ts +21 -8
- package/src/core/client-wire.ts +10 -11
- package/src/core/client.ts +34 -29
- package/src/core/groups.ts +3 -0
- package/src/core/metrics.ts +7 -3
- package/src/core/outbound.ts +12 -5
- package/src/core/routing.ts +17 -8
- package/src/core/server.ts +108 -34
- package/src/core/state.ts +51 -13
- package/src/events/clients.ts +156 -0
- package/src/events/cluster.ts +732 -0
- package/src/events/data.ts +38 -0
- package/src/events/emit.ts +127 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub.ts +481 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +96 -0
- package/src/events/registry.ts +178 -0
- package/src/events/types.ts +378 -0
- package/src/generated/direct-ser.ts +2 -1
- package/src/generated/fbs/backend.fbs +1 -1
- package/src/generated/registry.ts +3 -1
- package/src/generated/ts-ser.ts +1 -1
- package/src/generated/wire-registry.json +1 -0
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +5 -2
- package/src/server.ts +7 -3
- package/src/transport/stats.ts +8 -4
- package/src/transport/transport.ts +149 -68
package/src/core/server.ts
CHANGED
|
@@ -4,31 +4,37 @@
|
|
|
4
4
|
* `IgnServer` API object (no class, no `this`). This is the ONLY place that
|
|
5
5
|
* knows how the pieces fit together.
|
|
6
6
|
*
|
|
7
|
+
* Generic over the wire stack: `createServer({ bindings })` with your own
|
|
8
|
+
* generated bindings types `publish` / `on` / ... against YOUR events. The
|
|
9
|
+
* default is the built-in registry, so existing code keeps working unchanged.
|
|
10
|
+
*
|
|
7
11
|
* Public entry: `public/server.ts` re-exports `createServer` + the types.
|
|
8
12
|
*/
|
|
9
13
|
import type { ServerWebSocket } from "bun";
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import type { MetricsSnapshot } from "./metrics";
|
|
14
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
|
|
15
|
+
import { createNatsBridge } from "../bridge/nats";
|
|
16
|
+
import { createEventsHub, type EventsHubInternal } from "../events/hub";
|
|
17
|
+
import type { EventsHub } from "../events/types";
|
|
15
18
|
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
19
|
import {
|
|
20
20
|
activeGroups,
|
|
21
|
-
groupMembers as groupMemberIds,
|
|
22
21
|
joinGroup as addToGroup,
|
|
23
|
-
|
|
22
|
+
groupMembers as groupMemberIds,
|
|
24
23
|
publishToGroup as publishToGroupState,
|
|
24
|
+
leaveGroup as removeFromGroup,
|
|
25
25
|
} from "./groups";
|
|
26
|
+
import { setInt64GuardMode } from "./int64-guard";
|
|
27
|
+
import type { MetricsSnapshot } from "./metrics";
|
|
28
|
+
import { drainSocket, sendControl, sendFrame } from "./outbound";
|
|
29
|
+
import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "./rooms";
|
|
30
|
+
import { handleMessage } from "./routing";
|
|
26
31
|
import { createServerState, type IgnServerOptions, type WsData } from "./state";
|
|
27
|
-
import { createNatsBridge } from "../bridge/nats";
|
|
28
32
|
|
|
29
33
|
/** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
|
|
30
34
|
export interface ClientInfo {
|
|
31
35
|
id: string;
|
|
36
|
+
/** identity this connection acts on behalf of (undefined if none) */
|
|
37
|
+
userId?: string;
|
|
32
38
|
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
33
39
|
meta?: Record<string, unknown>;
|
|
34
40
|
/** server-side groups this client belongs to */
|
|
@@ -44,7 +50,8 @@ export interface ClientInfo {
|
|
|
44
50
|
function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
|
|
45
51
|
return {
|
|
46
52
|
id: ws.data.id,
|
|
47
|
-
|
|
53
|
+
...(ws.data.userId !== undefined ? { userId: ws.data.userId } : {}),
|
|
54
|
+
...(ws.data.meta !== undefined ? { meta: ws.data.meta } : {}),
|
|
48
55
|
groups: [...ws.data.groups],
|
|
49
56
|
topics: [...ws.data.topics],
|
|
50
57
|
connectedAt: ws.data.connectedAt,
|
|
@@ -53,18 +60,28 @@ function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
|
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/** The public server API (returned by `createServer`). */
|
|
56
|
-
export interface IgnServer {
|
|
63
|
+
export interface IgnServer<B extends Bindings = DefaultBindings> {
|
|
57
64
|
readonly port: number;
|
|
58
65
|
readonly clientCount: number;
|
|
59
66
|
getMetrics(): MetricsSnapshot;
|
|
60
67
|
/** Broadcast a typed event to every connected client (zero-alloc on the happy path). */
|
|
61
|
-
publish<K extends
|
|
68
|
+
publish<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
|
|
62
69
|
/** Send a typed event to a single socket (zero-alloc on the happy path). */
|
|
63
|
-
publishTo<K extends
|
|
70
|
+
publishTo<K extends EventNameOf<B>>(
|
|
71
|
+
ws: ServerWebSocket<WsData>,
|
|
72
|
+
name: K,
|
|
73
|
+
payload: EventsOf<B>[K],
|
|
74
|
+
): void;
|
|
64
75
|
/** Publish a typed event to every socket subscribed to `topic`. */
|
|
65
|
-
publishToTopic<K extends
|
|
76
|
+
publishToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
|
|
66
77
|
/** Send a typed event to a specific client by id. Returns false if that client is offline. */
|
|
67
|
-
publishToClient<K extends
|
|
78
|
+
publishToClient<K extends EventNameOf<B>>(id: string, name: K, payload: EventsOf<B>[K]): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Allow clients to send `name` (adds it to the inbound allowlist at runtime).
|
|
81
|
+
* The events layer calls this automatically when `server.events.on(name)` is
|
|
82
|
+
* first used.
|
|
83
|
+
*/
|
|
84
|
+
allowInbound<K extends EventNameOf<B>>(name: K): IgnServer<B>;
|
|
68
85
|
/** Programmatic room membership (clients can also join via subscribe frames). */
|
|
69
86
|
join(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
70
87
|
leave(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
@@ -74,7 +91,7 @@ export interface IgnServer {
|
|
|
74
91
|
joinGroup(id: string, group: string): void;
|
|
75
92
|
leaveGroup(id: string, group: string): void;
|
|
76
93
|
/** Publish a typed event to every client in a server-side group. */
|
|
77
|
-
publishToGroup<K extends
|
|
94
|
+
publishToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
|
|
78
95
|
/** Live server-side group names (with at least one member). */
|
|
79
96
|
groups(): string[];
|
|
80
97
|
/** Client ids currently in `group`. */
|
|
@@ -89,42 +106,59 @@ export interface IgnServer {
|
|
|
89
106
|
/** Disconnect a client by id. Returns false if that client is offline. */
|
|
90
107
|
disconnectClient(id: string): boolean;
|
|
91
108
|
/** Register a handler for an inbound app event (must be in `options.inbound`). */
|
|
92
|
-
on<K extends
|
|
93
|
-
|
|
109
|
+
on<K extends EventNameOf<B>>(
|
|
110
|
+
name: K,
|
|
111
|
+
handler: (payload: EventsOf<B>[K], ws: ServerWebSocket<WsData>) => void,
|
|
112
|
+
): IgnServer<B>;
|
|
113
|
+
off<K extends EventNameOf<B>>(name: K): IgnServer<B>;
|
|
114
|
+
/**
|
|
115
|
+
* The events hub — present when `createServer({ events: {...} })` is used:
|
|
116
|
+
* typed handlers (`server.events.on`), client records, groups, and the
|
|
117
|
+
* cluster-aware emit surface.
|
|
118
|
+
*/
|
|
119
|
+
readonly events: EventsHub<B> | undefined;
|
|
94
120
|
/** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
|
|
95
121
|
drain(timeoutMs?: number): Promise<void>;
|
|
96
122
|
stop(force?: boolean): void;
|
|
97
123
|
}
|
|
98
124
|
|
|
99
|
-
export function createServer
|
|
125
|
+
export function createServer<B extends Bindings = DefaultBindings>(
|
|
126
|
+
options: IgnServerOptions<B>,
|
|
127
|
+
): IgnServer<B> {
|
|
100
128
|
const state = createServerState(options);
|
|
101
129
|
setInt64GuardMode(options.int64Guard ?? "off");
|
|
130
|
+
const bindings = state.bindings;
|
|
102
131
|
|
|
103
132
|
// NATS bridge (optional, best-effort — created eagerly, connects in the background)
|
|
104
133
|
const natsOpt = options.nats;
|
|
105
|
-
if (natsOpt)
|
|
134
|
+
if (natsOpt)
|
|
135
|
+
state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt, undefined, bindings);
|
|
106
136
|
|
|
107
137
|
// Encode once + broadcast to every connected client (NO bridge) — the shared
|
|
108
138
|
// hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
|
|
109
139
|
// events reach clients but are never re-bridged to NATS.
|
|
110
|
-
function fanOutAll(name:
|
|
111
|
-
const frame = encodeToScratch(name, payload);
|
|
140
|
+
function fanOutAll(name: string, payload: unknown): Uint8Array {
|
|
141
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
112
142
|
state.metrics.published++;
|
|
113
143
|
for (const ws of state.sockets) sendFrame(state, ws, frame);
|
|
114
144
|
return frame;
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
let eventsHub: EventsHubInternal<B> | undefined;
|
|
148
|
+
|
|
117
149
|
if (state.bridge) {
|
|
118
150
|
state.bridge.setOnInbound((name, payload) => {
|
|
119
151
|
fanOutAll(name, payload);
|
|
152
|
+
// server-side handling of externally-published events (the events layer)
|
|
153
|
+
eventsHub?.dispatchBridgeInbound(name, payload);
|
|
120
154
|
});
|
|
121
155
|
}
|
|
122
156
|
|
|
123
157
|
const bun = Bun.serve<WsData>({
|
|
124
158
|
port: options.port,
|
|
125
|
-
hostname: options.hostname,
|
|
159
|
+
...(options.hostname !== undefined ? { hostname: options.hostname } : {}),
|
|
126
160
|
idleTimeout: options.idleTimeout ?? 30,
|
|
127
|
-
tls: options.tls,
|
|
161
|
+
...(options.tls !== undefined ? { tls: options.tls } : {}),
|
|
128
162
|
fetch: (req, srv) => {
|
|
129
163
|
const url = new URL(req.url);
|
|
130
164
|
if (url.pathname === state.path) return checkUpgrade(state, req, srv);
|
|
@@ -150,13 +184,17 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
150
184
|
const existing = state.clients.get(ws.data.id);
|
|
151
185
|
if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
|
|
152
186
|
state.clients.set(ws.data.id, ws);
|
|
187
|
+
// events-layer attach (client record + presence) BEFORE group seeding
|
|
188
|
+
state.onConnect?.(ws);
|
|
153
189
|
for (const g of ws.data.groups) addToGroup(state, ws, g);
|
|
154
190
|
// announce our wire version + capabilities so clients can negotiate
|
|
155
|
-
sendControl(state, ws, "hello", { version:
|
|
191
|
+
sendControl(state, ws, "hello", { version: bindings.wireVersion, caps: [], lastSeq: 0 });
|
|
156
192
|
// then assign identity so the client knows its id + server-side groups
|
|
157
193
|
sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
|
|
158
194
|
},
|
|
159
195
|
close: (ws) => {
|
|
196
|
+
// events-layer detach FIRST (client record still carries groups/topics)
|
|
197
|
+
state.onDisconnect?.(ws);
|
|
160
198
|
state.sockets.delete(ws);
|
|
161
199
|
state.clients.delete(ws.data.id);
|
|
162
200
|
for (const g of ws.data.groups) removeFromGroup(state, ws, g);
|
|
@@ -170,7 +208,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
170
208
|
},
|
|
171
209
|
});
|
|
172
210
|
|
|
173
|
-
const api: IgnServer = {
|
|
211
|
+
const api: IgnServer<B> = {
|
|
174
212
|
get port(): number {
|
|
175
213
|
return bun.port ?? 0;
|
|
176
214
|
},
|
|
@@ -178,13 +216,16 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
178
216
|
return state.sockets.size;
|
|
179
217
|
},
|
|
180
218
|
getMetrics(): MetricsSnapshot {
|
|
181
|
-
const stats = getEncodeStats();
|
|
219
|
+
const stats = state.transport.getEncodeStats();
|
|
182
220
|
for (const [name, n] of Object.entries(stats.direct)) {
|
|
183
221
|
if (n > 0) state.metrics.countPath(name, "direct");
|
|
184
222
|
}
|
|
185
223
|
for (const [name, n] of Object.entries(stats.json)) {
|
|
186
224
|
if (n > 0) state.metrics.countPath(name, "json");
|
|
187
225
|
}
|
|
226
|
+
for (const [name, n] of Object.entries(stats.js)) {
|
|
227
|
+
if (n > 0) state.metrics.countPath(name, "js");
|
|
228
|
+
}
|
|
188
229
|
const snapshot = state.metrics.snapshot(state.sockets.size);
|
|
189
230
|
const b = state.bridge;
|
|
190
231
|
if (b) {
|
|
@@ -195,6 +236,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
195
236
|
snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
|
|
196
237
|
snapshot.natsStatus = b.status;
|
|
197
238
|
}
|
|
239
|
+
if (eventsHub) snapshot.events = eventsHub.metrics();
|
|
198
240
|
return snapshot;
|
|
199
241
|
},
|
|
200
242
|
publish(name, payload) {
|
|
@@ -203,10 +245,10 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
203
245
|
},
|
|
204
246
|
publishTo(ws, name, payload) {
|
|
205
247
|
state.metrics.published++;
|
|
206
|
-
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
248
|
+
sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
|
|
207
249
|
},
|
|
208
250
|
publishToTopic(topic, name, payload) {
|
|
209
|
-
const frame = encodeToScratch(name, payload);
|
|
251
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
210
252
|
state.metrics.published++;
|
|
211
253
|
publishToRoom(state, topic, frame);
|
|
212
254
|
state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
|
|
@@ -215,9 +257,13 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
215
257
|
const ws = state.clients.get(id);
|
|
216
258
|
if (!ws) return false;
|
|
217
259
|
state.metrics.published++;
|
|
218
|
-
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
260
|
+
sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
|
|
219
261
|
return true;
|
|
220
262
|
},
|
|
263
|
+
allowInbound(name) {
|
|
264
|
+
state.inbound.add(name);
|
|
265
|
+
return api;
|
|
266
|
+
},
|
|
221
267
|
join(topic, ws) {
|
|
222
268
|
joinRoom(state, ws, topic);
|
|
223
269
|
},
|
|
@@ -236,7 +282,7 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
236
282
|
if (ws) removeFromGroup(state, ws, group);
|
|
237
283
|
},
|
|
238
284
|
publishToGroup(group, name, payload) {
|
|
239
|
-
const frame = encodeToScratch(name, payload);
|
|
285
|
+
const frame = state.transport.encodeToScratch(name, payload);
|
|
240
286
|
state.metrics.published++;
|
|
241
287
|
publishToGroupState(state, group, frame);
|
|
242
288
|
state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
|
|
@@ -268,15 +314,22 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
268
314
|
return true;
|
|
269
315
|
},
|
|
270
316
|
on(name, handler) {
|
|
271
|
-
state.inboundHandlers.set(
|
|
317
|
+
state.inboundHandlers.set(
|
|
318
|
+
name,
|
|
319
|
+
handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void,
|
|
320
|
+
);
|
|
272
321
|
return api;
|
|
273
322
|
},
|
|
274
323
|
off(name) {
|
|
275
324
|
state.inboundHandlers.delete(name);
|
|
276
325
|
return api;
|
|
277
326
|
},
|
|
327
|
+
get events(): EventsHub<B> | undefined {
|
|
328
|
+
return eventsHub;
|
|
329
|
+
},
|
|
278
330
|
async drain(timeoutMs = 2000): Promise<void> {
|
|
279
331
|
bun.stop(false); // stop listening; keep active sockets draining
|
|
332
|
+
await eventsHub?.close();
|
|
280
333
|
await state.bridge?.close();
|
|
281
334
|
const deadline = Date.now() + timeoutMs;
|
|
282
335
|
while (state.sockets.size > 0 && Date.now() < deadline) {
|
|
@@ -286,9 +339,30 @@ export function createServer(options: IgnServerOptions): IgnServer {
|
|
|
286
339
|
},
|
|
287
340
|
stop(force = true): void {
|
|
288
341
|
bun.stop(force);
|
|
342
|
+
void eventsHub?.close();
|
|
289
343
|
void state.bridge?.close();
|
|
290
344
|
},
|
|
291
345
|
};
|
|
292
346
|
|
|
347
|
+
// ── events layer (opt-in) ─────────────────────────────────────────────
|
|
348
|
+
if (options.events) {
|
|
349
|
+
eventsHub = createEventsHub({
|
|
350
|
+
state,
|
|
351
|
+
server: api,
|
|
352
|
+
bindings,
|
|
353
|
+
...(state.bridge !== undefined ? { serverBridge: state.bridge } : {}),
|
|
354
|
+
options: options.events,
|
|
355
|
+
});
|
|
356
|
+
state.onConnect = (ws) => {
|
|
357
|
+
eventsHub!.attach(ws);
|
|
358
|
+
};
|
|
359
|
+
state.onDisconnect = (ws) => {
|
|
360
|
+
eventsHub!.detach(ws);
|
|
361
|
+
};
|
|
362
|
+
state.onGroupChange = (group, ws, joined) => {
|
|
363
|
+
eventsHub!.onGroupChange(group, ws, joined);
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
293
367
|
return api;
|
|
294
368
|
}
|
package/src/core/state.ts
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
* PUBLIC surface and are re-exported by `public/server.ts`.
|
|
8
8
|
*/
|
|
9
9
|
import type { ServerWebSocket } from "bun";
|
|
10
|
-
import
|
|
10
|
+
import { defaultBindings } from "../bindings/default";
|
|
11
|
+
import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
|
|
12
|
+
import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
13
|
+
import { createTransport, defaultTransport, type Transport } from "../transport/transport";
|
|
11
14
|
import type { Int64GuardMode } from "./int64-guard";
|
|
12
15
|
import { createMetrics, type Metrics } from "./metrics";
|
|
13
|
-
import { RingBuffer } from "./ring";
|
|
14
|
-
import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
16
|
+
import type { RingBuffer } from "./ring";
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Optional identity metadata a client may carry for targeting / grouping.
|
|
@@ -20,6 +22,12 @@ import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
|
20
22
|
export interface ClientMeta {
|
|
21
23
|
/** explicit client id; omitted → auto-assigned `crypto.randomUUID()` */
|
|
22
24
|
id?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The identity this connection acts ON BEHALF OF (e.g. the logged-in user).
|
|
27
|
+
* Several connections may share a `userId` (multi-tab / multi-device) — the
|
|
28
|
+
* events layer groups them for user-targeted emits (`hub.emitToUser`).
|
|
29
|
+
*/
|
|
30
|
+
userId?: string;
|
|
23
31
|
/** server-side groups this client belongs to on connect */
|
|
24
32
|
groups?: string[];
|
|
25
33
|
/** arbitrary app metadata (exposed via `getClient` / `getClients`) */
|
|
@@ -41,6 +49,8 @@ export interface WsData {
|
|
|
41
49
|
groups: Set<string>;
|
|
42
50
|
/** stable client id (auth metadata or auto-generated UUID) */
|
|
43
51
|
id: string;
|
|
52
|
+
/** identity this connection acts on behalf of (undefined = anonymous) */
|
|
53
|
+
userId?: string;
|
|
44
54
|
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
45
55
|
meta?: Record<string, unknown>;
|
|
46
56
|
/** epoch ms when the socket opened (for `getClients` ordering/uptime) */
|
|
@@ -61,15 +71,22 @@ export interface IgnBackpressureOptions {
|
|
|
61
71
|
maxQueue?: number;
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
export interface IgnServerOptions {
|
|
74
|
+
export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
|
|
65
75
|
port: number;
|
|
66
76
|
hostname?: string;
|
|
67
77
|
/** seconds; 0 = no timeout */
|
|
68
78
|
idleTimeout?: number;
|
|
69
79
|
/** websocket path, default "/ws" */
|
|
70
80
|
path?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The wire stack (event ids, decoders, encoders). Defaults to the built-in
|
|
83
|
+
* registry; pass your own (from `generateBindings` + `assembleBindings`) to
|
|
84
|
+
* serve YOUR schema. When provided, the server API (`publish` / `on` / ...)
|
|
85
|
+
* is typed against your `Events`.
|
|
86
|
+
*/
|
|
87
|
+
bindings?: B;
|
|
71
88
|
/** app events clients are ALLOWED to send; control events are always allowed. default [] */
|
|
72
|
-
inbound?:
|
|
89
|
+
inbound?: EventNameOf<B>[];
|
|
73
90
|
/** slow-consumer protection. default: off (unbounded buffering) */
|
|
74
91
|
backpressure?: IgnBackpressureOptions;
|
|
75
92
|
/**
|
|
@@ -117,6 +134,13 @@ export interface IgnServerOptions {
|
|
|
117
134
|
* NATS.
|
|
118
135
|
*/
|
|
119
136
|
nats?: NatsBridgeOptions | NatsBridge;
|
|
137
|
+
/**
|
|
138
|
+
* Enable the events layer (typed event handlers + the global emit, client
|
|
139
|
+
* records with per-connection data, groups, optional cluster sync). Exposed
|
|
140
|
+
* as `server.events`; the module-global `emit`/`on` singleton
|
|
141
|
+
* (`ignex-nova/events`) is bound by default.
|
|
142
|
+
*/
|
|
143
|
+
events?: import("../events/types").EventsOptions<B>;
|
|
120
144
|
/** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
|
|
121
145
|
fetch?: (req: Request) => Response | Promise<Response>;
|
|
122
146
|
}
|
|
@@ -125,8 +149,12 @@ type InboundHandler = (payload: unknown, ws: ServerWebSocket<WsData>) => void;
|
|
|
125
149
|
|
|
126
150
|
/** The full, explicit server state — created once per server, passed to actions. */
|
|
127
151
|
export interface ServerState {
|
|
152
|
+
/** the wire stack this server speaks (ids / decoders / encoders). */
|
|
153
|
+
bindings: Bindings;
|
|
154
|
+
/** per-server encoder (scratch + FFI binding or pure-JS fallback). */
|
|
155
|
+
transport: Transport;
|
|
128
156
|
path: string;
|
|
129
|
-
inbound:
|
|
157
|
+
inbound: Set<string>;
|
|
130
158
|
bp: Required<IgnBackpressureOptions> | null;
|
|
131
159
|
metrics: Metrics;
|
|
132
160
|
startedAt: number;
|
|
@@ -144,13 +172,23 @@ export interface ServerState {
|
|
|
144
172
|
groups: Map<string, Set<ServerWebSocket<WsData>>>;
|
|
145
173
|
/** optional NATS bridge (wired in createServer when `options.nats` is set) */
|
|
146
174
|
bridge?: NatsBridge;
|
|
147
|
-
inboundHandlers: Map<
|
|
175
|
+
inboundHandlers: Map<string, InboundHandler>;
|
|
148
176
|
topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
|
|
149
177
|
replaySeq: number;
|
|
178
|
+
/** events-layer lifecycle hooks (wired by createServer when `events` is set) */
|
|
179
|
+
onConnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
180
|
+
onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
181
|
+
/** fired on ANY group membership change (auth seed, control frames, programmatic) */
|
|
182
|
+
onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
|
|
150
183
|
}
|
|
151
184
|
|
|
152
|
-
export function createServerState
|
|
185
|
+
export function createServerState<B extends Bindings = DefaultBindings>(
|
|
186
|
+
options: IgnServerOptions<B>,
|
|
187
|
+
): ServerState {
|
|
188
|
+
const bindings = options.bindings ?? defaultBindings;
|
|
153
189
|
return {
|
|
190
|
+
bindings,
|
|
191
|
+
transport: bindings === defaultBindings ? defaultTransport : createTransport(bindings),
|
|
154
192
|
path: options.path ?? "/ws",
|
|
155
193
|
inbound: new Set(options.inbound ?? []),
|
|
156
194
|
bp: options.backpressure
|
|
@@ -162,11 +200,11 @@ export function createServerState(options: IgnServerOptions): ServerState {
|
|
|
162
200
|
: null,
|
|
163
201
|
metrics: createMetrics(),
|
|
164
202
|
startedAt: Date.now(),
|
|
165
|
-
authenticate: options.authenticate,
|
|
166
|
-
allowedOrigins: options.allowedOrigins,
|
|
167
|
-
token: options.token,
|
|
168
|
-
maxConnections: options.maxConnections,
|
|
169
|
-
maxMessageSize: options.maxMessageSize,
|
|
203
|
+
...(options.authenticate !== undefined ? { authenticate: options.authenticate } : {}),
|
|
204
|
+
...(options.allowedOrigins !== undefined ? { allowedOrigins: options.allowedOrigins } : {}),
|
|
205
|
+
...(options.token !== undefined ? { token: options.token } : {}),
|
|
206
|
+
...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
|
|
207
|
+
...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
|
|
170
208
|
replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
|
|
171
209
|
sockets: new Set(),
|
|
172
210
|
clients: new Map(),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client store — the live registry of active connections, "who is connected,
|
|
3
|
+
* on whose behalf, and what the app remembers about them".
|
|
4
|
+
*
|
|
5
|
+
* - `byId: Map<clientId, EventClient>` — one record per socket.
|
|
6
|
+
* - `byUser: Map<userId, Set<clientId>>` — the reverse "on what behalf" index,
|
|
7
|
+
* so a user with several tabs/devices is one logical target.
|
|
8
|
+
* - Each record carries a per-connection `data` store (created on attach,
|
|
9
|
+
* dropped on detach) — `hub.setClientData` / `client.data` read/write it.
|
|
10
|
+
*
|
|
11
|
+
* Pure local state: cross-instance presence / state sync lives in `cluster.ts`
|
|
12
|
+
* and is driven FROM this store via the `onAttach` / `onDetach` hooks (which
|
|
13
|
+
* the hub wires to offloaded queue work, keeping connect/disconnect O(1)).
|
|
14
|
+
*/
|
|
15
|
+
import type { ServerWebSocket } from "bun";
|
|
16
|
+
import type { WsData } from "../core/state";
|
|
17
|
+
import { createClientData } from "./data";
|
|
18
|
+
import type { ClientData, EventClient } from "./types";
|
|
19
|
+
|
|
20
|
+
/** Factory for a client record bound to a live socket. */
|
|
21
|
+
export function createEventClient(
|
|
22
|
+
ws: ServerWebSocket<WsData>,
|
|
23
|
+
): EventClient {
|
|
24
|
+
const client: MutableEventClient = {
|
|
25
|
+
get id(): string {
|
|
26
|
+
return ws.data.id;
|
|
27
|
+
},
|
|
28
|
+
// `userId` is the identity this connection acts on behalf of — read live
|
|
29
|
+
// from the socket data (single source of truth; `setUserId` writes there).
|
|
30
|
+
get userId(): string | undefined {
|
|
31
|
+
return ws.data.userId;
|
|
32
|
+
},
|
|
33
|
+
get meta(): Record<string, unknown> | undefined {
|
|
34
|
+
return ws.data.meta;
|
|
35
|
+
},
|
|
36
|
+
data: createClientData(),
|
|
37
|
+
get groups(): ReadonlySet<string> {
|
|
38
|
+
return ws.data.groups;
|
|
39
|
+
},
|
|
40
|
+
get topics(): ReadonlySet<string> {
|
|
41
|
+
return ws.data.topics;
|
|
42
|
+
},
|
|
43
|
+
get connectedAt(): number {
|
|
44
|
+
return ws.data.connectedAt;
|
|
45
|
+
},
|
|
46
|
+
get ip(): string {
|
|
47
|
+
return ws.remoteAddress;
|
|
48
|
+
},
|
|
49
|
+
// plain mutable property — the store flips it to true on detach
|
|
50
|
+
closed: false,
|
|
51
|
+
get ws(): ServerWebSocket<WsData> {
|
|
52
|
+
return ws;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return client;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The client record with readonly modifiers stripped (internal mutation). */
|
|
59
|
+
export type MutableEventClient = {
|
|
60
|
+
-readonly [K in keyof EventClient]: EventClient[K];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface ClientStore {
|
|
64
|
+
readonly size: number;
|
|
65
|
+
attach(ws: ServerWebSocket<WsData>): EventClient;
|
|
66
|
+
detach(ws: ServerWebSocket<WsData>): EventClient | undefined;
|
|
67
|
+
get(id: string): EventClient | undefined;
|
|
68
|
+
all(): EventClient[];
|
|
69
|
+
byUser(userId: string): EventClient[];
|
|
70
|
+
setUserId(clientId: string, userId: string): boolean;
|
|
71
|
+
onAttach(cb: (client: EventClient) => void): void;
|
|
72
|
+
onDetach(cb: (client: EventClient) => void): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createClientStore(): ClientStore {
|
|
76
|
+
const byId = new Map<string, MutableEventClient>();
|
|
77
|
+
const byUser = new Map<string, Set<string>>();
|
|
78
|
+
const attachCbs: Array<(client: EventClient) => void> = [];
|
|
79
|
+
const detachCbs: Array<(client: EventClient) => void> = [];
|
|
80
|
+
|
|
81
|
+
const indexUser = (client: EventClient): void => {
|
|
82
|
+
const userId = client.userId;
|
|
83
|
+
if (!userId) return;
|
|
84
|
+
let set = byUser.get(userId);
|
|
85
|
+
if (!set) {
|
|
86
|
+
set = new Set();
|
|
87
|
+
byUser.set(userId, set);
|
|
88
|
+
}
|
|
89
|
+
set.add(client.id);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const unindexUser = (client: EventClient): void => {
|
|
93
|
+
const userId = client.userId;
|
|
94
|
+
if (!userId) return;
|
|
95
|
+
const set = byUser.get(userId);
|
|
96
|
+
if (!set) return;
|
|
97
|
+
set.delete(client.id);
|
|
98
|
+
if (set.size === 0) byUser.delete(userId);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
get size(): number {
|
|
103
|
+
return byId.size;
|
|
104
|
+
},
|
|
105
|
+
attach(ws) {
|
|
106
|
+
const existing = byId.get(ws.data.id);
|
|
107
|
+
if (existing) return existing;
|
|
108
|
+
const client = createEventClient(ws);
|
|
109
|
+
byId.set(client.id, client);
|
|
110
|
+
indexUser(client);
|
|
111
|
+
for (const cb of attachCbs) cb(client);
|
|
112
|
+
return client;
|
|
113
|
+
},
|
|
114
|
+
detach(ws) {
|
|
115
|
+
const client = byId.get(ws.data.id);
|
|
116
|
+
if (!client) return undefined;
|
|
117
|
+
byId.delete(client.id);
|
|
118
|
+
unindexUser(client);
|
|
119
|
+
client.closed = true;
|
|
120
|
+
for (const cb of detachCbs) cb(client);
|
|
121
|
+
return client;
|
|
122
|
+
},
|
|
123
|
+
get(id) {
|
|
124
|
+
return byId.get(id);
|
|
125
|
+
},
|
|
126
|
+
all() {
|
|
127
|
+
return [...byId.values()];
|
|
128
|
+
},
|
|
129
|
+
byUser(userId) {
|
|
130
|
+
const ids = byUser.get(userId);
|
|
131
|
+
if (!ids) return [];
|
|
132
|
+
const out: EventClient[] = [];
|
|
133
|
+
for (const id of ids) {
|
|
134
|
+
const c = byId.get(id);
|
|
135
|
+
if (c) out.push(c);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
},
|
|
139
|
+
setUserId(clientId, userId) {
|
|
140
|
+
const client = byId.get(clientId);
|
|
141
|
+
if (!client) return false;
|
|
142
|
+
client.ws.data.userId = userId;
|
|
143
|
+
unindexUser(client);
|
|
144
|
+
indexUser(client);
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
onAttach(cb) {
|
|
148
|
+
attachCbs.push(cb as (client: EventClient) => void);
|
|
149
|
+
},
|
|
150
|
+
onDetach(cb) {
|
|
151
|
+
detachCbs.push(cb as (client: EventClient) => void);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type { ClientData };
|