@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster transport resolution — maps `events.cluster` options onto a
|
|
3
|
+
* concrete {@link ClusterTransport} (plus an owned bridge when the hub had to
|
|
4
|
+
* create one and therefore must close it again).
|
|
5
|
+
*
|
|
6
|
+
* Pure resolver: no side effects beyond constructing the adapters it returns.
|
|
7
|
+
*/
|
|
8
|
+
import { createNatsBridge, type NatsBridge, type NatsBridgeOptions } from "../../bridge/nats";
|
|
9
|
+
import type { Bindings } from "../../bindings/types";
|
|
10
|
+
import type { ClusterTransport, EventsClusterOptions } from "../types";
|
|
11
|
+
import { createNatsClusterTransport } from "../cluster/transport-nats";
|
|
12
|
+
import { createRedisClusterTransport } from "../cluster/transport-redis";
|
|
13
|
+
|
|
14
|
+
export interface ResolvedClusterTransport {
|
|
15
|
+
transport?: ClusterTransport;
|
|
16
|
+
/** set when this resolver created the bridge — the hub closes it on shutdown */
|
|
17
|
+
ownBridge?: NatsBridge;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveClusterTransport(
|
|
21
|
+
opts: EventsClusterOptions,
|
|
22
|
+
serverBridge: NatsBridge | undefined,
|
|
23
|
+
bindings: Bindings,
|
|
24
|
+
onError: (err: Error) => void,
|
|
25
|
+
): ResolvedClusterTransport {
|
|
26
|
+
// explicit adapter always wins
|
|
27
|
+
if (opts.transport) return { transport: opts.transport };
|
|
28
|
+
|
|
29
|
+
if (opts.nats) {
|
|
30
|
+
const nats = opts.nats;
|
|
31
|
+
// `true` → reuse the server's bridge when present, else dial localhost
|
|
32
|
+
if (typeof nats === "boolean") {
|
|
33
|
+
if (serverBridge) return { transport: createNatsClusterTransport(serverBridge) };
|
|
34
|
+
const own = createNatsBridge(
|
|
35
|
+
{ servers: ["nats://localhost:4222"], inbound: false },
|
|
36
|
+
undefined,
|
|
37
|
+
bindings,
|
|
38
|
+
);
|
|
39
|
+
return { transport: createNatsClusterTransport(own), ownBridge: own };
|
|
40
|
+
}
|
|
41
|
+
// a pre-built bridge (`status` is bridge-only surface) vs raw options
|
|
42
|
+
if ("status" in nats) return { transport: createNatsClusterTransport(nats) };
|
|
43
|
+
const own = createNatsBridge(nats as NatsBridgeOptions, undefined, bindings);
|
|
44
|
+
return { transport: createNatsClusterTransport(own), ownBridge: own };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (opts.redis) return { transport: createRedisClusterTransport(opts.redis, onError) };
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events layer — internal barrel. Public entry: `public/events.ts`
|
|
3
|
+
* (`ignex-nova/events`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createClientStore, createEventClient } from "./clients";
|
|
7
|
+
export {
|
|
8
|
+
createClusterSubjects,
|
|
9
|
+
createClusterSync,
|
|
10
|
+
createMemoryStateStore,
|
|
11
|
+
createNatsClusterTransport,
|
|
12
|
+
createRedisClusterTransport,
|
|
13
|
+
createRedisStateStore,
|
|
14
|
+
decodeClusterMessage,
|
|
15
|
+
encodeClusterMessage,
|
|
16
|
+
} from "./cluster";
|
|
17
|
+
export { createClientData } from "./data";
|
|
18
|
+
export { createEmitter, deliverLocal } from "./emit";
|
|
19
|
+
export {
|
|
20
|
+
bindEvents,
|
|
21
|
+
emit,
|
|
22
|
+
emitToClient,
|
|
23
|
+
emitToGroup,
|
|
24
|
+
emitToTopic,
|
|
25
|
+
emitToUser,
|
|
26
|
+
getEventsHub,
|
|
27
|
+
isEventsBound,
|
|
28
|
+
off,
|
|
29
|
+
offAny,
|
|
30
|
+
offServerEvent,
|
|
31
|
+
on,
|
|
32
|
+
onAny,
|
|
33
|
+
once,
|
|
34
|
+
onServerEvent,
|
|
35
|
+
unbindEvents,
|
|
36
|
+
} from "./global";
|
|
37
|
+
export { createGroupManager } from "./groups";
|
|
38
|
+
export type { EventsHubInternal } from "./hub";
|
|
39
|
+
export { createEventsHub } from "./hub";
|
|
40
|
+
export { createTaskQueue } from "./queue";
|
|
41
|
+
export { createHandlerRegistry } from "./registry";
|
|
42
|
+
export type {
|
|
43
|
+
ClientData,
|
|
44
|
+
ClientGroup,
|
|
45
|
+
ClusterStateStore,
|
|
46
|
+
ClusterTransport,
|
|
47
|
+
EmitTarget,
|
|
48
|
+
EmitTargetKind,
|
|
49
|
+
EventClient,
|
|
50
|
+
EventContext,
|
|
51
|
+
EventHandler,
|
|
52
|
+
EventSource,
|
|
53
|
+
EventsClusterOptions,
|
|
54
|
+
EventsHub,
|
|
55
|
+
EventsMetricsSnapshot,
|
|
56
|
+
EventsOptions,
|
|
57
|
+
RedisConnectionOptions,
|
|
58
|
+
RemoteClient,
|
|
59
|
+
ServerEventHandler,
|
|
60
|
+
UserGroup,
|
|
61
|
+
} from "./types";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offload task queue — a small bounded worker pool that keeps ALL non-essential
|
|
3
|
+
* work (cluster publishes, Redis/state-store writes, presence maintenance) off
|
|
4
|
+
* the WebSocket hot path.
|
|
5
|
+
*
|
|
6
|
+
* Contract:
|
|
7
|
+
* - `enqueue` is O(1) and never throws; when the queue is full (over
|
|
8
|
+
* `maxPending`) the NEWEST task is dropped and counted (`drop-newest` —
|
|
9
|
+
* fresher state is more valuable than stale queued work).
|
|
10
|
+
* - Tasks may be async; workers await them with bounded concurrency
|
|
11
|
+
* (`workers`), so one slow Redis call can't stall the loop.
|
|
12
|
+
* - Errors are caught per task, counted, and forwarded to `onError`
|
|
13
|
+
* (default: nothing) — a failing task never takes the process down and
|
|
14
|
+
* never breaks the caller's hot path.
|
|
15
|
+
* - `close()` stops accepting; `drain()` resolves once queued + in-flight
|
|
16
|
+
* work has completed (used by graceful shutdown).
|
|
17
|
+
*
|
|
18
|
+
* Efficiency: storage is a pre-allocated {@link RingBuffer} (O(1) push/shift,
|
|
19
|
+
* no `Array.shift` memmove while saturated) and IDLE WORKERS PARK on a
|
|
20
|
+
* promise — zero timers, zero `setImmediate` spins, zero CPU while idle.
|
|
21
|
+
* `enqueue` wakes exactly one parked worker; completion notifications drive
|
|
22
|
+
* `drain()`.
|
|
23
|
+
*/
|
|
24
|
+
import { RingBuffer } from "../core/ring";
|
|
25
|
+
|
|
26
|
+
export interface TaskQueue {
|
|
27
|
+
readonly pending: number;
|
|
28
|
+
enqueue(task: () => void | Promise<void>): void;
|
|
29
|
+
drain(): Promise<void>;
|
|
30
|
+
close(): void;
|
|
31
|
+
stats(): { queued: number; processed: number; dropped: number; errors: number };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TaskQueueOptions {
|
|
35
|
+
/** concurrent workers, default 2 */
|
|
36
|
+
workers?: number;
|
|
37
|
+
/** max queued tasks before drop-newest, default 4096 */
|
|
38
|
+
maxPending?: number;
|
|
39
|
+
onError?: (err: Error) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createTaskQueue(opts: TaskQueueOptions = {}): TaskQueue {
|
|
43
|
+
const workersN = Math.max(1, opts.workers ?? 2);
|
|
44
|
+
const maxPending = Math.max(1, opts.maxPending ?? 4096);
|
|
45
|
+
const onError = opts.onError;
|
|
46
|
+
const tasks = new RingBuffer<() => void | Promise<void>>(Math.min(maxPending, 64));
|
|
47
|
+
let queued = 0;
|
|
48
|
+
let processed = 0;
|
|
49
|
+
let dropped = 0;
|
|
50
|
+
let errors = 0;
|
|
51
|
+
let inflight = 0;
|
|
52
|
+
let stopping = false;
|
|
53
|
+
|
|
54
|
+
// parked-worker wakeups: enqueue resolves one sleeper; close resolves all
|
|
55
|
+
const sleepers: Array<() => void> = [];
|
|
56
|
+
const park = (): Promise<void> => new Promise((resolve) => sleepers.push(resolve));
|
|
57
|
+
const wakeOne = (): void => {
|
|
58
|
+
const w = sleepers.shift();
|
|
59
|
+
if (w !== undefined) w();
|
|
60
|
+
};
|
|
61
|
+
const wakeAll = (): void => {
|
|
62
|
+
while (sleepers.length > 0) sleepers.shift()!();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// drain notification: resolved once the queue AND in-flight work hit zero
|
|
66
|
+
const drainWaiters: Array<() => void> = [];
|
|
67
|
+
const notifyIdle = (): void => {
|
|
68
|
+
if (drainWaiters.length === 0 || tasks.length > 0 || inflight > 0) return;
|
|
69
|
+
const waiters = drainWaiters.splice(0, drainWaiters.length);
|
|
70
|
+
for (const w of waiters) w();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const worker = async (): Promise<void> => {
|
|
74
|
+
for (;;) {
|
|
75
|
+
const task = tasks.shift();
|
|
76
|
+
if (task === undefined) {
|
|
77
|
+
if (stopping) return;
|
|
78
|
+
await park(); // parked — no CPU while idle
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
inflight++;
|
|
82
|
+
try {
|
|
83
|
+
await task();
|
|
84
|
+
processed++;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
errors++;
|
|
87
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
88
|
+
} finally {
|
|
89
|
+
inflight--;
|
|
90
|
+
}
|
|
91
|
+
notifyIdle();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < workersN; i++) void worker();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
get pending(): number {
|
|
99
|
+
return tasks.length + inflight;
|
|
100
|
+
},
|
|
101
|
+
enqueue(task) {
|
|
102
|
+
if (stopping) return;
|
|
103
|
+
if (tasks.length >= maxPending) {
|
|
104
|
+
dropped++;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
tasks.push(task);
|
|
108
|
+
queued++;
|
|
109
|
+
wakeOne();
|
|
110
|
+
},
|
|
111
|
+
drain() {
|
|
112
|
+
if (tasks.length === 0 && inflight === 0) return Promise.resolve();
|
|
113
|
+
return new Promise<void>((resolve) => drainWaiters.push(resolve));
|
|
114
|
+
},
|
|
115
|
+
close() {
|
|
116
|
+
stopping = true;
|
|
117
|
+
wakeAll(); // let parked workers see `stopping` and exit
|
|
118
|
+
},
|
|
119
|
+
stats() {
|
|
120
|
+
return { queued, processed, dropped, errors };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler registry — the "events file receives events" core. An ordered,
|
|
3
|
+
* multi-handler-per-event registry with per-handler error isolation:
|
|
4
|
+
* one throwing handler never prevents the others, and every failure is
|
|
5
|
+
* counted (surfaced via `hub.metrics().handlerErrors`).
|
|
6
|
+
*
|
|
7
|
+
* Supports `on` / `off` / `once` / `onAny` / `removeAllListeners` and a
|
|
8
|
+
* separate server-event registry (`onServerEvent`) for events that arrive
|
|
9
|
+
* from other instances or the NATS bridge.
|
|
10
|
+
*
|
|
11
|
+
* The registry is BINDINGS-AGNOSTIC: names and payloads are `unknown` here,
|
|
12
|
+
* and the `ctx` is passed through opaquely (the hub types it as
|
|
13
|
+
* `EventContext<B>` at the boundary).
|
|
14
|
+
*
|
|
15
|
+
* Dispatch is ALLOCATION-FREE by construction (copy-on-write): handler lists
|
|
16
|
+
* are plain arrays that are REPLACED on every mutation (`on`/`off`/`once`),
|
|
17
|
+
* so `dispatch` iterates its snapshot directly — no `[...set]` copies on the
|
|
18
|
+
* per-event hot path, and a handler that subscribes/unsubscribes mid-dispatch
|
|
19
|
+
* can never corrupt the in-flight iteration.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Opaque context — the hub hands a typed `EventContext<B>` through. */
|
|
23
|
+
export type DispatchContext = unknown;
|
|
24
|
+
|
|
25
|
+
type AnyHandler = (payload: unknown, ctx: DispatchContext) => void | Promise<void>;
|
|
26
|
+
|
|
27
|
+
export interface HandlerRegistry {
|
|
28
|
+
on(name: string, handler: AnyHandler): void;
|
|
29
|
+
off(name: string, handler?: AnyHandler): void;
|
|
30
|
+
once(name: string, handler: AnyHandler): void;
|
|
31
|
+
onAny(cb: AnyHandler): void;
|
|
32
|
+
offAny(cb: AnyHandler): void;
|
|
33
|
+
has(name: string): boolean;
|
|
34
|
+
names(): string[];
|
|
35
|
+
count(name: string): number;
|
|
36
|
+
removeAll(name?: string): void;
|
|
37
|
+
/** dispatch to every handler for `name` + every onAny handler. Isolated. */
|
|
38
|
+
dispatch(name: string, payload: unknown, ctx: DispatchContext): void;
|
|
39
|
+
/**
|
|
40
|
+
* Like `dispatch` / `dispatchServerEvent`, but resolves AFTER every matched
|
|
41
|
+
* handler settled (sync throw caught, async rejection awaited), collecting
|
|
42
|
+
* the errors. Allocation-per-call — used by the reliability layer
|
|
43
|
+
* (`delivery.ts`) for retry/DLQ decisions; the hot path keeps using
|
|
44
|
+
* `dispatch`.
|
|
45
|
+
*/
|
|
46
|
+
settleDispatch(name: string, payload: unknown, ctx: DispatchContext, mode?: "client" | "server"): Promise<Error[]>;
|
|
47
|
+
/** register a server-side handler (remote / bridge events only) */
|
|
48
|
+
onServerEvent(name: string, handler: AnyHandler): void;
|
|
49
|
+
offServerEvent(name: string, handler?: AnyHandler): void;
|
|
50
|
+
/** true when a server-event handler exists for `name` (avoids payload decode). */
|
|
51
|
+
wantsServerEvent(name: string): boolean;
|
|
52
|
+
dispatchServerEvent(name: string, payload: unknown, ctx: DispatchContext): void;
|
|
53
|
+
onError(cb: (err: Error, name: string) => void): void;
|
|
54
|
+
/** total handler exceptions since creation */
|
|
55
|
+
readonly errorCount: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function invoke(handler: AnyHandler, payload: unknown, ctx: DispatchContext, onError: (err: Error) => void): void {
|
|
59
|
+
try {
|
|
60
|
+
const r = handler(payload, ctx);
|
|
61
|
+
if (r && typeof (r as Promise<void>).then === "function") {
|
|
62
|
+
void (r as Promise<void>).catch((err: unknown) => onError(err instanceof Error ? err : new Error(String(err))));
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── copy-on-write list helpers ──────────────────────────────────────────────
|
|
70
|
+
// Mutations allocate (the write path); dispatch only reads.
|
|
71
|
+
|
|
72
|
+
const cowAdd = (arr: AnyHandler[] | undefined, h: AnyHandler): AnyHandler[] =>
|
|
73
|
+
arr === undefined ? [h] : [...arr, h];
|
|
74
|
+
|
|
75
|
+
const cowRemove = (arr: AnyHandler[] | undefined, h?: AnyHandler): AnyHandler[] | undefined => {
|
|
76
|
+
if (arr === undefined) return undefined;
|
|
77
|
+
if (h === undefined) return undefined; // remove whole list
|
|
78
|
+
const next = arr.filter((x) => x !== h);
|
|
79
|
+
return next.length === 0 ? undefined : next;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function createHandlerRegistry(): HandlerRegistry {
|
|
83
|
+
const handlers = new Map<string, AnyHandler[]>();
|
|
84
|
+
let anyHandlers: AnyHandler[] = [];
|
|
85
|
+
const serverHandlers = new Map<string, AnyHandler[]>();
|
|
86
|
+
const errorCbs: Array<(err: Error, name: string) => void> = [];
|
|
87
|
+
let errorCount = 0;
|
|
88
|
+
|
|
89
|
+
const report = (err: Error, name: string): void => {
|
|
90
|
+
errorCount++;
|
|
91
|
+
for (const cb of errorCbs) {
|
|
92
|
+
try {
|
|
93
|
+
cb(err, name);
|
|
94
|
+
} catch {
|
|
95
|
+
// error reporters must never break dispatch
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
on(name, handler) {
|
|
102
|
+
handlers.set(name, cowAdd(handlers.get(name), handler));
|
|
103
|
+
},
|
|
104
|
+
off(name, handler) {
|
|
105
|
+
if (!handler) {
|
|
106
|
+
handlers.delete(name);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const next = cowRemove(handlers.get(name), handler);
|
|
110
|
+
if (next === undefined) handlers.delete(name);
|
|
111
|
+
else handlers.set(name, next);
|
|
112
|
+
},
|
|
113
|
+
once(name, handler) {
|
|
114
|
+
const wrap: AnyHandler = (payload, ctx) => {
|
|
115
|
+
// remove FIRST so a re-entrant fire cannot invoke it twice
|
|
116
|
+
const cur = handlers.get(name);
|
|
117
|
+
if (cur !== undefined) {
|
|
118
|
+
const next = cowRemove(cur, wrap);
|
|
119
|
+
if (next === undefined) handlers.delete(name);
|
|
120
|
+
else handlers.set(name, next);
|
|
121
|
+
}
|
|
122
|
+
return handler(payload, ctx);
|
|
123
|
+
};
|
|
124
|
+
handlers.set(name, cowAdd(handlers.get(name), wrap));
|
|
125
|
+
},
|
|
126
|
+
onAny(cb) {
|
|
127
|
+
anyHandlers = [...anyHandlers, cb];
|
|
128
|
+
},
|
|
129
|
+
offAny(cb) {
|
|
130
|
+
const next = anyHandlers.filter((x) => x !== cb);
|
|
131
|
+
anyHandlers = next;
|
|
132
|
+
},
|
|
133
|
+
has(name) {
|
|
134
|
+
return handlers.has(name) || anyHandlers.length > 0;
|
|
135
|
+
},
|
|
136
|
+
names() {
|
|
137
|
+
return [...handlers.keys()];
|
|
138
|
+
},
|
|
139
|
+
count(name) {
|
|
140
|
+
return handlers.get(name)?.length ?? 0;
|
|
141
|
+
},
|
|
142
|
+
removeAll(name) {
|
|
143
|
+
if (name) handlers.delete(name);
|
|
144
|
+
else handlers.clear();
|
|
145
|
+
},
|
|
146
|
+
dispatch(name, payload, ctx) {
|
|
147
|
+
const list = handlers.get(name);
|
|
148
|
+
if (list !== undefined) {
|
|
149
|
+
for (let i = 0; i < list.length; i++) {
|
|
150
|
+
const h = list[i];
|
|
151
|
+
if (h !== undefined) invoke(h, payload, ctx, (err) => report(err, name));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const anys = anyHandlers;
|
|
155
|
+
for (let i = 0; i < anys.length; i++) {
|
|
156
|
+
const h = anys[i];
|
|
157
|
+
if (h === undefined) continue;
|
|
158
|
+
invoke(
|
|
159
|
+
(p) => {
|
|
160
|
+
(h as (n: string, p: unknown, c: DispatchContext) => unknown)(name, p, ctx);
|
|
161
|
+
},
|
|
162
|
+
payload,
|
|
163
|
+
ctx,
|
|
164
|
+
(err) => report(err, name),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
wantsServerEvent(name) {
|
|
169
|
+
return (serverHandlers.get(name)?.length ?? 0) > 0;
|
|
170
|
+
},
|
|
171
|
+
async settleDispatch(name, payload, ctx, mode = "client") {
|
|
172
|
+
const errors: Error[] = [];
|
|
173
|
+
const run = async (h: AnyHandler): Promise<void> => {
|
|
174
|
+
try {
|
|
175
|
+
await h(payload, ctx);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
178
|
+
report(errors[errors.length - 1]!, name);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const jobs: Promise<void>[] = [];
|
|
182
|
+
const source = mode === "server" ? serverHandlers.get(name) : handlers.get(name);
|
|
183
|
+
if (source !== undefined) for (const h of source) jobs.push(run(h));
|
|
184
|
+
await Promise.all(jobs);
|
|
185
|
+
return errors;
|
|
186
|
+
},
|
|
187
|
+
onServerEvent(name, handler) {
|
|
188
|
+
serverHandlers.set(name, cowAdd(serverHandlers.get(name), handler));
|
|
189
|
+
},
|
|
190
|
+
offServerEvent(name, handler) {
|
|
191
|
+
if (!handler) {
|
|
192
|
+
serverHandlers.delete(name);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const next = cowRemove(serverHandlers.get(name), handler);
|
|
196
|
+
if (next === undefined) serverHandlers.delete(name);
|
|
197
|
+
else serverHandlers.set(name, next);
|
|
198
|
+
},
|
|
199
|
+
dispatchServerEvent(name, payload, ctx) {
|
|
200
|
+
const list = serverHandlers.get(name);
|
|
201
|
+
if (list === undefined) return;
|
|
202
|
+
for (let i = 0; i < list.length; i++) {
|
|
203
|
+
const h = list[i];
|
|
204
|
+
if (h !== undefined) invoke(h, payload, ctx, (err) => report(err, name));
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
onError(cb) {
|
|
208
|
+
errorCbs.push(cb);
|
|
209
|
+
},
|
|
210
|
+
get errorCount(): number {
|
|
211
|
+
return errorCount;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled emits — delayed/deferred event delivery with cancellation.
|
|
3
|
+
* Backs `hub.schedule(name, payload, target?, delayMs)` (time-based events:
|
|
4
|
+
* reminder pushes, cooldowns, TTL'd notices).
|
|
5
|
+
*
|
|
6
|
+
* Implementation is a plain per-entry `setTimeout` wheel: scheduling volume in
|
|
7
|
+
* an events hub is human-scale (thousands), where timer accuracy and O(1)
|
|
8
|
+
* cancel beat a heap's memory compactness. Entries are tracked in a Map so
|
|
9
|
+
* `cancel(id)` is exact; `clear()` drops everything (used on close). Timers
|
|
10
|
+
* never reference sockets directly — they call the hub's emit at fire time,
|
|
11
|
+
* so cluster routing / bridge semantics stay identical to a normal emit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type EmitFn = (name: string, payload: unknown, target?: unknown) => void;
|
|
15
|
+
|
|
16
|
+
export interface Scheduler {
|
|
17
|
+
/** schedule one emit; returns its id (cancel with it) */
|
|
18
|
+
schedule(name: string, payload: unknown, target: unknown, delayMs: number): string;
|
|
19
|
+
/** cancel a scheduled emit — true when it had not fired yet */
|
|
20
|
+
cancel(id: string): boolean;
|
|
21
|
+
/** ids currently pending */
|
|
22
|
+
pending(): string[];
|
|
23
|
+
readonly size: number;
|
|
24
|
+
clear(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Entry {
|
|
28
|
+
timer: ReturnType<typeof setTimeout>;
|
|
29
|
+
name: string;
|
|
30
|
+
payload: unknown;
|
|
31
|
+
target: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createScheduler(emit: EmitFn): Scheduler {
|
|
35
|
+
const entries = new Map<string, Entry>();
|
|
36
|
+
|
|
37
|
+
const fire = (id: string): void => {
|
|
38
|
+
const e = entries.get(id);
|
|
39
|
+
if (!e) return;
|
|
40
|
+
entries.delete(id);
|
|
41
|
+
try {
|
|
42
|
+
emit(e.name, e.payload, e.target);
|
|
43
|
+
} catch {
|
|
44
|
+
// emit must not throw (hub guards this too)
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
schedule(name, payload, target, delayMs) {
|
|
50
|
+
const id = crypto.randomUUID();
|
|
51
|
+
const ms = Math.max(0, delayMs);
|
|
52
|
+
entries.set(id, { name, payload, target, timer: setTimeout(() => fire(id), ms) });
|
|
53
|
+
return id;
|
|
54
|
+
},
|
|
55
|
+
cancel(id) {
|
|
56
|
+
const e = entries.get(id);
|
|
57
|
+
if (!e) return false;
|
|
58
|
+
clearTimeout(e.timer);
|
|
59
|
+
entries.delete(id);
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
pending() {
|
|
63
|
+
return [...entries.keys()];
|
|
64
|
+
},
|
|
65
|
+
get size(): number {
|
|
66
|
+
return entries.size;
|
|
67
|
+
},
|
|
68
|
+
clear() {
|
|
69
|
+
for (const e of entries.values()) clearTimeout(e.timer);
|
|
70
|
+
entries.clear();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|