@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.
- 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 +53 -0
- package/src/core/client-wire.ts +183 -8
- package/src/core/client.ts +84 -4
- 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,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
|
+
}
|
package/src/events/emit.ts
CHANGED
|
@@ -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
|
-
/**
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
+
}
|