@ignex/nova 0.1.3 → 0.1.6

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 (98) hide show
  1. package/README.md +4 -1
  2. package/docs/ai/TREE.md +69 -9
  3. package/docs/architecture.md +75 -27
  4. package/docs/events.md +83 -1
  5. package/docs/generic-bindings.md +10 -0
  6. package/docs/wire-format.md +65 -18
  7. package/package.json +2 -1
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/generate.ts +97 -3
  10. package/public/server.ts +10 -0
  11. package/rust/src/generated/backend.rs +503 -0
  12. package/rust/src/transcode/generated.rs +376 -17
  13. package/src/bridge/nats/inbound.ts +46 -0
  14. package/src/bridge/nats/index.ts +131 -0
  15. package/src/bridge/nats/real-transport.ts +133 -0
  16. package/src/bridge/nats/types.ts +80 -0
  17. package/src/codegen/constants.ts +14 -4
  18. package/src/codegen/direct-gen.ts +20 -6
  19. package/src/codegen/registry-gen.ts +10 -6
  20. package/src/codegen/rust-glue-gen.ts +10 -3
  21. package/src/codegen/schema-model.ts +28 -3
  22. package/src/codegen/ts-ser-gen.ts +12 -3
  23. package/src/core/auth.ts +65 -4
  24. package/src/core/client-rpc.ts +75 -0
  25. package/src/core/client-state.ts +53 -0
  26. package/src/core/client-wire.ts +183 -8
  27. package/src/core/client.ts +84 -4
  28. package/src/core/groups.ts +5 -0
  29. package/src/core/metrics.ts +38 -21
  30. package/src/core/outbound.ts +50 -6
  31. package/src/core/rate-limit.ts +69 -0
  32. package/src/core/replay.ts +41 -1
  33. package/src/core/resume.ts +181 -0
  34. package/src/core/rooms.ts +10 -3
  35. package/src/core/routing.ts +128 -5
  36. package/src/core/server/client-info.ts +37 -0
  37. package/src/core/server/http-routes.ts +59 -0
  38. package/src/core/{server.ts → server/index.ts} +112 -120
  39. package/src/core/server/metrics-view.ts +53 -0
  40. package/src/core/server/socket-lifecycle.ts +57 -0
  41. package/src/core/state.ts +73 -1
  42. package/src/core/topic-log.ts +86 -0
  43. package/src/events/clients.ts +18 -0
  44. package/src/events/cluster/dedupe.ts +43 -0
  45. package/src/events/cluster/envelope.ts +149 -0
  46. package/src/events/cluster/index.ts +50 -0
  47. package/src/events/cluster/keys.ts +33 -0
  48. package/src/events/cluster/kinds.ts +32 -0
  49. package/src/events/cluster/presence-table.ts +99 -0
  50. package/src/events/cluster/presence.ts +53 -0
  51. package/src/events/cluster/redis-client.ts +50 -0
  52. package/src/events/cluster/store-memory.ts +67 -0
  53. package/src/events/cluster/store-redis.ts +44 -0
  54. package/src/events/cluster/subjects.ts +30 -0
  55. package/src/events/cluster/sync.ts +476 -0
  56. package/src/events/cluster/transport-nats.ts +24 -0
  57. package/src/events/cluster/transport-redis.ts +120 -0
  58. package/src/events/cluster-rpc.ts +196 -0
  59. package/src/events/delivery.ts +83 -0
  60. package/src/events/emit.ts +57 -11
  61. package/src/events/hub/context-factory.ts +79 -0
  62. package/src/events/hub/dispatch.ts +86 -0
  63. package/src/events/hub/index.ts +536 -0
  64. package/src/events/hub/internal.ts +31 -0
  65. package/src/events/hub/metrics-snapshot.ts +84 -0
  66. package/src/events/hub/resolve-cluster.ts +49 -0
  67. package/src/events/queue.ts +36 -9
  68. package/src/events/registry.ts +90 -54
  69. package/src/events/schedule.ts +73 -0
  70. package/src/events/trace.ts +283 -0
  71. package/src/events/types/client.ts +68 -0
  72. package/src/events/types/cluster.ts +40 -0
  73. package/src/events/types/context.ts +50 -0
  74. package/src/events/types/emit-target.ts +29 -0
  75. package/src/events/types/groups.ts +35 -0
  76. package/src/events/types/hub.ts +124 -0
  77. package/src/events/types/index.ts +30 -0
  78. package/src/events/types/metrics.ts +52 -0
  79. package/src/events/types/options.ts +62 -0
  80. package/src/generated/direct-ser.ts +146 -59
  81. package/src/generated/fbs/backend.fbs +23 -0
  82. package/src/generated/registry.ts +92 -33
  83. package/src/generated/rust/backend_generated.rs +503 -0
  84. package/src/generated/ts/backend.ts +4 -0
  85. package/src/generated/ts/resume.ts +74 -0
  86. package/src/generated/ts/resumed.ts +88 -0
  87. package/src/generated/ts/rpc-call.ts +112 -0
  88. package/src/generated/ts/rpc-result.ts +126 -0
  89. package/src/generated/ts/snapshot-request.ts +19 -5
  90. package/src/generated/ts-ser.ts +109 -16
  91. package/src/generated/wire-registry.json +7 -3
  92. package/src/schema/index.ts +45 -1
  93. package/src/transport/transport.ts +117 -77
  94. package/src/bridge/nats.ts +0 -309
  95. package/src/events/cluster.ts +0 -732
  96. package/src/events/hub.ts +0 -481
  97. package/src/events/types.ts +0 -378
  98. package/src/transport/stats.ts +0 -48
@@ -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,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
+ }
@@ -19,6 +19,7 @@ import { sendFrame } from "../core/outbound";
19
19
  import { publishToGroup } from "../core/groups";
20
20
  import { publishToRoom } from "../core/rooms";
21
21
  import type { ClusterSync } from "./cluster";
22
+ import { capturePayload } from "./trace";
22
23
  import type { EmitTarget, EmitTargetKind } from "./types";
23
24
 
24
25
  export interface EmitCounters {
@@ -26,18 +27,32 @@ export interface EmitCounters {
26
27
  emittedByTarget: Record<EmitTargetKind, number>;
27
28
  deliveredLocal: number;
28
29
  clusterPublished: number;
30
+ clusterRouted: number;
29
31
  }
30
32
 
31
33
  export interface EmitEngine {
32
- emit(name: string, payload: unknown, target: EmitTarget): void;
34
+ emit(name: string, payload: unknown, target: EmitTarget, parentTraceId?: string): void;
33
35
  }
34
36
 
35
37
  export interface EmitterOptions {
36
38
  state: ServerState;
37
39
  bridge?: NatsBridge;
38
40
  cluster?: ClusterSync;
39
- /** local sockets acting on behalf of a userId (user-target delivery) */
40
- userSockets: (userId: string) => ServerWebSocket<WsData>[];
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;
41
56
  counters: EmitCounters;
42
57
  }
43
58
 
@@ -46,7 +61,7 @@ export function deliverLocal(
46
61
  state: ServerState,
47
62
  target: EmitTarget,
48
63
  frame: Uint8Array,
49
- userSockets: (userId: string) => ServerWebSocket<WsData>[],
64
+ eachUserSocket: EmitterOptions["eachUserSocket"],
50
65
  ): number {
51
66
  switch (target.type) {
52
67
  case "broadcast": {
@@ -64,9 +79,7 @@ export function deliverLocal(
64
79
  return n;
65
80
  }
66
81
  case "user": {
67
- const list = userSockets(target.userId);
68
- for (const ws of list) sendFrame(state, ws, frame);
69
- return list.length;
82
+ return eachUserSocket(target.userId, (ws) => sendFrame(state, ws, frame));
70
83
  }
71
84
  case "client": {
72
85
  const ws = state.clients.get(target.clientId);
@@ -109,19 +122,52 @@ export function createEmitter(opts: EmitterOptions): EmitEngine {
109
122
  };
110
123
 
111
124
  return {
112
- emit(name, payload, target) {
125
+ emit(name, payload, target, parentTraceId) {
113
126
  const frame = state.transport.encodeToScratch(name, payload);
114
127
  opts.counters.emitted++;
115
128
  opts.counters.emittedByTarget[target.type]++;
116
- opts.counters.deliveredLocal += deliverLocal(state, target, frame, opts.userSockets);
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.
117
142
  if (bridge) {
118
143
  const subject = bridgeSubject(target, name);
119
144
  if (subject) bridge.publish(subject, frame); // copies the scratch view
120
145
  }
121
146
  if (cluster) {
122
- opts.counters.clusterPublished++;
123
- cluster.publish(target.type, targetKey(target), name, frame);
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
+ }
124
168
  }
169
+ // local delivery LAST: stamps delivery seqs into the scratch header
170
+ opts.counters.deliveredLocal += deliverLocal(state, target, frame, opts.eachUserSocket);
125
171
  },
126
172
  };
127
173
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Context factory — builds and CACHES the {@link EventContext} handed to
3
+ * handlers.
4
+ *
5
+ * Contexts are CACHED (instantiation-time work, not per-event work): every
6
+ * EventContext is immutable and its closures are bound to the stable hub API,
7
+ * so one context per client record + one shared context per client-less
8
+ * source is built once and reused — dispatching an event allocates nothing.
9
+ *
10
+ * The hub reference is injected lazily (`getHub`) because the factory is
11
+ * constructed before the hub API object exists.
12
+ */
13
+ import type { ServerWebSocket } from "bun";
14
+ import type { Bindings } from "../../bindings/types";
15
+ import type { WsData } from "../../core/state";
16
+ import type { EventClient, EventContext, EventSource } from "../types";
17
+
18
+ export interface ContextFactory<B extends Bindings> {
19
+ /** context for a client-sent event (cached per client record) */
20
+ forClient(client: EventClient): EventContext<B>;
21
+ /** full form: explicit client + source (client-less sources share one ctx) */
22
+ make(client: EventClient | undefined, source: EventSource): EventContext<B>;
23
+ }
24
+
25
+ export function createContextFactory<B extends Bindings>(deps: {
26
+ server: IgnServerOf<B>;
27
+ getHub: () => EventsHubOf<B>;
28
+ }): ContextFactory<B> {
29
+ const { server, getHub } = deps;
30
+
31
+ /** Assemble a fresh context bound to the stable hub API. */
32
+ const buildCtx = (client: EventClient | undefined, source: EventSource): EventContext<B> => {
33
+ const hub = getHub();
34
+ const ctx: EventContext<B> = {
35
+ source,
36
+ hub,
37
+ server,
38
+ emit: (name, payload, target) => hub.emit(name as never, payload as never, target),
39
+ emitToGroup: (group, name, payload) => hub.emitToGroup(group, name as never, payload as never),
40
+ emitToUser: (userId, name, payload) => hub.emitToUser(userId, name as never, payload as never),
41
+ emitToClient: (clientId, name, payload) =>
42
+ hub.emitToClient(clientId, name as never, payload as never),
43
+ emitToTopic: (topic, name, payload) => hub.emitToTopic(topic, name as never, payload as never),
44
+ ...(client ? { client } : {}),
45
+ };
46
+ return ctx;
47
+ };
48
+
49
+ // caches: per-client contexts + one shared context per client-less source
50
+ const ctxByClient = new WeakMap<object, EventContext<B>>();
51
+ const sharedCtxs = new Map<EventSource, EventContext<B>>();
52
+
53
+ return {
54
+ forClient(client) {
55
+ let ctx = ctxByClient.get(client);
56
+ if (ctx === undefined) {
57
+ ctx = buildCtx(client, "client");
58
+ ctxByClient.set(client, ctx);
59
+ }
60
+ return ctx;
61
+ },
62
+ make(client, source) {
63
+ if (client === undefined) {
64
+ let shared = sharedCtxs.get(source);
65
+ if (shared === undefined) {
66
+ shared = buildCtx(undefined, source);
67
+ sharedCtxs.set(source, shared);
68
+ }
69
+ return shared;
70
+ }
71
+ return this.forClient(client);
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Structural stand-ins to keep this module free of circular type imports. */
77
+ type IgnServerOf<B extends Bindings> = import("../../core/server").IgnServer<B>;
78
+ type EventsHubOf<B extends Bindings> = import("../types").EventsHub<B>;
79
+ export type HubSocket = ServerWebSocket<WsData>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Handler dispatch — routes an inbound event to the registry, optionally with
3
+ * the reliability layer (retry + DLQ). When no policy is configured this is a
4
+ * plain fire-and-forget dispatch: zero overhead on the hot path.
5
+ */
6
+ import type { EventContext } from "../types";
7
+ import type { HandlerRegistry } from "../registry";
8
+ import {
9
+ deliverWithRetry,
10
+ resolveDeliveryPolicy,
11
+ type DeliveryPolicy,
12
+ type ResolvedDeliveryPolicy,
13
+ } from "../delivery";
14
+
15
+ /** How the hub counts reliability activity (folded into metrics). */
16
+ export interface DispatchCounters {
17
+ /** retry schedules by the delivery layer */
18
+ handlerRetries: number;
19
+ /** events dead-lettered after exhausting retries */
20
+ dlqCount: number;
21
+ }
22
+
23
+ /**
24
+ * Build the resolved policy + registry pair used by {@link createDispatcher}.
25
+ * Returns `policy: null` when handlers are unconfigured (plain dispatch).
26
+ */
27
+ export function resolveDispatchPolicy(
28
+ opts: DeliveryPolicy | undefined,
29
+ counters: DispatchCounters,
30
+ ): ResolvedDeliveryPolicy | null {
31
+ if (!opts) return null;
32
+ return resolveDeliveryPolicy({
33
+ ...opts,
34
+ dlq: (info) => {
35
+ counters.dlqCount++;
36
+ opts.dlq?.(info);
37
+ },
38
+ });
39
+ }
40
+
41
+ export interface Dispatcher<B extends import("../../bindings/types").Bindings> {
42
+ /** dispatch a client-sent event */
43
+ client(name: string, payload: unknown, ctx: EventContext<B>): void;
44
+ /** dispatch a server-side event (remote instance / bridge origin) */
45
+ server(name: string, payload: unknown, ctx: EventContext<B>): void;
46
+ }
47
+
48
+ /**
49
+ * Reliability-aware dispatcher. `mode` selects the handler surface
50
+ * (client-sent vs server-side); retries are counted into `counters`.
51
+ */
52
+ export function createDispatcher<B extends import("../../bindings/types").Bindings>(deps: {
53
+ registry: HandlerRegistry & { settleDispatch: HandlerRegistry["settleDispatch"] };
54
+ policy: ResolvedDeliveryPolicy | null;
55
+ counters: DispatchCounters;
56
+ }): Dispatcher<B> {
57
+ const { registry, policy, counters } = deps;
58
+ const dispatch = (
59
+ name: string,
60
+ payload: unknown,
61
+ ctx: EventContext<B>,
62
+ mode: "client" | "server",
63
+ ): void => {
64
+ if (policy === null) {
65
+ // default: plain dispatch, zero overhead
66
+ if (mode === "server") registry.dispatchServerEvent(name, payload, ctx);
67
+ else registry.dispatch(name, payload, ctx);
68
+ return;
69
+ }
70
+ void deliverWithRetry(
71
+ registry,
72
+ policy,
73
+ name,
74
+ payload,
75
+ ctx,
76
+ () => {
77
+ counters.handlerRetries++;
78
+ },
79
+ mode,
80
+ );
81
+ };
82
+ return {
83
+ client: (name, payload, ctx) => dispatch(name, payload, ctx, "client"),
84
+ server: (name, payload, ctx) => dispatch(name, payload, ctx, "server"),
85
+ };
86
+ }