@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.
Files changed (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Cross-instance RPC — request/response between hub instances over the
3
+ * {@link ClusterTransport} (NATS / Redis / custom). Complements the WS-level
4
+ * `client.request` ⇄ `server.handle` path: THIS layer is server ⇄ server,
5
+ * e.g. "instance A, what is your live client count?" or "run this against the
6
+ * instance holding user u-42's socket".
7
+ *
8
+ * Wire format: a small JSON envelope (this is an admin/control path, never
9
+ * the WS hot path):
10
+ * { v: 1, t: "req" | "res", id, from, method, args?, ok?, err?, result? }
11
+ *
12
+ * Subjects: `{prefix}.cluster.rpc.{instanceId}` (targeted) and
13
+ * `{prefix}.cluster.rpc.any` (any-instance calls — every instance receives
14
+ * the request; the FIRST response wins and later ones are ignored).
15
+ *
16
+ * All transport work runs through the offload queue contract: publish() is
17
+ * sync-but-cheap on the transport, subscriptions deliver on the broker's
18
+ * callbacks; pending calls carry their own timeout so a dead instance can
19
+ * never hang a caller beyond it.
20
+ */
21
+ import type { ClusterTransport } from "./types";
22
+
23
+ const enc = new TextEncoder();
24
+ const dec = new TextDecoder();
25
+
26
+ interface RpcRequest {
27
+ v: 1;
28
+ t: "req";
29
+ id: string;
30
+ from: string;
31
+ method: string;
32
+ args: unknown;
33
+ }
34
+ interface RpcResponse {
35
+ v: 1;
36
+ t: "res";
37
+ id: string;
38
+ from: string;
39
+ ok: boolean;
40
+ err?: string;
41
+ result?: unknown;
42
+ }
43
+ type RpcMessage = RpcRequest | RpcResponse;
44
+
45
+ export type RpcMethodHandler = (args: unknown, fromInstanceId: string) => unknown | Promise<unknown>;
46
+
47
+ export interface ClusterRpc {
48
+ /**
49
+ * Call `method` on ONE instance (`instanceId`) or on any instance
50
+ * (omit → `{prefix}.cluster.rpc.any`; first response wins). Rejects on
51
+ * timeout (`timeoutMs`, default 5000) with no response.
52
+ */
53
+ call(method: string, args?: unknown, opts?: { readonly instanceId?: string; readonly timeoutMs?: number }): Promise<unknown>;
54
+ /** register a method handler (last registration wins) */
55
+ on(method: string, handler: RpcMethodHandler): void;
56
+ /** registered method names */
57
+ methods(): string[];
58
+ stats(): { sent: number; received: number; timeouts: number; errors: number };
59
+ close(): void;
60
+ }
61
+
62
+ export interface ClusterRpcOptions {
63
+ instanceId: string;
64
+ prefix: string;
65
+ transport: ClusterTransport;
66
+ }
67
+
68
+ export function createClusterRpc(opts: ClusterRpcOptions): ClusterRpc {
69
+ const base = `${opts.prefix}.cluster.rpc`;
70
+ const selfSubject = `${base}.${opts.instanceId}`;
71
+ const anySubject = `${base}.any`;
72
+ const handlers = new Map<string, RpcMethodHandler>();
73
+ const pending = new Map<
74
+ string,
75
+ { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
76
+ >();
77
+ let sent = 0;
78
+ let received = 0;
79
+ let timeouts = 0;
80
+ let errors = 0;
81
+
82
+ const post = (msg: RpcMessage, subject: string): boolean => {
83
+ if (!opts.transport.connected) {
84
+ errors++;
85
+ return false;
86
+ }
87
+ try {
88
+ opts.transport.publish(subject, enc.encode(JSON.stringify(msg)));
89
+ return true;
90
+ } catch {
91
+ errors++;
92
+ return false;
93
+ }
94
+ };
95
+
96
+ const handleMessage = (data: Uint8Array): void => {
97
+ let msg: RpcMessage | null = null;
98
+ try {
99
+ msg = JSON.parse(dec.decode(data)) as RpcMessage;
100
+ } catch {
101
+ return;
102
+ }
103
+ if (!msg || msg.v !== 1) return;
104
+ if (msg.t === "req") {
105
+ received++;
106
+ const handler = handlers.get(msg.method);
107
+ if (!handler) return; // not ours — another instance answers (.any)
108
+ void (async () => {
109
+ try {
110
+ const result = await handler(msg.args, msg.from);
111
+ post(
112
+ { v: 1, t: "res", id: msg.id, from: opts.instanceId, ok: true, result },
113
+ `${base}.${msg.from}`,
114
+ );
115
+ } catch (err) {
116
+ post(
117
+ {
118
+ v: 1,
119
+ t: "res",
120
+ id: msg.id,
121
+ from: opts.instanceId,
122
+ ok: false,
123
+ err: err instanceof Error ? err.message : String(err),
124
+ },
125
+ `${base}.${msg.from}`,
126
+ );
127
+ }
128
+ })();
129
+ return;
130
+ }
131
+ // response — first one wins for .any fan-in
132
+ const p = pending.get(msg.id);
133
+ if (!p) return;
134
+ pending.delete(msg.id);
135
+ clearTimeout(p.timer);
136
+ if (msg.ok) p.resolve(msg.result);
137
+ else p.reject(new Error(`ignex cluster rpc failed: ${msg.err ?? "unknown error"}`));
138
+ };
139
+
140
+ const unsubs = [opts.transport.subscribe(selfSubject, handleMessage), opts.transport.subscribe(anySubject, handleMessage)];
141
+
142
+ return {
143
+ call(method, args, rpcOpts) {
144
+ const timeoutMs = rpcOpts?.timeoutMs ?? 5_000;
145
+ const instanceId = rpcOpts?.instanceId;
146
+ return new Promise((resolve, reject) => {
147
+ const id = crypto.randomUUID();
148
+ const timer = setTimeout(() => {
149
+ if (!pending.has(id)) return;
150
+ pending.delete(id);
151
+ timeouts++;
152
+ reject(new Error(`ignex cluster rpc "${method}" timed out after ${timeoutMs}ms`));
153
+ }, timeoutMs);
154
+ pending.set(id, { resolve, reject, timer });
155
+ const req: RpcRequest = {
156
+ v: 1,
157
+ t: "req",
158
+ id,
159
+ from: opts.instanceId,
160
+ method,
161
+ args,
162
+ };
163
+ if (!post(req, instanceId !== undefined ? `${base}.${instanceId}` : anySubject)) {
164
+ clearTimeout(timer);
165
+ pending.delete(id);
166
+ reject(new Error("ignex cluster rpc: transport offline"));
167
+ return;
168
+ }
169
+ sent++;
170
+ });
171
+ },
172
+ on(method, handler) {
173
+ handlers.set(method, handler);
174
+ },
175
+ methods() {
176
+ return [...handlers.keys()];
177
+ },
178
+ stats() {
179
+ return { sent, received, timeouts, errors };
180
+ },
181
+ close() {
182
+ for (const u of unsubs)
183
+ try {
184
+ u();
185
+ } catch {
186
+ // already unsubscribed
187
+ }
188
+ for (const [, p] of pending) {
189
+ clearTimeout(p.timer);
190
+ p.reject(new Error("ignex cluster rpc: closed"));
191
+ }
192
+ pending.clear();
193
+ handlers.clear();
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Per-connection app data store — the "what to store for each client" half of
3
+ * the client record. A tiny Map-backed key/value bag with JSON export, created
4
+ * on attach and discarded on detach (no leak across reconnects).
5
+ */
6
+ import type { ClientData } from "./types";
7
+
8
+ export function createClientData(): ClientData {
9
+ const map = new Map<string, unknown>();
10
+ return {
11
+ get(key) {
12
+ return map.get(key);
13
+ },
14
+ set(key, value) {
15
+ map.set(key, value);
16
+ },
17
+ has(key) {
18
+ return map.has(key);
19
+ },
20
+ delete(key) {
21
+ return map.delete(key);
22
+ },
23
+ clear() {
24
+ map.clear();
25
+ },
26
+ keys() {
27
+ return [...map.keys()];
28
+ },
29
+ entries() {
30
+ return [...map.entries()];
31
+ },
32
+ toJSON() {
33
+ const out: Record<string, unknown> = {};
34
+ for (const [k, v] of map) out[k] = v;
35
+ return out;
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Handler reliability — retry with backoff + a dead-letter sink for events
3
+ * whose handlers keep failing. Opt-in via
4
+ * `createServer({ events: { handlers: { retries, backoffMs, dlq } } })`.
5
+ *
6
+ * Without `handlers` (the default) dispatch is the plain zero-alloc registry
7
+ * path — this layer is never touched. With it, client / remote / bridge
8
+ * dispatches go through {@link deliverWithRetry}: each attempt uses the
9
+ * registry's settling dispatch so async handler rejections count; failures
10
+ * schedule the next attempt after an exponentially growing delay; exhausting
11
+ * the budget hands the event to `dlq` (dead-letter queue) — by default a
12
+ * counter-only sink.
13
+ */
14
+
15
+ export type DeadLetterHandler = (info: {
16
+ readonly name: string;
17
+ readonly payload: unknown;
18
+ readonly err: Error;
19
+ readonly attempts: number;
20
+ }) => void;
21
+
22
+ export interface DeliveryPolicy {
23
+ /** retry attempts AFTER the first try (0 = fire once), default 2 */
24
+ retries?: number;
25
+ /** base backoff before the first retry (doubles each attempt), default 100 */
26
+ backoffMs?: number;
27
+ /** called once when all attempts failed */
28
+ dlq?: DeadLetterHandler;
29
+ }
30
+
31
+ export interface ResolvedDeliveryPolicy {
32
+ retries: number;
33
+ backoffMs: number;
34
+ dlq: DeadLetterHandler;
35
+ }
36
+
37
+ export function resolveDeliveryPolicy(opts: DeliveryPolicy = {}): ResolvedDeliveryPolicy {
38
+ return {
39
+ retries: opts.retries ?? 2,
40
+ backoffMs: opts.backoffMs ?? 100,
41
+ dlq:
42
+ opts.dlq ??
43
+ (() => {
44
+ /* counter-only default — surfaced via metrics.dlqCount */
45
+ }),
46
+ };
47
+ }
48
+
49
+ interface SettlingRegistry {
50
+ settleDispatch(name: string, payload: unknown, ctx: unknown, mode?: "client" | "server"): Promise<Error[]>;
51
+ }
52
+
53
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
54
+
55
+ /**
56
+ * Dispatch `name` to `registry`, retrying (with doubling backoff) while any
57
+ * handler fails, then dead-lettering. Resolves when the event reached its
58
+ * final state (delivered / dead-lettered). Never throws.
59
+ */
60
+ export async function deliverWithRetry(
61
+ registry: SettlingRegistry,
62
+ policy: ResolvedDeliveryPolicy,
63
+ name: string,
64
+ payload: unknown,
65
+ ctx: unknown,
66
+ onRetry?: () => void,
67
+ mode: "client" | "server" = "client",
68
+ ): Promise<void> {
69
+ for (let attempt = 0; ; attempt++) {
70
+ const errors = await registry.settleDispatch(name, payload, ctx, mode);
71
+ if (errors.length === 0) return;
72
+ if (attempt >= policy.retries) {
73
+ try {
74
+ policy.dlq({ name, payload, err: errors[errors.length - 1]!, attempts: attempt + 1 });
75
+ } catch {
76
+ // a failing DLQ must never break the caller
77
+ }
78
+ return;
79
+ }
80
+ onRetry?.();
81
+ await sleep(policy.backoffMs * 2 ** attempt);
82
+ }
83
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Emit engine — the "global emit" hot path. Encodes ONCE through the transport
3
+ * scratch (zero-alloc on the happy path), delivers to the target's local
4
+ * sockets synchronously, then hands the SAME frame to the external bridge
5
+ * (existing NATS broadcast/topic/group subjects) and the cluster sync
6
+ * (offloaded) so other instances deliver it too.
7
+ *
8
+ * Hot-path guarantees:
9
+ * - encode + local delivery are synchronous and allocation-free (Bun copies
10
+ * on `ws.send`);
11
+ * - the bridge `publish` copies the scratch frame (never holds a stale view);
12
+ * - cluster work is enqueued on the offload queue — a slow broker can never
13
+ * stall the socket loop.
14
+ */
15
+ import type { ServerWebSocket } from "bun";
16
+ import type { NatsBridge } from "../bridge/nats";
17
+ import type { ServerState, WsData } from "../core/state";
18
+ import { sendFrame } from "../core/outbound";
19
+ import { publishToGroup } from "../core/groups";
20
+ import { publishToRoom } from "../core/rooms";
21
+ import type { ClusterSync } from "./cluster";
22
+ import { capturePayload } from "./trace";
23
+ import type { EmitTarget, EmitTargetKind } from "./types";
24
+
25
+ export interface EmitCounters {
26
+ emitted: number;
27
+ emittedByTarget: Record<EmitTargetKind, number>;
28
+ deliveredLocal: number;
29
+ clusterPublished: number;
30
+ clusterRouted: number;
31
+ }
32
+
33
+ export interface EmitEngine {
34
+ emit(name: string, payload: unknown, target: EmitTarget, parentTraceId?: string): void;
35
+ }
36
+
37
+ export interface EmitterOptions {
38
+ state: ServerState;
39
+ bridge?: NatsBridge;
40
+ cluster?: ClusterSync;
41
+ /**
42
+ * Invoke `each` for every LOCAL socket acting on behalf of `userId` and
43
+ * return how many were invoked. Callback-style (not array-returning) so a
44
+ * user-targeted emit allocates nothing on the hot path.
45
+ */
46
+ eachUserSocket: (
47
+ userId: string,
48
+ each: (ws: ServerWebSocket<WsData>) => void,
49
+ ) => number;
50
+ /**
51
+ * ROUTED targeted delivery: for client/user targets return the instance ids
52
+ * that own the destination connection(s) (presence), or null when unknown —
53
+ * null falls back to the full-mesh wildcard publish. Absent when no cluster.
54
+ */
55
+ routeInstances?: (target: EmitTarget) => readonly string[] | null;
56
+ counters: EmitCounters;
57
+ }
58
+
59
+ /** Deliver `frame` to the target's LOCAL sockets; returns the number written. */
60
+ export function deliverLocal(
61
+ state: ServerState,
62
+ target: EmitTarget,
63
+ frame: Uint8Array,
64
+ eachUserSocket: EmitterOptions["eachUserSocket"],
65
+ ): number {
66
+ switch (target.type) {
67
+ case "broadcast": {
68
+ for (const ws of state.sockets) sendFrame(state, ws, frame);
69
+ return state.sockets.size;
70
+ }
71
+ case "topic": {
72
+ const n = state.rooms.get(target.topic)?.size ?? 0;
73
+ publishToRoom(state, target.topic, frame);
74
+ return n;
75
+ }
76
+ case "group": {
77
+ const n = state.groups.get(target.group)?.size ?? 0;
78
+ publishToGroup(state, target.group, frame);
79
+ return n;
80
+ }
81
+ case "user": {
82
+ return eachUserSocket(target.userId, (ws) => sendFrame(state, ws, frame));
83
+ }
84
+ case "client": {
85
+ const ws = state.clients.get(target.clientId);
86
+ if (!ws) return 0;
87
+ sendFrame(state, ws, frame);
88
+ return 1;
89
+ }
90
+ }
91
+ }
92
+
93
+ export function targetKey(target: EmitTarget): string | undefined {
94
+ switch (target.type) {
95
+ case "broadcast":
96
+ return undefined;
97
+ case "topic":
98
+ return target.topic;
99
+ case "group":
100
+ return target.group;
101
+ case "user":
102
+ return target.userId;
103
+ case "client":
104
+ return target.clientId;
105
+ }
106
+ }
107
+
108
+ export function createEmitter(opts: EmitterOptions): EmitEngine {
109
+ const { state, bridge, cluster } = opts;
110
+
111
+ const bridgeSubject = (target: EmitTarget, name: string): string | undefined => {
112
+ switch (target.type) {
113
+ case "broadcast":
114
+ return bridge?.subjects.broadcast(name);
115
+ case "topic":
116
+ return bridge?.subjects.topic(target.topic, name);
117
+ case "group":
118
+ return bridge?.subjects.group(target.group, name);
119
+ default:
120
+ return undefined; // user/client targets are not bridged externally
121
+ }
122
+ };
123
+
124
+ return {
125
+ emit(name, payload, target, parentTraceId) {
126
+ const frame = state.transport.encodeToScratch(name, payload);
127
+ opts.counters.emitted++;
128
+ opts.counters.emittedByTarget[target.type]++;
129
+ // trace first (cheap typed-array stores) so a debugger sees the event
130
+ // even when delivery has zero local sockets / the bridge is down.
131
+ state.trace.record(
132
+ "out.emit",
133
+ name,
134
+ target.type,
135
+ targetKey(target),
136
+ frame.byteLength,
137
+ state.trace.captures ? capturePayload(payload, 2000) : undefined,
138
+ );
139
+ // EXTERNAL copies (bridge / cluster envelope) come FIRST — they must see
140
+ // the pristine frame, before per-socket delivery-seq stamping mutates
141
+ // the shared scratch header below. Both copy the bytes synchronously.
142
+ if (bridge) {
143
+ const subject = bridgeSubject(target, name);
144
+ if (subject) bridge.publish(subject, frame); // copies the scratch view
145
+ }
146
+ if (cluster) {
147
+ const msgId = crypto.randomUUID();
148
+ const traceId = parentTraceId ?? "";
149
+ if (opts.routeInstances !== undefined && (target.type === "client" || target.type === "user")) {
150
+ // ROUTED targeted delivery: only the owning instances receive it
151
+ const instances = opts.routeInstances(target);
152
+ if (instances !== null) {
153
+ opts.counters.clusterPublished++;
154
+ opts.counters.clusterRouted++;
155
+ cluster.route(instances, target.type as "client" | "user", targetKey(target) ?? "", name, frame, {
156
+ msgId,
157
+ traceId,
158
+ });
159
+ } else {
160
+ // presence knows nothing — fall back to the full mesh
161
+ opts.counters.clusterPublished++;
162
+ cluster.publish(target.type, targetKey(target), name, frame, { msgId, traceId });
163
+ }
164
+ } else {
165
+ opts.counters.clusterPublished++;
166
+ cluster.publish(target.type, targetKey(target), name, frame, { msgId, traceId });
167
+ }
168
+ }
169
+ // local delivery LAST: stamps delivery seqs into the scratch header
170
+ opts.counters.deliveredLocal += deliverLocal(state, target, frame, opts.eachUserSocket);
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Global emit — the module-level singleton the events layer exposes so ANY
3
+ * module in the app can send events through websockets without holding a
4
+ * server reference (the "global emit" of the events file pattern):
5
+ *
6
+ * import { on, emit } from "ignex-nova/events";
7
+ * on("order.created", (ctx) => { ... });
8
+ * emit("quote", { symbol: "AAPL", ... }); // broadcast
9
+ * emit("order", payload, { type: "group", group: "traders" }); // to a group
10
+ * emit("alert", payload, { type: "user", userId: "u-1" }); // to a user
11
+ *
12
+ * Bound automatically when `createServer({ events: {...} })` runs (last hub
13
+ * wins — one active hub per process is the supported topology; use
14
+ * `server.events` directly for per-server control). Calls before binding
15
+ * throw a descriptive error; calls after `close()` are dropped silently.
16
+ *
17
+ * Typed against the BUILT-IN event registry. Custom-schema apps should use
18
+ * `server.events.emit` / `server.events.on` (fully typed against YOUR events).
19
+ */
20
+
21
+ import type { DefaultBindings } from "../bindings/types";
22
+ import type { EventName, Events } from "../schema";
23
+ import type { EmitTarget, EventContext, EventHandler, EventsHub } from "./types";
24
+
25
+ let bound: EventsHub | null = null;
26
+
27
+ /** Bind the singleton to a hub (called by the hub on creation). */
28
+ export function bindEvents(hub: EventsHub): void {
29
+ bound = hub;
30
+ }
31
+
32
+ /** Unbind the singleton (called by the hub on close). */
33
+ export function unbindEvents(): void {
34
+ bound = null;
35
+ }
36
+
37
+ export function isEventsBound(): boolean {
38
+ return bound !== null;
39
+ }
40
+
41
+ /** The currently bound hub (null when none). */
42
+ export function getEventsHub(): EventsHub | null {
43
+ return bound;
44
+ }
45
+
46
+ function requireHub(): EventsHub {
47
+ if (!bound) {
48
+ throw new Error(
49
+ "ignex events: no events hub bound — pass `events: {}` to createServer() (or call bindEvents(hub) once)",
50
+ );
51
+ }
52
+ return bound;
53
+ }
54
+
55
+ export function emit<K extends EventName>(name: K, payload: Events[K], target?: EmitTarget): void {
56
+ requireHub().emit(name as never, payload as never, target);
57
+ }
58
+
59
+ export function emitToTopic<K extends EventName>(topic: string, name: K, payload: Events[K]): void {
60
+ requireHub().emitToTopic(topic, name as never, payload as never);
61
+ }
62
+
63
+ export function emitToGroup<K extends EventName>(group: string, name: K, payload: Events[K]): void {
64
+ requireHub().emitToGroup(group, name as never, payload as never);
65
+ }
66
+
67
+ export function emitToUser<K extends EventName>(userId: string, name: K, payload: Events[K]): void {
68
+ requireHub().emitToUser(userId, name as never, payload as never);
69
+ }
70
+
71
+ export function emitToClient<K extends EventName>(
72
+ clientId: string,
73
+ name: K,
74
+ payload: Events[K],
75
+ ): void {
76
+ requireHub().emitToClient(clientId, name as never, payload as never);
77
+ }
78
+
79
+ export function on<K extends EventName>(name: K, handler: EventHandler<DefaultBindings, K>): void {
80
+ requireHub().on(name as never, handler as never);
81
+ }
82
+
83
+ export function off<K extends EventName>(
84
+ name: K,
85
+ handler?: EventHandler<DefaultBindings, K>,
86
+ ): void {
87
+ requireHub().off(name as never, handler as never);
88
+ }
89
+
90
+ export function once<K extends EventName>(
91
+ name: K,
92
+ handler: EventHandler<DefaultBindings, K>,
93
+ ): void {
94
+ requireHub().once(name as never, handler as never);
95
+ }
96
+
97
+ export function onAny(cb: (name: string, payload: unknown, ctx: EventContext) => void): void {
98
+ requireHub().onAny(cb as never);
99
+ }
100
+
101
+ export function offAny(cb: (name: string, payload: unknown, ctx: EventContext) => void): void {
102
+ requireHub().offAny(cb as never);
103
+ }
104
+
105
+ export function onServerEvent<K extends EventName>(
106
+ name: K,
107
+ handler: EventHandler<DefaultBindings, K>,
108
+ ): void {
109
+ requireHub().onServerEvent(name as never, handler as never);
110
+ }
111
+
112
+ export function offServerEvent<K extends EventName>(
113
+ name: K,
114
+ handler?: EventHandler<DefaultBindings, K>,
115
+ ): void {
116
+ requireHub().offServerEvent(name as never, handler as never);
117
+ }