@ignex/nova 0.1.3 → 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 (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 +42 -0
  26. package/src/core/client-wire.ts +142 -8
  27. package/src/core/client.ts +72 -3
  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,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
+ }
@@ -14,7 +14,15 @@
14
14
  * never breaks the caller's hot path.
15
15
  * - `close()` stops accepting; `drain()` resolves once queued + in-flight
16
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()`.
17
23
  */
24
+ import { RingBuffer } from "../core/ring";
25
+
18
26
  export interface TaskQueue {
19
27
  readonly pending: number;
20
28
  enqueue(task: () => void | Promise<void>): void;
@@ -35,7 +43,7 @@ export function createTaskQueue(opts: TaskQueueOptions = {}): TaskQueue {
35
43
  const workersN = Math.max(1, opts.workers ?? 2);
36
44
  const maxPending = Math.max(1, opts.maxPending ?? 4096);
37
45
  const onError = opts.onError;
38
- const tasks: Array<() => void | Promise<void>> = [];
46
+ const tasks = new RingBuffer<() => void | Promise<void>>(Math.min(maxPending, 64));
39
47
  let queued = 0;
40
48
  let processed = 0;
41
49
  let dropped = 0;
@@ -43,14 +51,31 @@ export function createTaskQueue(opts: TaskQueueOptions = {}): TaskQueue {
43
51
  let inflight = 0;
44
52
  let stopping = false;
45
53
 
46
- const yieldToLoop = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
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
+ };
47
72
 
48
73
  const worker = async (): Promise<void> => {
49
74
  for (;;) {
50
- if (stopping && tasks.length === 0) return;
51
75
  const task = tasks.shift();
52
- if (!task) {
53
- await yieldToLoop();
76
+ if (task === undefined) {
77
+ if (stopping) return;
78
+ await park(); // parked — no CPU while idle
54
79
  continue;
55
80
  }
56
81
  inflight++;
@@ -63,6 +88,7 @@ export function createTaskQueue(opts: TaskQueueOptions = {}): TaskQueue {
63
88
  } finally {
64
89
  inflight--;
65
90
  }
91
+ notifyIdle();
66
92
  }
67
93
  };
68
94
 
@@ -80,14 +106,15 @@ export function createTaskQueue(opts: TaskQueueOptions = {}): TaskQueue {
80
106
  }
81
107
  tasks.push(task);
82
108
  queued++;
109
+ wakeOne();
83
110
  },
84
- async drain() {
85
- while (tasks.length > 0 || inflight > 0) {
86
- await yieldToLoop();
87
- }
111
+ drain() {
112
+ if (tasks.length === 0 && inflight === 0) return Promise.resolve();
113
+ return new Promise<void>((resolve) => drainWaiters.push(resolve));
88
114
  },
89
115
  close() {
90
116
  stopping = true;
117
+ wakeAll(); // let parked workers see `stopping` and exit
91
118
  },
92
119
  stats() {
93
120
  return { queued, processed, dropped, errors };
@@ -11,6 +11,12 @@
11
11
  * The registry is BINDINGS-AGNOSTIC: names and payloads are `unknown` here,
12
12
  * and the `ctx` is passed through opaquely (the hub types it as
13
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.
14
20
  */
15
21
 
16
22
  /** Opaque context — the hub hands a typed `EventContext<B>` through. */
@@ -30,6 +36,14 @@ export interface HandlerRegistry {
30
36
  removeAll(name?: string): void;
31
37
  /** dispatch to every handler for `name` + every onAny handler. Isolated. */
32
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[]>;
33
47
  /** register a server-side handler (remote / bridge events only) */
34
48
  onServerEvent(name: string, handler: AnyHandler): void;
35
49
  offServerEvent(name: string, handler?: AnyHandler): void;
@@ -52,10 +66,23 @@ function invoke(handler: AnyHandler, payload: unknown, ctx: DispatchContext, onE
52
66
  }
53
67
  }
54
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
+
55
82
  export function createHandlerRegistry(): HandlerRegistry {
56
- const handlers = new Map<string, Set<AnyHandler>>();
57
- const anyHandlers = new Set<AnyHandler>();
58
- const serverHandlers = new Map<string, Set<AnyHandler>>();
83
+ const handlers = new Map<string, AnyHandler[]>();
84
+ let anyHandlers: AnyHandler[] = [];
85
+ const serverHandlers = new Map<string, AnyHandler[]>();
59
86
  const errorCbs: Array<(err: Error, name: string) => void> = [];
60
87
  let errorCount = 0;
61
88
 
@@ -70,102 +97,111 @@ export function createHandlerRegistry(): HandlerRegistry {
70
97
  }
71
98
  };
72
99
 
73
- const add = (name: string, handler: AnyHandler): void => {
74
- let set = handlers.get(name);
75
- if (!set) {
76
- set = new Set();
77
- handlers.set(name, set);
78
- }
79
- set.add(handler);
80
- };
81
-
82
- const addServer = (name: string, handler: AnyHandler): void => {
83
- let set = serverHandlers.get(name);
84
- if (!set) {
85
- set = new Set();
86
- serverHandlers.set(name, set);
87
- }
88
- set.add(handler);
89
- };
90
-
91
100
  return {
92
101
  on(name, handler) {
93
- add(name, handler);
102
+ handlers.set(name, cowAdd(handlers.get(name), handler));
94
103
  },
95
104
  off(name, handler) {
96
105
  if (!handler) {
97
106
  handlers.delete(name);
98
107
  return;
99
108
  }
100
- handlers.get(name)?.delete(handler);
109
+ const next = cowRemove(handlers.get(name), handler);
110
+ if (next === undefined) handlers.delete(name);
111
+ else handlers.set(name, next);
101
112
  },
102
113
  once(name, handler) {
103
114
  const wrap: AnyHandler = (payload, ctx) => {
104
- handlers.get(name)?.delete(wrap);
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
+ }
105
122
  return handler(payload, ctx);
106
123
  };
107
- add(name, wrap);
124
+ handlers.set(name, cowAdd(handlers.get(name), wrap));
108
125
  },
109
126
  onAny(cb) {
110
- anyHandlers.add(cb);
127
+ anyHandlers = [...anyHandlers, cb];
111
128
  },
112
129
  offAny(cb) {
113
- anyHandlers.delete(cb);
130
+ const next = anyHandlers.filter((x) => x !== cb);
131
+ anyHandlers = next;
114
132
  },
115
133
  has(name) {
116
- return handlers.has(name) || anyHandlers.size > 0;
134
+ return handlers.has(name) || anyHandlers.length > 0;
117
135
  },
118
136
  names() {
119
137
  return [...handlers.keys()];
120
138
  },
121
139
  count(name) {
122
- return handlers.get(name)?.size ?? 0;
140
+ return handlers.get(name)?.length ?? 0;
123
141
  },
124
142
  removeAll(name) {
125
143
  if (name) handlers.delete(name);
126
144
  else handlers.clear();
127
145
  },
128
146
  dispatch(name, payload, ctx) {
129
- const set = handlers.get(name);
130
- if (set) {
131
- const snapshot = [...set];
132
- for (const h of snapshot) {
133
- invoke(h, payload, ctx, (err) => report(err, name));
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));
134
152
  }
135
153
  }
136
- if (anyHandlers.size > 0) {
137
- const snapshot = [...anyHandlers];
138
- for (const h of snapshot) {
139
- invoke(
140
- (p) => {
141
- (h as (n: string, p: unknown, c: DispatchContext) => unknown)(name, p, ctx);
142
- },
143
- payload,
144
- ctx,
145
- (err) => report(err, name),
146
- );
147
- }
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
+ );
148
166
  }
149
167
  },
150
168
  wantsServerEvent(name) {
151
- return (serverHandlers.get(name)?.size ?? 0) > 0;
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;
152
186
  },
153
187
  onServerEvent(name, handler) {
154
- addServer(name, handler);
188
+ serverHandlers.set(name, cowAdd(serverHandlers.get(name), handler));
155
189
  },
156
190
  offServerEvent(name, handler) {
157
191
  if (!handler) {
158
192
  serverHandlers.delete(name);
159
193
  return;
160
194
  }
161
- serverHandlers.get(name)?.delete(handler);
195
+ const next = cowRemove(serverHandlers.get(name), handler);
196
+ if (next === undefined) serverHandlers.delete(name);
197
+ else serverHandlers.set(name, next);
162
198
  },
163
199
  dispatchServerEvent(name, payload, ctx) {
164
- const set = serverHandlers.get(name);
165
- if (!set) return;
166
- const snapshot = [...set];
167
- for (const h of snapshot) {
168
- invoke(h, payload, ctx, (err) => report(err, name));
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));
169
205
  }
170
206
  },
171
207
  onError(cb) {
@@ -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
+ }