@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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event trace — a fixed-capacity, structure-of-arrays ring that records every
|
|
3
|
+
* fired nova event (emitted, published, received) so a debugger can answer
|
|
4
|
+
* "what fired, when, where did it go, how big" without touching the hot path.
|
|
5
|
+
*
|
|
6
|
+
* Design goals (GC pressure ≈ zero):
|
|
7
|
+
* - ALL scalars live in pre-allocated TypedArrays sized once at creation
|
|
8
|
+
* (instantiation-time allocation, never per event).
|
|
9
|
+
* - Strings (event name / target key / captured payload) are stored as
|
|
10
|
+
* REFERENCES into pre-allocated slot arrays — writing a record moves no
|
|
11
|
+
* memory and allocates nothing; old strings are reclaimed naturally when
|
|
12
|
+
* the ring wraps over their slots.
|
|
13
|
+
* - Row objects are materialized ONLY on read (`recent` / `stats`) — the
|
|
14
|
+
* debugger's poll pays the allocation, the event loop never does.
|
|
15
|
+
*
|
|
16
|
+
* Recording is bounded work: a monotonic seq, six typed-array stores and a
|
|
17
|
+
* couple of reference stores (~tens of ns) — safe to leave ON in development
|
|
18
|
+
* and production alike (`IGNEX_NOVA_TRACE=0` disables it globally).
|
|
19
|
+
*/
|
|
20
|
+
import type { EmitTargetKind } from "./types";
|
|
21
|
+
|
|
22
|
+
/** Where a traced event came from / went. Encoded as one byte per record. */
|
|
23
|
+
export type TraceDirection =
|
|
24
|
+
| "out.publish" // server API publish* (fan-out to local sockets)
|
|
25
|
+
| "out.emit" // events-layer emit (targeted fan-out)
|
|
26
|
+
| "in.client" // decoded from a local socket frame
|
|
27
|
+
| "in.remote" // cluster sync frame from another instance
|
|
28
|
+
| "in.bridge"; // NATS bridge inbound
|
|
29
|
+
|
|
30
|
+
/** Numeric encoding (TypedArray storage) for {@link TraceDirection}. */
|
|
31
|
+
const DIRS = ["out.publish", "out.emit", "in.client", "in.remote", "in.bridge"] as const;
|
|
32
|
+
|
|
33
|
+
const DIR_CODES: Record<TraceDirection, number> = {
|
|
34
|
+
"out.publish": 0,
|
|
35
|
+
"out.emit": 1,
|
|
36
|
+
"in.client": 2,
|
|
37
|
+
"in.remote": 3,
|
|
38
|
+
"in.bridge": 4,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Numeric encoding for {@link EmitTargetKind} (+ none for inbound rows). */
|
|
42
|
+
const TARGET_CODES: Record<string, number> = {
|
|
43
|
+
broadcast: 0,
|
|
44
|
+
topic: 1,
|
|
45
|
+
group: 2,
|
|
46
|
+
user: 3,
|
|
47
|
+
client: 4,
|
|
48
|
+
};
|
|
49
|
+
const TARGET_NAMES = ["broadcast", "topic", "group", "user", "client"] as const;
|
|
50
|
+
|
|
51
|
+
/** Options — all resolved at creation (instantiation time). */
|
|
52
|
+
export interface EventTraceOptions {
|
|
53
|
+
/** Ring capacity in records. Default 1024 (≈ a few hundred KB total). */
|
|
54
|
+
capacity?: number;
|
|
55
|
+
/** Master switch. Default true unless `IGNEX_NOVA_TRACE=0`. */
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Capture a truncated JSON preview of the payload per record. Default 0
|
|
59
|
+
* (off) — capture costs a stringify per recorded event.
|
|
60
|
+
*/
|
|
61
|
+
capturePayloadChars?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One materialized trace row (allocated on read, never on write). */
|
|
65
|
+
export interface EventTraceRow {
|
|
66
|
+
/** monotonic sequence (ring-global ordering) */
|
|
67
|
+
seq: number;
|
|
68
|
+
/** epoch ms when the event was recorded */
|
|
69
|
+
ts: number;
|
|
70
|
+
direction: TraceDirection;
|
|
71
|
+
/** wire event name ("quote.tick", "subscribe", …) */
|
|
72
|
+
name: string;
|
|
73
|
+
/** emit target kind (absent for plain inbound rows) */
|
|
74
|
+
target?: EmitTargetKind;
|
|
75
|
+
/** topic / group / userId / clientId the event was addressed to */
|
|
76
|
+
key?: string;
|
|
77
|
+
/** encoded frame size in bytes (0 for rows without a frame) */
|
|
78
|
+
bytes: number;
|
|
79
|
+
/** truncated JSON payload preview (only when capture is enabled) */
|
|
80
|
+
payload?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Aggregate counters over the records currently retained by the ring. */
|
|
84
|
+
export interface EventTraceStats {
|
|
85
|
+
enabled: boolean;
|
|
86
|
+
capacity: number;
|
|
87
|
+
/** records retained (≤ capacity; full once the ring has wrapped) */
|
|
88
|
+
size: number;
|
|
89
|
+
/** total records written since creation (incl. overwritten ones) */
|
|
90
|
+
total: number;
|
|
91
|
+
inCount: number;
|
|
92
|
+
outCount: number;
|
|
93
|
+
bytes: number;
|
|
94
|
+
/** per-event counts over the retained window (name → n) */
|
|
95
|
+
byName: Record<string, number>;
|
|
96
|
+
/** last recorded event (name + ts) for at-a-glance panels */
|
|
97
|
+
last: { name: string; ts: number } | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Filter + limit options shared by `recent()` and server-level getters. */
|
|
101
|
+
export interface TraceQueryOptions {
|
|
102
|
+
/** max rows (default 100, capped by capacity) */
|
|
103
|
+
limit?: number;
|
|
104
|
+
/** keep only this direction */
|
|
105
|
+
direction?: TraceDirection;
|
|
106
|
+
/** keep only this wire event name */
|
|
107
|
+
name?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The trace surface owned by a server's state (created once per server). */
|
|
111
|
+
export interface EventTrace {
|
|
112
|
+
readonly enabled: boolean;
|
|
113
|
+
readonly capacity: number;
|
|
114
|
+
/** true when payload previews are being captured (call sites stringify then) */
|
|
115
|
+
readonly captures: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Record one event. Allocation-free: scalars go into TypedArrays, strings
|
|
118
|
+
* are held by reference in reused slots. `payloadText` (pre-truncated JSON)
|
|
119
|
+
* is stored only when capture is enabled.
|
|
120
|
+
*/
|
|
121
|
+
record(
|
|
122
|
+
direction: TraceDirection,
|
|
123
|
+
name: string,
|
|
124
|
+
target: EmitTargetKind | undefined,
|
|
125
|
+
key: string | undefined,
|
|
126
|
+
bytes: number,
|
|
127
|
+
payloadText?: string,
|
|
128
|
+
): void;
|
|
129
|
+
/** Materialize up to `limit` rows, newest first, optionally filtered. */
|
|
130
|
+
recent(options?: TraceQueryOptions): EventTraceRow[];
|
|
131
|
+
/** Aggregate over the retained window (computed on read). */
|
|
132
|
+
stats(): EventTraceStats;
|
|
133
|
+
/** Drop every retained record (counters survive). */
|
|
134
|
+
clear(): void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True unless explicitly disabled (`IGNEX_NOVA_TRACE=0`). */
|
|
138
|
+
export const traceEnabledDefault = (): boolean => process.env.IGNEX_NOVA_TRACE !== "0";
|
|
139
|
+
|
|
140
|
+
/** Truncate-and-stringify used by call sites when capture is enabled. */
|
|
141
|
+
export const capturePayload = (payload: unknown, maxChars: number): string => {
|
|
142
|
+
try {
|
|
143
|
+
const text = JSON.stringify(payload) ?? String(payload);
|
|
144
|
+
return text.length > maxChars ? text.slice(0, maxChars) : text;
|
|
145
|
+
} catch {
|
|
146
|
+
return "[unserializable]";
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export function createEventTrace(options: EventTraceOptions = {}): EventTrace {
|
|
151
|
+
const enabled = options.enabled ?? traceEnabledDefault();
|
|
152
|
+
const capacity = Math.max(1, options.capacity ?? 1024);
|
|
153
|
+
const captureChars = Math.max(0, options.capturePayloadChars ?? 0);
|
|
154
|
+
|
|
155
|
+
// ── pre-allocated storage (the only allocation this module ever makes) ──
|
|
156
|
+
const seqArr = new Float64Array(capacity); // seq can exceed 2^31 safely
|
|
157
|
+
const tsArr = new Float64Array(capacity);
|
|
158
|
+
const dirArr = new Uint8Array(capacity);
|
|
159
|
+
const targetArr = new Uint8Array(capacity); // 255 = none
|
|
160
|
+
const bytesArr = new Int32Array(capacity);
|
|
161
|
+
const nameSlots: Array<string | undefined> = Array.from({ length: capacity });
|
|
162
|
+
const keySlots: Array<string | undefined> = Array.from({ length: capacity });
|
|
163
|
+
const payloadSlots: Array<string | undefined> =
|
|
164
|
+
captureChars > 0 ? Array.from({ length: capacity }) : [];
|
|
165
|
+
|
|
166
|
+
let head = 0; // next slot to overwrite
|
|
167
|
+
let size = 0; // records retained (≤ capacity)
|
|
168
|
+
let totalWritten = 0;
|
|
169
|
+
let totalBytes = 0;
|
|
170
|
+
let lastSeq = 0;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
enabled,
|
|
174
|
+
capacity,
|
|
175
|
+
captures: captureChars > 0,
|
|
176
|
+
|
|
177
|
+
record(direction, name, target, key, bytes, payloadText) {
|
|
178
|
+
if (!enabled) return;
|
|
179
|
+
const i = head;
|
|
180
|
+
seqArr[i] = ++lastSeq;
|
|
181
|
+
tsArr[i] = Date.now();
|
|
182
|
+
dirArr[i] = DIR_CODES[direction];
|
|
183
|
+
targetArr[i] = target === undefined ? 255 : (TARGET_CODES[target] ?? 255);
|
|
184
|
+
bytesArr[i] = bytes;
|
|
185
|
+
nameSlots[i] = name;
|
|
186
|
+
keySlots[i] = key;
|
|
187
|
+
if (captureChars > 0 && payloadText !== undefined) payloadSlots[i] = payloadText;
|
|
188
|
+
head = i + 1 === capacity ? 0 : i + 1;
|
|
189
|
+
if (size < capacity) size++;
|
|
190
|
+
totalWritten++;
|
|
191
|
+
totalBytes += bytes;
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
recent(options = {}) {
|
|
195
|
+
if (!enabled || size === 0) return [];
|
|
196
|
+
const limit = Math.max(0, Math.min(options.limit ?? 100, size));
|
|
197
|
+
const dirFilter = options.direction !== undefined ? DIR_CODES[options.direction] : -1;
|
|
198
|
+
const nameFilter = options.name;
|
|
199
|
+
const out: EventTraceRow[] = [];
|
|
200
|
+
// walk backwards from the newest slot (head - 1), wrapping as needed
|
|
201
|
+
let i = head === 0 ? capacity - 1 : head - 1;
|
|
202
|
+
for (let seen = 0; seen < size && out.length < limit; seen++) {
|
|
203
|
+
const dir = dirArr[i] ?? 255;
|
|
204
|
+
const name = nameSlots[i];
|
|
205
|
+
if (
|
|
206
|
+
(dirFilter < 0 || dir === dirFilter) &&
|
|
207
|
+
(nameFilter === undefined || name === nameFilter)
|
|
208
|
+
) {
|
|
209
|
+
const targetCode = targetArr[i] ?? 255;
|
|
210
|
+
const row: EventTraceRow = {
|
|
211
|
+
seq: seqArr[i] ?? 0,
|
|
212
|
+
ts: tsArr[i] ?? 0,
|
|
213
|
+
direction: DIRS[dir] ?? "out.publish",
|
|
214
|
+
name: name ?? "?",
|
|
215
|
+
bytes: bytesArr[i] ?? 0,
|
|
216
|
+
};
|
|
217
|
+
if (targetCode !== 255) {
|
|
218
|
+
const t = TARGET_NAMES[targetCode];
|
|
219
|
+
if (t !== undefined) row.target = t;
|
|
220
|
+
}
|
|
221
|
+
const key = keySlots[i];
|
|
222
|
+
if (key !== undefined) row.key = key;
|
|
223
|
+
if (captureChars > 0) {
|
|
224
|
+
const p = payloadSlots[i];
|
|
225
|
+
if (p !== undefined) row.payload = p;
|
|
226
|
+
}
|
|
227
|
+
out.push(row);
|
|
228
|
+
}
|
|
229
|
+
i = i === 0 ? capacity - 1 : i - 1;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
stats() {
|
|
235
|
+
const byName: Record<string, number> = {};
|
|
236
|
+
let inCount = 0;
|
|
237
|
+
let outCount = 0;
|
|
238
|
+
let windowBytes = 0;
|
|
239
|
+
let lastName: string | undefined;
|
|
240
|
+
let lastTs = 0;
|
|
241
|
+
let newestSeq = -1;
|
|
242
|
+
if (enabled) {
|
|
243
|
+
let i = head === 0 ? capacity - 1 : head - 1;
|
|
244
|
+
for (let seen = 0; seen < size; seen++) {
|
|
245
|
+
const name = nameSlots[i];
|
|
246
|
+
const seq = seqArr[i] ?? 0;
|
|
247
|
+
if (name !== undefined) {
|
|
248
|
+
byName[name] = (byName[name] ?? 0) + 1;
|
|
249
|
+
if (seq > newestSeq) {
|
|
250
|
+
newestSeq = seq;
|
|
251
|
+
lastName = name;
|
|
252
|
+
lastTs = tsArr[i] ?? 0;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const dir = dirArr[i] ?? 255;
|
|
256
|
+
if (dir <= 1) outCount++;
|
|
257
|
+
else inCount++;
|
|
258
|
+
windowBytes += bytesArr[i] ?? 0;
|
|
259
|
+
i = i === 0 ? capacity - 1 : i - 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
enabled,
|
|
264
|
+
capacity,
|
|
265
|
+
size: enabled ? size : 0,
|
|
266
|
+
total: totalWritten,
|
|
267
|
+
inCount,
|
|
268
|
+
outCount,
|
|
269
|
+
bytes: windowBytes,
|
|
270
|
+
byName,
|
|
271
|
+
...(lastName !== undefined ? { last: { name: lastName, ts: lastTs } } : { last: null }),
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
clear() {
|
|
276
|
+
head = 0;
|
|
277
|
+
size = 0;
|
|
278
|
+
nameSlots.fill(undefined);
|
|
279
|
+
keySlots.fill(undefined);
|
|
280
|
+
if (captureChars > 0) payloadSlots.fill(undefined);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client record types — the server-side representation of a connected peer.
|
|
3
|
+
*
|
|
4
|
+
* Type-only module (part of the `src/events/types` barrel). The runtime lives
|
|
5
|
+
* in `src/events/clients.ts` / `src/events/data.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerWebSocket } from "bun";
|
|
8
|
+
import type { WsData } from "../../core/state";
|
|
9
|
+
|
|
10
|
+
/** Per-connection state store attached to an active client record. */
|
|
11
|
+
export interface ClientData {
|
|
12
|
+
/** Read a value previously `set` on this connection. */
|
|
13
|
+
get(key: string): unknown;
|
|
14
|
+
/** Store a value on this connection (arbitrary app state, per socket). */
|
|
15
|
+
set(key: string, value: unknown): void;
|
|
16
|
+
has(key: string): boolean;
|
|
17
|
+
delete(key: string): boolean;
|
|
18
|
+
clear(): void;
|
|
19
|
+
keys(): string[];
|
|
20
|
+
entries(): Array<[string, unknown]>;
|
|
21
|
+
toJSON(): Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An active user connection — the server-side representation of "who is
|
|
26
|
+
* connected, on whose behalf, and what to remember about them".
|
|
27
|
+
*
|
|
28
|
+
* - `id` is the connection id (the socket identity, unique per connection).
|
|
29
|
+
* - `userId` is the identity this connection acts ON BEHALF OF (set via the
|
|
30
|
+
* `authenticate` hook, `hub.setUserId`, or later); several connections may
|
|
31
|
+
* share a `userId` (multi-tab / multi-device), and `hub.clientsByUser`
|
|
32
|
+
* groups them.
|
|
33
|
+
* - `data` is the per-connection app store, cleared automatically on close.
|
|
34
|
+
* - `groups` / `topics` are shared with the transport (`ws.data`), so control
|
|
35
|
+
* frames (`joinGroup` / `subscribe`) stay consistent with the events layer.
|
|
36
|
+
*/
|
|
37
|
+
export interface EventClient {
|
|
38
|
+
/** stable connection id (ws identity, unique per socket) */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/** identity this connection acts on behalf of (undefined = anonymous) */
|
|
41
|
+
readonly userId: string | undefined;
|
|
42
|
+
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
43
|
+
readonly meta: Record<string, unknown> | undefined;
|
|
44
|
+
/** per-connection app state store (auto-cleared on disconnect) */
|
|
45
|
+
readonly data: ClientData;
|
|
46
|
+
/** server-side client groups this connection belongs to */
|
|
47
|
+
readonly groups: ReadonlySet<string>;
|
|
48
|
+
/** topics/rooms this connection has joined */
|
|
49
|
+
readonly topics: ReadonlySet<string>;
|
|
50
|
+
/** epoch ms the socket connected */
|
|
51
|
+
readonly connectedAt: number;
|
|
52
|
+
/** remote IP (from the socket) */
|
|
53
|
+
readonly ip: string;
|
|
54
|
+
/** true after the socket closed (record is then detached) */
|
|
55
|
+
readonly closed: boolean;
|
|
56
|
+
/** the underlying socket (advanced / low-level use) */
|
|
57
|
+
readonly ws: ServerWebSocket<WsData>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A connection known to exist on ANOTHER instance (via cluster presence). */
|
|
61
|
+
export interface RemoteClient {
|
|
62
|
+
clientId: string;
|
|
63
|
+
/** the instance that reported this connection */
|
|
64
|
+
instanceId: string;
|
|
65
|
+
userId?: string;
|
|
66
|
+
/** epoch ms the connection was (re)confirmed by its instance */
|
|
67
|
+
lastSeen: number;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster sync port types — the broker transport and shared-state store
|
|
3
|
+
* contracts used by `src/events/cluster/`.
|
|
4
|
+
*
|
|
5
|
+
* Type-only module (part of the `src/events/types` barrel).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cross-instance messaging transport (server ⇄ server). NATS and Redis
|
|
10
|
+
* adapters are provided; any broker that supports named channels + byte
|
|
11
|
+
* payloads can be plugged in (tests use an in-memory bus). All calls are
|
|
12
|
+
* fire-and-forget and are invoked from the offload queue, never from the WS
|
|
13
|
+
* hot path.
|
|
14
|
+
*/
|
|
15
|
+
export interface ClusterTransport {
|
|
16
|
+
readonly connected: boolean;
|
|
17
|
+
/** synchronously hand bytes to the broker (throws → caller counts an error) */
|
|
18
|
+
publish(subject: string, data: Uint8Array): void;
|
|
19
|
+
/** subscribe; `cb` receives raw message bytes; returns an unsubscribe fn */
|
|
20
|
+
subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Optional shared-state store (presence / cluster group membership / client
|
|
26
|
+
* data). A memory implementation is used by default (per-instance); Redis is
|
|
27
|
+
* the production choice for horizontally scaled deployments (`createRedisStateStore`).
|
|
28
|
+
*/
|
|
29
|
+
export interface ClusterStateStore {
|
|
30
|
+
get(key: string): Promise<string | null>;
|
|
31
|
+
set(key: string, value: string, ttlMs?: number): Promise<void>;
|
|
32
|
+
del(key: string): Promise<void>;
|
|
33
|
+
sadd(key: string, member: string): Promise<void>;
|
|
34
|
+
srem(key: string, member: string): Promise<void>;
|
|
35
|
+
smembers(key: string): Promise<string[]>;
|
|
36
|
+
expire(key: string, ttlMs: number): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Redis connection options — a URL string or an ioredis options object. */
|
|
40
|
+
export type RedisConnectionOptions = string | Record<string, unknown>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event context + handler types — what every hub handler receives.
|
|
3
|
+
*
|
|
4
|
+
* Type-only module (part of the `src/events/types` barrel). Contexts are
|
|
5
|
+
* built (and cached) by `src/events/hub/context-factory.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../../bindings/types";
|
|
8
|
+
import type { IgnServer } from "../../core/server";
|
|
9
|
+
import type { EmitTarget } from "./emit-target";
|
|
10
|
+
import type { EventClient } from "./client";
|
|
11
|
+
import type { EventsHub } from "./hub";
|
|
12
|
+
|
|
13
|
+
/** Where an event reached the hub from. */
|
|
14
|
+
export type EventSource = "client" | "remote" | "bridge";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The context every handler receives — the "who / where / how do I reply"
|
|
18
|
+
* bundle. For client-sent events `client` is the sender's record; for
|
|
19
|
+
* server-side events (`onServerEvent`) there is no sender client.
|
|
20
|
+
*/
|
|
21
|
+
export interface EventContext<B extends Bindings = DefaultBindings> {
|
|
22
|
+
/** where the event came from: a local client, another instance, or the bridge */
|
|
23
|
+
readonly source: EventSource;
|
|
24
|
+
/** the client that sent the event (undefined for remote/bridge events) */
|
|
25
|
+
readonly client?: EventClient;
|
|
26
|
+
/** cross-instance trace id (remote/bridge events; undefined when absent) */
|
|
27
|
+
readonly traceId?: string;
|
|
28
|
+
/** the events hub (for `hub.emit`, groups, client data, …) */
|
|
29
|
+
readonly hub: EventsHub<B>;
|
|
30
|
+
/** the underlying server (raw `publish`/`publishToClient`/… escape hatch) */
|
|
31
|
+
readonly server: IgnServer<B>;
|
|
32
|
+
/** emit helpers bound to this hub (reply without importing the singleton) */
|
|
33
|
+
emit<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K], target?: EmitTarget): void;
|
|
34
|
+
emitToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
|
|
35
|
+
emitToUser<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
36
|
+
emitToClient<K extends EventNameOf<B>>(clientId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
37
|
+
emitToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A handler registered on the hub (client-sent events). May be async. */
|
|
41
|
+
export type EventHandler<B extends Bindings, K extends EventNameOf<B>> = (
|
|
42
|
+
payload: EventsOf<B>[K],
|
|
43
|
+
ctx: EventContext<B>,
|
|
44
|
+
) => void | Promise<void>;
|
|
45
|
+
|
|
46
|
+
/** A handler for server-side events (remote instances / bridge inbound). */
|
|
47
|
+
export type ServerEventHandler<B extends Bindings, K extends EventNameOf<B>> = (
|
|
48
|
+
payload: EventsOf<B>[K],
|
|
49
|
+
ctx: EventContext<B>,
|
|
50
|
+
) => void | Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit target — the discriminated union that addresses an emit.
|
|
3
|
+
*
|
|
4
|
+
* Type-only module (part of the `src/events/types` barrel). The delivery
|
|
5
|
+
* runtime lives in `src/events/emit.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where an emit goes. The discriminated union is how the API "easily
|
|
10
|
+
* differentiates" between the addressing modes:
|
|
11
|
+
*
|
|
12
|
+
* - `{ type: "broadcast" }` — every connected client, on every instance.
|
|
13
|
+
* - `{ type: "topic", topic }` — subscribers of a topic (rooms + replay).
|
|
14
|
+
* - `{ type: "group", group }` — members of a server-side group.
|
|
15
|
+
* - `{ type: "user", userId }` — every socket acting on behalf of `userId`.
|
|
16
|
+
* - `{ type: "client", clientId }` — one specific connection.
|
|
17
|
+
*
|
|
18
|
+
* Local delivery is synchronous and allocation-free (the transport scratch +
|
|
19
|
+
* `ws.send` copy); the cross-instance fan-out (when a cluster is configured)
|
|
20
|
+
* is deferred to the offload queue so the emit call never blocks.
|
|
21
|
+
*/
|
|
22
|
+
export type EmitTarget =
|
|
23
|
+
| { type: "broadcast" }
|
|
24
|
+
| { type: "topic"; topic: string }
|
|
25
|
+
| { type: "group"; group: string }
|
|
26
|
+
| { type: "user"; userId: string }
|
|
27
|
+
| { type: "client"; clientId: string };
|
|
28
|
+
|
|
29
|
+
export type EmitTargetKind = EmitTarget["type"];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group handle types — client groups (by connection id) and user groups
|
|
3
|
+
* (by userId).
|
|
4
|
+
*
|
|
5
|
+
* Type-only module (part of the `src/events/types` barrel). The runtime lives
|
|
6
|
+
* in `src/events/groups.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../../bindings/types";
|
|
9
|
+
|
|
10
|
+
/** A named client group: membership by connection id, fan-out via the hub. */
|
|
11
|
+
export interface ClientGroup<B extends Bindings = DefaultBindings> {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** add a connection (by id) to the group (idempotent) */
|
|
14
|
+
add(clientId: string): void;
|
|
15
|
+
remove(clientId: string): void;
|
|
16
|
+
has(clientId: string): boolean;
|
|
17
|
+
/** member connection ids */
|
|
18
|
+
members(): string[];
|
|
19
|
+
readonly size: number;
|
|
20
|
+
/** emit an event to every member of this group (cluster-aware) */
|
|
21
|
+
emit<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A named USER group: membership by `userId`, fan-out to every socket of each member user. */
|
|
25
|
+
export interface UserGroup<B extends Bindings = DefaultBindings> {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
add(userId: string): void;
|
|
28
|
+
remove(userId: string): void;
|
|
29
|
+
has(userId: string): boolean;
|
|
30
|
+
/** member user ids */
|
|
31
|
+
members(): string[];
|
|
32
|
+
readonly size: number;
|
|
33
|
+
/** emit an event to every socket acting on behalf of each member user */
|
|
34
|
+
emit<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The events hub — the public API surface type.
|
|
3
|
+
*
|
|
4
|
+
* Type-only module (part of the `src/events/types` barrel). The runtime
|
|
5
|
+
* composition root is `src/events/hub/index.ts` (`createEventsHub`).
|
|
6
|
+
*/
|
|
7
|
+
import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../../bindings/types";
|
|
8
|
+
import type { IgnServer } from "../../core/server";
|
|
9
|
+
import type { EventContext, EventHandler, ServerEventHandler } from "./context";
|
|
10
|
+
import type { ClientGroup, UserGroup } from "./groups";
|
|
11
|
+
import type { EmitTarget } from "./emit-target";
|
|
12
|
+
import type { EventClient, RemoteClient } from "./client";
|
|
13
|
+
import type { EventsMetricsSnapshot } from "./metrics";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The events hub — the public API returned as `server.events` when
|
|
17
|
+
* `createServer({ events: {...} })` is used, and the backing store for the
|
|
18
|
+
* module-global `emit` / `on` singleton (`ignex-nova/events`).
|
|
19
|
+
*/
|
|
20
|
+
export interface EventsHub<B extends Bindings = DefaultBindings> {
|
|
21
|
+
readonly server: IgnServer<B>;
|
|
22
|
+
/** stable id of THIS instance (self-delivery dedupe in a cluster) */
|
|
23
|
+
readonly instanceId: string;
|
|
24
|
+
|
|
25
|
+
// ── receiving events (the "events file": where events come in) ────────
|
|
26
|
+
on<K extends EventNameOf<B>>(name: K, handler: EventHandler<B, K>): EventsHub<B>;
|
|
27
|
+
off<K extends EventNameOf<B>>(name: K, handler?: EventHandler<B, K>): EventsHub<B>;
|
|
28
|
+
once<K extends EventNameOf<B>>(name: K, handler: EventHandler<B, K>): EventsHub<B>;
|
|
29
|
+
/** every client-sent inbound event (name + payload + ctx) */
|
|
30
|
+
onAny(cb: (name: EventNameOf<B>, payload: unknown, ctx: EventContext<B>) => void): EventsHub<B>;
|
|
31
|
+
offAny(cb: (name: EventNameOf<B>, payload: unknown, ctx: EventContext<B>) => void): EventsHub<B>;
|
|
32
|
+
/** server-side handlers for events from OTHER instances / the bridge */
|
|
33
|
+
onServerEvent<K extends EventNameOf<B>>(name: K, handler: ServerEventHandler<B, K>): EventsHub<B>;
|
|
34
|
+
offServerEvent<K extends EventNameOf<B>>(
|
|
35
|
+
name: K,
|
|
36
|
+
handler?: ServerEventHandler<B, K>,
|
|
37
|
+
): EventsHub<B>;
|
|
38
|
+
/** event names with at least one handler */
|
|
39
|
+
events(): EventNameOf<B>[];
|
|
40
|
+
listenerCount(name: EventNameOf<B>): number;
|
|
41
|
+
removeAllListeners(name?: EventNameOf<B>): EventsHub<B>;
|
|
42
|
+
|
|
43
|
+
// ── emitting events (through websockets, cluster-aware) ───────────────
|
|
44
|
+
emit<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K], target?: EmitTarget): void;
|
|
45
|
+
emitToTopic<K extends EventNameOf<B>>(topic: string, name: K, payload: EventsOf<B>[K]): void;
|
|
46
|
+
emitToGroup<K extends EventNameOf<B>>(group: string, name: K, payload: EventsOf<B>[K]): void;
|
|
47
|
+
emitToUser<K extends EventNameOf<B>>(userId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
48
|
+
emitToClient<K extends EventNameOf<B>>(clientId: string, name: K, payload: EventsOf<B>[K]): void;
|
|
49
|
+
|
|
50
|
+
// ── client records ("who is connected, on whose behalf") ──────────────
|
|
51
|
+
client(id: string): EventClient | undefined;
|
|
52
|
+
clients(): EventClient[];
|
|
53
|
+
/** every connection acting on behalf of `userId` */
|
|
54
|
+
clientsByUser(userId: string): EventClient[];
|
|
55
|
+
readonly clientCount: number;
|
|
56
|
+
/** bind a connection to an identity (on whose behalf it acts) */
|
|
57
|
+
setUserId(clientId: string, userId: string): void;
|
|
58
|
+
/** per-connection app state */
|
|
59
|
+
setClientData(clientId: string, key: string, value: unknown): void;
|
|
60
|
+
getClientData(clientId: string, key: string): unknown;
|
|
61
|
+
clearClientData(clientId: string): void;
|
|
62
|
+
|
|
63
|
+
// ── groups ────────────────────────────────────────────────────────────
|
|
64
|
+
/** client group handle (membership by connection id) */
|
|
65
|
+
group(name: string): ClientGroup<B>;
|
|
66
|
+
/** live client-group names */
|
|
67
|
+
groups(): string[];
|
|
68
|
+
/** user group handle (membership by userId, fan-out to every socket) */
|
|
69
|
+
userGroup(name: string): UserGroup<B>;
|
|
70
|
+
/** live user-group names */
|
|
71
|
+
userGroups(): string[];
|
|
72
|
+
|
|
73
|
+
// ── horizontal scaling ────────────────────────────────────────────────
|
|
74
|
+
/** connections known on other instances (presence; [] when unclustered) */
|
|
75
|
+
clusterClients(): RemoteClient[];
|
|
76
|
+
/** other instance ids heard from recently (presence; [] when unclustered) */
|
|
77
|
+
clusterInstances(): string[];
|
|
78
|
+
/** user→clients index from the shared state store, if configured */
|
|
79
|
+
clusterUserClients(userId: string): Promise<Array<{ instanceId: string; clientId: string }>>;
|
|
80
|
+
/** cluster-wide client-group members (shared state store), if configured */
|
|
81
|
+
clusterGroupMembers(group: string): Promise<string[]>;
|
|
82
|
+
/** cluster-wide user-group members (shared state store), if configured */
|
|
83
|
+
clusterUserGroupMembers(group: string): Promise<string[]>;
|
|
84
|
+
/** client data from the shared state store, if configured */
|
|
85
|
+
remoteClientData(clientId: string): Promise<Record<string, unknown> | undefined>;
|
|
86
|
+
|
|
87
|
+
// ── cross-instance rpc (server ⇄ server over the cluster transport) ──
|
|
88
|
+
/**
|
|
89
|
+
* Call a method on another instance (or any instance when `opts.instanceId`
|
|
90
|
+
* is omitted — first responder wins). Requires a configured cluster.
|
|
91
|
+
*/
|
|
92
|
+
call(method: string, args?: unknown, opts?: { readonly instanceId?: string; readonly timeoutMs?: number }): Promise<unknown>;
|
|
93
|
+
/** register a cross-instance rpc method handler */
|
|
94
|
+
onMethod(method: string, handler: (args: unknown, fromInstanceId: string) => unknown | Promise<unknown>): EventsHub<B>;
|
|
95
|
+
/** request/response for CLIENT-sent events (WS-level, `client.request`) */
|
|
96
|
+
onRequest<K extends EventNameOf<B>>(
|
|
97
|
+
name: K,
|
|
98
|
+
responder: (payload: EventsOf<B>[K], ctx: EventContext<B>) => EventsOf<B>[K] | Promise<EventsOf<B>[K]>,
|
|
99
|
+
): EventsHub<B>;
|
|
100
|
+
|
|
101
|
+
// ── scheduled emits (time-based events) ───────────────────────────────
|
|
102
|
+
/** emit `name` after `delayMs`; returns an id usable with {@link cancelScheduled} */
|
|
103
|
+
schedule<K extends EventNameOf<B>>(
|
|
104
|
+
name: K,
|
|
105
|
+
payload: EventsOf<B>[K],
|
|
106
|
+
target: EmitTarget | undefined,
|
|
107
|
+
delayMs: number,
|
|
108
|
+
): string;
|
|
109
|
+
/** cancel a scheduled emit — true when it had not fired yet */
|
|
110
|
+
cancelScheduled(id: string): boolean;
|
|
111
|
+
/** emits currently scheduled */
|
|
112
|
+
readonly scheduledCount: number;
|
|
113
|
+
|
|
114
|
+
// ── lifecycle / observability ─────────────────────────────────────────
|
|
115
|
+
metrics(): EventsMetricsSnapshot;
|
|
116
|
+
queueStats(): {
|
|
117
|
+
pending: number;
|
|
118
|
+
queued: number;
|
|
119
|
+
processed: number;
|
|
120
|
+
dropped: number;
|
|
121
|
+
errors: number;
|
|
122
|
+
};
|
|
123
|
+
close(): Promise<void>;
|
|
124
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events layer types — barrel. One folder per concern:
|
|
3
|
+
*
|
|
4
|
+
* client — EventClient / ClientData / RemoteClient records
|
|
5
|
+
* emit-target — EmitTarget addressing union
|
|
6
|
+
* context — EventContext + handler signatures
|
|
7
|
+
* groups — ClientGroup / UserGroup handles
|
|
8
|
+
* cluster — ClusterTransport / ClusterStateStore ports
|
|
9
|
+
* metrics — EventsMetricsSnapshot
|
|
10
|
+
* hub — the public EventsHub API
|
|
11
|
+
* options — EventsOptions / EventsClusterOptions inputs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type { ClientData, EventClient, RemoteClient } from "./client";
|
|
15
|
+
export type { EmitTarget, EmitTargetKind } from "./emit-target";
|
|
16
|
+
export type {
|
|
17
|
+
EventContext,
|
|
18
|
+
EventSource,
|
|
19
|
+
EventHandler,
|
|
20
|
+
ServerEventHandler,
|
|
21
|
+
} from "./context";
|
|
22
|
+
export type { ClientGroup, UserGroup } from "./groups";
|
|
23
|
+
export type {
|
|
24
|
+
ClusterStateStore,
|
|
25
|
+
ClusterTransport,
|
|
26
|
+
RedisConnectionOptions,
|
|
27
|
+
} from "./cluster";
|
|
28
|
+
export type { EventsMetricsSnapshot } from "./metrics";
|
|
29
|
+
export type { EventsHub } from "./hub";
|
|
30
|
+
export type { EventsClusterOptions, EventsOptions } from "./options";
|