@ignex/nova 0.1.1 → 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 +136 -33
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +292 -0
- package/docs/architecture.md +101 -28
- package/docs/events.md +252 -0
- package/docs/generic-bindings.md +207 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +74 -20
- package/index.ts +75 -27
- package/package.json +13 -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 +510 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +52 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +377 -17
- 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/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/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +28 -0
- package/src/codegen/direct-gen.ts +564 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +246 -0
- package/src/codegen/rust-glue-gen.ts +552 -0
- package/src/codegen/schema-model.ts +363 -0
- package/src/codegen/ts-ser-gen.ts +230 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +67 -5
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +63 -8
- package/src/core/client-wire.ts +148 -15
- package/src/core/client.ts +105 -31
- package/src/core/groups.ts +8 -0
- package/src/core/metrics.ts +42 -21
- package/src/core/outbound.ts +62 -11
- 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 +144 -12
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/server/index.ts +360 -0
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +124 -14
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +174 -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/data.ts +38 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +173 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- 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/index.ts +61 -0
- package/src/events/queue.ts +123 -0
- package/src/events/registry.ts +214 -0
- 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 +148 -60
- package/src/generated/fbs/backend.fbs +24 -1
- package/src/generated/registry.ts +94 -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 +110 -17
- package/src/generated/wire-registry.json +7 -2
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +49 -2
- package/src/server.ts +7 -3
- package/src/transport/transport.ts +200 -79
- package/src/bridge/nats.ts +0 -269
- package/src/core/server.ts +0 -294
- package/src/transport/stats.ts +0 -44
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events hub — composition root for the events layer. Created by
|
|
3
|
+
* `createServer({ events: {...} })` (exposed as `server.events`) and, by
|
|
4
|
+
* default, bound to the module-global `emit` / `on` singleton.
|
|
5
|
+
*
|
|
6
|
+
* Wires the pieces together (each lives in its own module):
|
|
7
|
+
* - `clients` (`../clients`) — the active-connection registry (who is
|
|
8
|
+
* connected, on whose behalf, per-connection data);
|
|
9
|
+
* - `registry` (`../registry`) — multi-handler dispatch;
|
|
10
|
+
* - `dispatcher` (`./dispatch`) — reliability-aware dispatch (retry + DLQ
|
|
11
|
+
* when configured, plain fire-and-forget otherwise);
|
|
12
|
+
* - `emitter` (`../emit`) — encode-once + local fan-out + bridge +
|
|
13
|
+
* offloaded cluster publish;
|
|
14
|
+
* - `groups` (`../groups`) — client groups and user groups;
|
|
15
|
+
* - `cluster` (`../cluster`) — optional cross-instance sync with presence,
|
|
16
|
+
* all off the hot path via the offload queue;
|
|
17
|
+
* - `contexts` (`./context-factory`) — cached handler contexts;
|
|
18
|
+
* - `scheduler` (`../schedule`) — time-based emits;
|
|
19
|
+
* - `clusterRpc` (`../cluster-rpc`) — cross-instance request/response.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle is driven by the server: `attach`/`detach` on socket open/close,
|
|
22
|
+
* `onGroupChange` on any group mutation (auth seed, control frames,
|
|
23
|
+
* programmatic), `dispatchBridgeInbound` for NATS-inbound events.
|
|
24
|
+
*/
|
|
25
|
+
import type { ServerWebSocket } from "bun";
|
|
26
|
+
import type { Bindings, DefaultBindings, EventNameOf } from "../../bindings/types";
|
|
27
|
+
import type { NatsBridge } from "../../bridge/nats";
|
|
28
|
+
import type { IgnServer } from "../../core/server";
|
|
29
|
+
import type { WsData } from "../../core/state";
|
|
30
|
+
import { createClientStore } from "../clients";
|
|
31
|
+
import {
|
|
32
|
+
createClusterSync,
|
|
33
|
+
createMemoryStateStore,
|
|
34
|
+
type ClusterSync,
|
|
35
|
+
} from "../cluster";
|
|
36
|
+
import { createClusterRpc, type ClusterRpc } from "../cluster-rpc";
|
|
37
|
+
import { createEmitter, deliverLocal, type EmitCounters } from "../emit";
|
|
38
|
+
import { bindEvents, unbindEvents } from "../global";
|
|
39
|
+
import { createGroupManager, type GroupManager } from "../groups";
|
|
40
|
+
import { createTaskQueue } from "../queue";
|
|
41
|
+
import { createHandlerRegistry, type HandlerRegistry } from "../registry";
|
|
42
|
+
import { createScheduler, type Scheduler } from "../schedule";
|
|
43
|
+
import type {
|
|
44
|
+
ClusterStateStore,
|
|
45
|
+
ClusterTransport,
|
|
46
|
+
EmitTarget,
|
|
47
|
+
EventClient,
|
|
48
|
+
EventContext,
|
|
49
|
+
EventsOptions,
|
|
50
|
+
} from "../types";
|
|
51
|
+
import { createContextFactory, type ContextFactory } from "./context-factory";
|
|
52
|
+
import { createDispatcher, resolveDispatchPolicy, type Dispatcher } from "./dispatch";
|
|
53
|
+
import type { CreateEventsHubOptions, EventsHubInternal } from "./internal";
|
|
54
|
+
import { buildMetricsSnapshot } from "./metrics-snapshot";
|
|
55
|
+
import { resolveClusterTransport } from "./resolve-cluster";
|
|
56
|
+
|
|
57
|
+
export type { CreateEventsHubOptions, EventsHubInternal } from "./internal";
|
|
58
|
+
|
|
59
|
+
/** Fresh zeroed emit counters (one per hub). */
|
|
60
|
+
const zeroCounters = (): EmitCounters => ({
|
|
61
|
+
emitted: 0,
|
|
62
|
+
emittedByTarget: { broadcast: 0, topic: 0, group: 0, user: 0, client: 0 },
|
|
63
|
+
deliveredLocal: 0,
|
|
64
|
+
clusterPublished: 0,
|
|
65
|
+
clusterRouted: 0,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Queue options come in sparse — normalize to the factory's full form. */
|
|
69
|
+
const resolveQueueOpts = (
|
|
70
|
+
queue: EventsOptions<never>["queue"],
|
|
71
|
+
): { workers?: number; maxPending?: number } => ({
|
|
72
|
+
...(queue?.workers !== undefined ? { workers: queue.workers } : {}),
|
|
73
|
+
...(queue?.maxPending !== undefined ? { maxPending: queue.maxPending } : {}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** `{ id, userId? }` projection used for cluster presence announcements. */
|
|
77
|
+
const clientIdentity = (c: EventClient): { id: string; userId?: string } => ({
|
|
78
|
+
id: c.id,
|
|
79
|
+
...(c.userId !== undefined ? { userId: c.userId } : {}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Map a remote envelope kind/key pair onto a local emit target. */
|
|
83
|
+
const targetFromRemote = (kind: string, key: string): EmitTarget => {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case "broadcast":
|
|
86
|
+
return { type: "broadcast" };
|
|
87
|
+
case "topic":
|
|
88
|
+
return { type: "topic", topic: key };
|
|
89
|
+
case "group":
|
|
90
|
+
return { type: "group", group: key };
|
|
91
|
+
case "user":
|
|
92
|
+
return { type: "user", userId: key };
|
|
93
|
+
default:
|
|
94
|
+
return { type: "client", clientId: key };
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ROUTED targeted delivery helper: for client/user targets return the owning
|
|
100
|
+
* instance ids via presence ([] → null so callers fall back to full mesh).
|
|
101
|
+
*/
|
|
102
|
+
const makeRouteInstances =
|
|
103
|
+
(cluster: ClusterSync) =>
|
|
104
|
+
(target: EmitTarget): readonly string[] | null => {
|
|
105
|
+
const owned = (instances: string[]): readonly string[] | null =>
|
|
106
|
+
instances.length > 0 ? instances : null;
|
|
107
|
+
if (target.type === "client") return owned(cluster.instancesForClient(target.clientId));
|
|
108
|
+
if (target.type === "user") return owned(cluster.instancesForUser(target.userId));
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function createEventsHub<B extends Bindings = DefaultBindings>(
|
|
113
|
+
opts: CreateEventsHubOptions<B>,
|
|
114
|
+
): EventsHubInternal<B> {
|
|
115
|
+
const { state, server, bindings, serverBridge } = opts;
|
|
116
|
+
const eOpts = opts.options;
|
|
117
|
+
|
|
118
|
+
// ── identity + infrastructure ─────────────────────────────────────────
|
|
119
|
+
const instanceId = eOpts.cluster?.instanceId ?? crypto.randomUUID();
|
|
120
|
+
const prefix = eOpts.cluster?.prefix ?? bindings.subjectPrefix ?? "ignex";
|
|
121
|
+
const queue = createTaskQueue(resolveQueueOpts(eOpts.queue));
|
|
122
|
+
const counters: EmitCounters = zeroCounters();
|
|
123
|
+
|
|
124
|
+
let closed = false;
|
|
125
|
+
let boundGlobal = false;
|
|
126
|
+
let transportErrors = 0;
|
|
127
|
+
|
|
128
|
+
const reportClusterError = (err: Error): void => {
|
|
129
|
+
transportErrors++;
|
|
130
|
+
void err;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ── handler registry + reliable dispatch ──────────────────────────────
|
|
134
|
+
const registry: HandlerRegistry & { settleDispatch: HandlerRegistry["settleDispatch"] } =
|
|
135
|
+
createHandlerRegistry();
|
|
136
|
+
// reliability counters (retries + dead letters), folded into metrics
|
|
137
|
+
const dispatchCounters = { handlerRetries: 0, dlqCount: 0 };
|
|
138
|
+
const dispatcher: Dispatcher<B> = createDispatcher<B>({
|
|
139
|
+
registry,
|
|
140
|
+
policy: resolveDispatchPolicy(eOpts.handlers, dispatchCounters),
|
|
141
|
+
counters: dispatchCounters,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── client store ──────────────────────────────────────────────────────
|
|
145
|
+
const clients = createClientStore();
|
|
146
|
+
/** Callback-style user targeting — no intermediate array per emit (zero alloc). */
|
|
147
|
+
const eachUserSocket = (
|
|
148
|
+
userId: string,
|
|
149
|
+
each: (ws: ServerWebSocket<WsData>) => void,
|
|
150
|
+
): number =>
|
|
151
|
+
clients.forEachByUser(userId, (client) => {
|
|
152
|
+
each(client.ws);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── contexts ──────────────────────────────────────────────────────────
|
|
156
|
+
const contexts: ContextFactory<B> = createContextFactory<B>({
|
|
157
|
+
server,
|
|
158
|
+
getHub: () => api,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── inbound dispatch (client-sent events → handlers) ──────────────────
|
|
162
|
+
const dispatchers = new Set<string>();
|
|
163
|
+
|
|
164
|
+
const dispatchClientEvent = (
|
|
165
|
+
name: string,
|
|
166
|
+
payload: unknown,
|
|
167
|
+
ws: ServerWebSocket<WsData>,
|
|
168
|
+
): void => {
|
|
169
|
+
const client = clients.get(ws.data.id) ?? attach(ws);
|
|
170
|
+
dispatcher.client(name, payload, contexts.make(client, "client"));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const ensureDispatcher = (name: string): void => {
|
|
174
|
+
if (dispatchers.has(name)) return;
|
|
175
|
+
dispatchers.add(name);
|
|
176
|
+
server.allowInbound(name as never);
|
|
177
|
+
server.on(
|
|
178
|
+
name as never,
|
|
179
|
+
((payload: unknown, ws: ServerWebSocket<WsData>) =>
|
|
180
|
+
dispatchClientEvent(name, payload, ws)) as never,
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ── cluster (optional) ────────────────────────────────────────────────
|
|
185
|
+
let cluster: ClusterSync | undefined;
|
|
186
|
+
let ownBridge: NatsBridge | undefined;
|
|
187
|
+
let stateStore: ClusterStateStore | undefined;
|
|
188
|
+
let clusterTransportRef: ClusterTransport | undefined;
|
|
189
|
+
if (eOpts.cluster) {
|
|
190
|
+
const resolved = resolveClusterTransport(
|
|
191
|
+
eOpts.cluster,
|
|
192
|
+
serverBridge,
|
|
193
|
+
bindings,
|
|
194
|
+
reportClusterError,
|
|
195
|
+
);
|
|
196
|
+
if (!resolved.transport) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
"ignex events: cluster configured without a transport — set cluster.nats, cluster.redis, or cluster.transport",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
ownBridge = resolved.ownBridge;
|
|
202
|
+
clusterTransportRef = resolved.transport;
|
|
203
|
+
stateStore = eOpts.cluster.state ?? createMemoryStateStore();
|
|
204
|
+
cluster = createClusterSync({
|
|
205
|
+
instanceId,
|
|
206
|
+
prefix,
|
|
207
|
+
transport: resolved.transport,
|
|
208
|
+
queue,
|
|
209
|
+
bindings,
|
|
210
|
+
stateStore,
|
|
211
|
+
presenceTtlMs: eOpts.cluster.presenceTtlMs ?? 60_000,
|
|
212
|
+
heartbeatMs: eOpts.cluster.heartbeatMs ?? 15_000,
|
|
213
|
+
onRemoteFrame: (kind, key, name, frame, meta) => {
|
|
214
|
+
// trace: the frame ARRIVED from another instance — record it before
|
|
215
|
+
// local delivery so the debugger sees remote-originated events too.
|
|
216
|
+
state.trace.record("in.remote", name, undefined, undefined, frame.byteLength);
|
|
217
|
+
counters.deliveredLocal += deliverLocal(
|
|
218
|
+
state,
|
|
219
|
+
targetFromRemote(kind, key),
|
|
220
|
+
frame,
|
|
221
|
+
eachUserSocket,
|
|
222
|
+
);
|
|
223
|
+
deliverRemoteServerEvent(name, frame, meta.traceId);
|
|
224
|
+
},
|
|
225
|
+
onError: reportClusterError,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Deliver a remote event to local server-event handlers. The producer's
|
|
231
|
+
* trace id propagates into the remote context via an ephemeral spread —
|
|
232
|
+
* only when one actually rides along.
|
|
233
|
+
*/
|
|
234
|
+
const deliverRemoteServerEvent = (
|
|
235
|
+
name: string,
|
|
236
|
+
frame: Uint8Array,
|
|
237
|
+
traceId: string,
|
|
238
|
+
): void => {
|
|
239
|
+
if (!registry.wantsServerEvent(name)) return;
|
|
240
|
+
const id = bindings.anyEventNameToId[name];
|
|
241
|
+
if (id === undefined) return;
|
|
242
|
+
try {
|
|
243
|
+
const baseCtx = contexts.make(undefined, "remote");
|
|
244
|
+
const ctx = traceId !== "" ? ({ ...baseCtx, traceId } as EventContext<B>) : baseCtx;
|
|
245
|
+
dispatcher.server(name, bindings.decodePayload(id, frame), ctx);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
reportClusterError(err instanceof Error ? err : new Error(String(err)));
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// ── emitter ───────────────────────────────────────────────────────────
|
|
252
|
+
const emitter = createEmitter({
|
|
253
|
+
state,
|
|
254
|
+
...(serverBridge !== undefined ? { bridge: serverBridge } : {}),
|
|
255
|
+
...(cluster !== undefined
|
|
256
|
+
? { cluster, routeInstances: makeRouteInstances(cluster) }
|
|
257
|
+
: {}),
|
|
258
|
+
eachUserSocket,
|
|
259
|
+
counters,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── cross-instance rpc ────────────────────────────────────────────────
|
|
263
|
+
const clusterRpc: ClusterRpc | undefined =
|
|
264
|
+
cluster !== undefined && clusterTransportRef !== undefined
|
|
265
|
+
? createClusterRpc({ instanceId, prefix, transport: clusterTransportRef })
|
|
266
|
+
: undefined;
|
|
267
|
+
|
|
268
|
+
// ── scheduled emits ───────────────────────────────────────────────────
|
|
269
|
+
const scheduler: Scheduler = createScheduler((name, payload, target) => {
|
|
270
|
+
api.emit(name as never, payload as never, target as EmitTarget | undefined);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── groups ────────────────────────────────────────────────────────────
|
|
274
|
+
const groups: GroupManager<B> = createGroupManager<B>({
|
|
275
|
+
state,
|
|
276
|
+
emitToGroup: (group, name, payload) => api.emitToGroup(group, name as never, payload as never),
|
|
277
|
+
emitToUser: (userId, name, payload) => api.emitToUser(userId, name as never, payload as never),
|
|
278
|
+
onUserGroupChange: (name, members) => cluster?.userGroupChanged(name, members),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── client lifecycle (events-layer view of socket open/close) ─────────
|
|
282
|
+
const attach = (ws: ServerWebSocket<WsData>): EventClient | undefined => {
|
|
283
|
+
const client = clients.attach(ws);
|
|
284
|
+
if (!client) return undefined;
|
|
285
|
+
cluster?.clientJoined(clientIdentity(client));
|
|
286
|
+
eOpts.onConnect?.(client);
|
|
287
|
+
return client;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const detach = (ws: ServerWebSocket<WsData>): EventClient | undefined => {
|
|
291
|
+
const client = clients.detach(ws);
|
|
292
|
+
if (!client) return undefined;
|
|
293
|
+
cluster?.clientLeft(clientIdentity(client));
|
|
294
|
+
eOpts.onDisconnect?.(client);
|
|
295
|
+
return client;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const onGroupChange = (group: string, ws: ServerWebSocket<WsData>, joined: boolean): void => {
|
|
299
|
+
cluster?.clientGroupChanged(group, ws.data.id, joined);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ── the hub API ───────────────────────────────────────────────────────
|
|
303
|
+
const api: EventsHubInternal<B> = {
|
|
304
|
+
get server(): IgnServer<B> {
|
|
305
|
+
return server;
|
|
306
|
+
},
|
|
307
|
+
get instanceId(): string {
|
|
308
|
+
return instanceId;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
// ── receiving events ────────────────────────────────────────────────
|
|
312
|
+
on(name, handler) {
|
|
313
|
+
ensureDispatcher(name);
|
|
314
|
+
registry.on(name, handler as never);
|
|
315
|
+
return api;
|
|
316
|
+
},
|
|
317
|
+
off(name, handler) {
|
|
318
|
+
registry.off(name, handler as never);
|
|
319
|
+
return api;
|
|
320
|
+
},
|
|
321
|
+
once(name, handler) {
|
|
322
|
+
ensureDispatcher(name);
|
|
323
|
+
registry.once(name, handler as never);
|
|
324
|
+
return api;
|
|
325
|
+
},
|
|
326
|
+
onAny(cb) {
|
|
327
|
+
registry.onAny(cb as never);
|
|
328
|
+
return api;
|
|
329
|
+
},
|
|
330
|
+
offAny(cb) {
|
|
331
|
+
registry.offAny(cb as never);
|
|
332
|
+
return api;
|
|
333
|
+
},
|
|
334
|
+
onServerEvent(name, handler) {
|
|
335
|
+
registry.onServerEvent(name, handler as never);
|
|
336
|
+
return api;
|
|
337
|
+
},
|
|
338
|
+
offServerEvent(name, handler) {
|
|
339
|
+
registry.offServerEvent(name, handler as never);
|
|
340
|
+
return api;
|
|
341
|
+
},
|
|
342
|
+
events() {
|
|
343
|
+
return registry.names() as EventNameOf<B>[];
|
|
344
|
+
},
|
|
345
|
+
listenerCount(name) {
|
|
346
|
+
return registry.count(name);
|
|
347
|
+
},
|
|
348
|
+
removeAllListeners(name) {
|
|
349
|
+
registry.removeAll(name);
|
|
350
|
+
return api;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// ── emitting events (cluster-aware) ─────────────────────────────────
|
|
354
|
+
emit(name, payload, target = { type: "broadcast" }) {
|
|
355
|
+
if (closed) return;
|
|
356
|
+
emitter.emit(name as never, payload as never, target);
|
|
357
|
+
},
|
|
358
|
+
emitToTopic(topic, name, payload) {
|
|
359
|
+
api.emit(name, payload, { type: "topic", topic });
|
|
360
|
+
},
|
|
361
|
+
emitToGroup(group, name, payload) {
|
|
362
|
+
api.emit(name, payload, { type: "group", group });
|
|
363
|
+
},
|
|
364
|
+
emitToUser(userId, name, payload) {
|
|
365
|
+
api.emit(name, payload, { type: "user", userId });
|
|
366
|
+
},
|
|
367
|
+
emitToClient(clientId, name, payload) {
|
|
368
|
+
api.emit(name, payload, { type: "client", clientId });
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// ── client records ──────────────────────────────────────────────────
|
|
372
|
+
client(id) {
|
|
373
|
+
return clients.get(id);
|
|
374
|
+
},
|
|
375
|
+
clients() {
|
|
376
|
+
return clients.all();
|
|
377
|
+
},
|
|
378
|
+
clientsByUser(userId) {
|
|
379
|
+
return clients.byUser(userId);
|
|
380
|
+
},
|
|
381
|
+
get clientCount(): number {
|
|
382
|
+
return clients.size;
|
|
383
|
+
},
|
|
384
|
+
setUserId(clientId, userId) {
|
|
385
|
+
const before = clients.get(clientId)?.userId;
|
|
386
|
+
if (!clients.setUserId(clientId, userId)) return;
|
|
387
|
+
if (before !== userId) {
|
|
388
|
+
// keep remote presence indexes in sync with the identity change
|
|
389
|
+
cluster?.clientLeft({ id: clientId, ...(before !== undefined ? { userId: before } : {}) });
|
|
390
|
+
cluster?.clientJoined({ id: clientId, ...(userId !== undefined ? { userId } : {}) });
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
setClientData(clientId, key, value) {
|
|
394
|
+
const client = clients.get(clientId);
|
|
395
|
+
if (!client) return;
|
|
396
|
+
client.data.set(key, value);
|
|
397
|
+
cluster?.setRemoteClientData(clientId, JSON.stringify(client.data.toJSON()));
|
|
398
|
+
},
|
|
399
|
+
getClientData(clientId, key) {
|
|
400
|
+
return clients.get(clientId)?.data.get(key);
|
|
401
|
+
},
|
|
402
|
+
clearClientData(clientId) {
|
|
403
|
+
const client = clients.get(clientId);
|
|
404
|
+
if (!client) return;
|
|
405
|
+
client.data.clear();
|
|
406
|
+
cluster?.setRemoteClientData(clientId, "{}");
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// ── groups ──────────────────────────────────────────────────────────
|
|
410
|
+
group(name) {
|
|
411
|
+
return groups.clientGroup(name);
|
|
412
|
+
},
|
|
413
|
+
groups() {
|
|
414
|
+
return groups.clientGroups();
|
|
415
|
+
},
|
|
416
|
+
userGroup(name) {
|
|
417
|
+
return groups.userGroup(name);
|
|
418
|
+
},
|
|
419
|
+
userGroups() {
|
|
420
|
+
return groups.userGroups();
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// ── horizontal scaling ──────────────────────────────────────────────
|
|
424
|
+
clusterClients() {
|
|
425
|
+
return cluster?.remoteClients() ?? [];
|
|
426
|
+
},
|
|
427
|
+
clusterInstances() {
|
|
428
|
+
return cluster?.knownInstances() ?? [];
|
|
429
|
+
},
|
|
430
|
+
async clusterUserClients(userId) {
|
|
431
|
+
return (await cluster?.clusterUserClients(userId)) ?? [];
|
|
432
|
+
},
|
|
433
|
+
async clusterGroupMembers(group) {
|
|
434
|
+
return (await cluster?.clusterGroupMembers(group)) ?? [];
|
|
435
|
+
},
|
|
436
|
+
async clusterUserGroupMembers(group) {
|
|
437
|
+
return (await cluster?.clusterUserGroupMembers(group)) ?? [];
|
|
438
|
+
},
|
|
439
|
+
async remoteClientData(clientId) {
|
|
440
|
+
return await cluster?.getRemoteClientData(clientId);
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
// ── cross-instance rpc ──────────────────────────────────────────────
|
|
444
|
+
call(method, args, opts) {
|
|
445
|
+
if (!clusterRpc)
|
|
446
|
+
return Promise.reject(
|
|
447
|
+
new Error("ignex events: hub.call requires a configured events.cluster"),
|
|
448
|
+
);
|
|
449
|
+
return clusterRpc.call(method, args, opts);
|
|
450
|
+
},
|
|
451
|
+
onMethod(method, handler) {
|
|
452
|
+
clusterRpc?.on(method, handler as never);
|
|
453
|
+
return api;
|
|
454
|
+
},
|
|
455
|
+
onRequest(name, responder) {
|
|
456
|
+
server.handle(
|
|
457
|
+
name,
|
|
458
|
+
(async (payload: unknown, ws: ServerWebSocket<WsData>) => {
|
|
459
|
+
const client = clients.get(ws.data.id) ?? attach(ws);
|
|
460
|
+
return await responder(payload as never, contexts.make(client, "client"));
|
|
461
|
+
}) as never,
|
|
462
|
+
);
|
|
463
|
+
return api;
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
// ── scheduled emits ─────────────────────────────────────────────────
|
|
467
|
+
schedule(name, payload, target, delayMs) {
|
|
468
|
+
if (closed) return "";
|
|
469
|
+
return scheduler.schedule(name as never, payload, target, delayMs);
|
|
470
|
+
},
|
|
471
|
+
cancelScheduled(id) {
|
|
472
|
+
return scheduler.cancel(id);
|
|
473
|
+
},
|
|
474
|
+
get scheduledCount(): number {
|
|
475
|
+
return scheduler.size;
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
// ── observability ───────────────────────────────────────────────────
|
|
479
|
+
metrics() {
|
|
480
|
+
const s = cluster?.stats();
|
|
481
|
+
return buildMetricsSnapshot({
|
|
482
|
+
counters,
|
|
483
|
+
transportErrors,
|
|
484
|
+
dispatch: dispatchCounters,
|
|
485
|
+
handlerErrors: registry.errorCount,
|
|
486
|
+
scheduledActive: scheduler.size,
|
|
487
|
+
...(s !== undefined ? { cluster: s } : {}),
|
|
488
|
+
queue: queue.stats(),
|
|
489
|
+
...(clusterRpc !== undefined ? { rpc: clusterRpc.stats() } : {}),
|
|
490
|
+
connectedClients: clients.size,
|
|
491
|
+
remoteClients: cluster?.remoteClients().length ?? 0,
|
|
492
|
+
clientGroups: groups.clientGroups().length,
|
|
493
|
+
userGroups: groups.userGroups().length,
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
queueStats() {
|
|
497
|
+
const s = queue.stats();
|
|
498
|
+
return { pending: queue.pending, ...s };
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
// ── internals (server-driven) ───────────────────────────────────────
|
|
502
|
+
attach,
|
|
503
|
+
detach,
|
|
504
|
+
onGroupChange,
|
|
505
|
+
dispatchBridgeInbound(name, payload) {
|
|
506
|
+
if (registry.wantsServerEvent(name)) {
|
|
507
|
+
dispatcher.client(name, payload, contexts.make(undefined, "bridge"));
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
async close() {
|
|
512
|
+
if (closed) return;
|
|
513
|
+
closed = true;
|
|
514
|
+
if (boundGlobal) unbindEvents();
|
|
515
|
+
scheduler.clear();
|
|
516
|
+
clusterRpc?.close();
|
|
517
|
+
// announce local leaves so remote presence converges before shutdown
|
|
518
|
+
for (const c of clients.all()) cluster?.clientLeft(clientIdentity(c));
|
|
519
|
+
await cluster?.close();
|
|
520
|
+
await queue.drain();
|
|
521
|
+
queue.close();
|
|
522
|
+
if (ownBridge) await ownBridge.close();
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
if (eOpts.global !== false) {
|
|
527
|
+
bindEvents(api as never);
|
|
528
|
+
boundGlobal = true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// pre-register dispatchers for explicitly allowed inbound events (so onAny
|
|
532
|
+
// sees them even without a dedicated handler)
|
|
533
|
+
for (const name of eOpts.inbound ?? []) ensureDispatcher(name);
|
|
534
|
+
|
|
535
|
+
return api;
|
|
536
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub internal contracts — the surface the server drives plus the factory
|
|
3
|
+
* options. Not part of the public {@link EventsHub}; consumed by
|
|
4
|
+
* `src/core/server/index.ts` (which attaches/detaches sockets) and by the hub's own
|
|
5
|
+
* composition root.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerWebSocket } from "bun";
|
|
8
|
+
import type { Bindings, DefaultBindings } from "../../bindings/types";
|
|
9
|
+
import type { NatsBridge } from "../../bridge/nats";
|
|
10
|
+
import type { IgnServer } from "../../core/server";
|
|
11
|
+
import type { ServerState, WsData } from "../../core/state";
|
|
12
|
+
import type { EventClient } from "../types";
|
|
13
|
+
import type { EventsHub, EventsOptions } from "../types";
|
|
14
|
+
|
|
15
|
+
/** Internal surface the server drives (not part of the public `EventsHub`). */
|
|
16
|
+
export interface EventsHubInternal<B extends Bindings = DefaultBindings>
|
|
17
|
+
extends EventsHub<B> {
|
|
18
|
+
attach(ws: ServerWebSocket<WsData>): EventClient | undefined;
|
|
19
|
+
detach(ws: ServerWebSocket<WsData>): EventClient | undefined;
|
|
20
|
+
onGroupChange(group: string, ws: ServerWebSocket<WsData>, joined: boolean): void;
|
|
21
|
+
dispatchBridgeInbound(name: string, payload: unknown): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CreateEventsHubOptions<B extends Bindings> {
|
|
25
|
+
state: ServerState;
|
|
26
|
+
server: IgnServer<B>;
|
|
27
|
+
bindings: Bindings;
|
|
28
|
+
/** the server's own NATS bridge (reused by the cluster when `cluster.nats: true`) */
|
|
29
|
+
serverBridge?: NatsBridge;
|
|
30
|
+
options: EventsOptions<B>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics snapshot assembly — a PURE function folding every events-layer
|
|
3
|
+
* counter source into one {@link EventsMetricsSnapshot}. No mutation, no I/O:
|
|
4
|
+
* the hub calls it with current values whenever `hub.metrics()` is polled.
|
|
5
|
+
*/
|
|
6
|
+
import type { EventsMetricsSnapshot } from "../types";
|
|
7
|
+
import type { EmitCounters } from "../emit";
|
|
8
|
+
import type { DispatchCounters } from "./dispatch";
|
|
9
|
+
|
|
10
|
+
/** One queue-stats shape (`queue.stats()`), duplicated structurally. */
|
|
11
|
+
interface QueueStats {
|
|
12
|
+
queued: number;
|
|
13
|
+
processed: number;
|
|
14
|
+
dropped: number;
|
|
15
|
+
errors: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Cluster sync counters (`cluster.stats()`), all optional (unclustered → 0). */
|
|
19
|
+
interface ClusterStats {
|
|
20
|
+
received: number;
|
|
21
|
+
droppedSelf: number;
|
|
22
|
+
droppedDupe: number;
|
|
23
|
+
errors: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Cross-instance rpc counters (`clusterRpc.stats()`). */
|
|
27
|
+
interface RpcStats {
|
|
28
|
+
sent: number;
|
|
29
|
+
received: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MetricsSnapshotInputs {
|
|
33
|
+
/** hub-level emit counters */
|
|
34
|
+
counters: EmitCounters;
|
|
35
|
+
/** cluster transport failures seen by the hub itself */
|
|
36
|
+
transportErrors: number;
|
|
37
|
+
/** dispatch reliability counters (retries + dead letters) */
|
|
38
|
+
dispatch: DispatchCounters;
|
|
39
|
+
/** handler exceptions observed by the registry */
|
|
40
|
+
handlerErrors: number;
|
|
41
|
+
/** emits currently scheduled (`scheduler.size`) */
|
|
42
|
+
scheduledActive: number;
|
|
43
|
+
cluster?: ClusterStats;
|
|
44
|
+
queue: QueueStats;
|
|
45
|
+
rpc?: RpcStats;
|
|
46
|
+
/** local connected clients */
|
|
47
|
+
connectedClients: number;
|
|
48
|
+
/** remote clients known via presence */
|
|
49
|
+
remoteClients: number;
|
|
50
|
+
/** active client-group / user-group counts */
|
|
51
|
+
clientGroups: number;
|
|
52
|
+
userGroups: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Sum of cluster-sync errors + hub-side transport errors. */
|
|
56
|
+
const totalClusterErrors = (c: MetricsSnapshotInputs): number =>
|
|
57
|
+
(c.cluster?.errors ?? 0) + c.transportErrors;
|
|
58
|
+
|
|
59
|
+
export function buildMetricsSnapshot(c: MetricsSnapshotInputs): EventsMetricsSnapshot {
|
|
60
|
+
return {
|
|
61
|
+
emitted: c.counters.emitted,
|
|
62
|
+
emittedByTarget: { ...c.counters.emittedByTarget },
|
|
63
|
+
deliveredLocal: c.counters.deliveredLocal,
|
|
64
|
+
clusterPublished: c.counters.clusterPublished,
|
|
65
|
+
clusterReceived: c.cluster?.received ?? 0,
|
|
66
|
+
clusterDroppedSelf: c.cluster?.droppedSelf ?? 0,
|
|
67
|
+
clusterDroppedDupe: c.cluster?.droppedDupe ?? 0,
|
|
68
|
+
clusterRouted: c.counters.clusterRouted,
|
|
69
|
+
clusterErrors: totalClusterErrors(c),
|
|
70
|
+
queueQueued: c.queue.queued,
|
|
71
|
+
queueDropped: c.queue.dropped,
|
|
72
|
+
queueErrors: c.queue.errors,
|
|
73
|
+
handlerErrors: c.handlerErrors,
|
|
74
|
+
handlerRetries: c.dispatch.handlerRetries,
|
|
75
|
+
dlqCount: c.dispatch.dlqCount,
|
|
76
|
+
scheduledActive: c.scheduledActive,
|
|
77
|
+
rpcSent: c.rpc?.sent ?? 0,
|
|
78
|
+
rpcReceived: c.rpc?.received ?? 0,
|
|
79
|
+
connectedClients: c.connectedClients,
|
|
80
|
+
remoteClients: c.remoteClients,
|
|
81
|
+
clientGroups: c.clientGroups,
|
|
82
|
+
userGroups: c.userGroups,
|
|
83
|
+
};
|
|
84
|
+
}
|