@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,86 @@
1
+ /**
2
+ * Durable topic-log seam — the pluggable backend behind the bounded replay
3
+ * ring. The ring keeps the LAST N frames per topic in memory; a {@link TopicLog}
4
+ * receives the same frames so subscribers can resume from points the ring has
5
+ * already forgotten.
6
+ *
7
+ * Contract (deliberately narrow):
8
+ * - `append` is SYNCHRONOUS and must never throw onto the publish hot path —
9
+ * implementations buffer internally and flush on their own schedule (the
10
+ * memory impl appends to an array; a file impl would hand off to a writer;
11
+ * a NATS JetStream / Redis Streams impl would enqueue a publish).
12
+ * - `range(topic, afterSeq, limit?)` returns frames strictly AFTER `afterSeq`
13
+ * oldest → newest, synchronously. Adapters over remote stores should
14
+ * maintain a local read-through cache so this stays sync-friendly.
15
+ * - `latestSeq(topic)` mirrors the server's replay-seq counter for the topic
16
+ * (0 = unknown/empty).
17
+ *
18
+ * Ship-with implementation: {@link createMemoryTopicLog} — per-topic bounded
19
+ * array (drop-oldest), process-local durability (survives ring overflow, not a
20
+ * restart). Production adapters (JetStream / Redis Streams / filesystem)
21
+ * implement the same three methods — see docs/architecture.md ("Durability").
22
+ */
23
+ import { RingBuffer } from "./ring";
24
+
25
+ /** One durably-retained topic frame. */
26
+ export interface LoggedFrame {
27
+ /** global replay seq (the same counter stamped into the topic history) */
28
+ seq: number;
29
+ frame: Uint8Array;
30
+ }
31
+
32
+ export interface TopicLog {
33
+ /** Record a frame for `topic` (fire-and-forget; never throws). */
34
+ append(topic: string, frame: Uint8Array, seq: number): void;
35
+ /** Frames strictly after `afterSeq`, oldest → newest (at most `limit`). */
36
+ range(topic: string, afterSeq: number, limit?: number): LoggedFrame[];
37
+ /** Highest seq retained for `topic` (0 = none). */
38
+ latestSeq(topic: string): number;
39
+ /** Release resources (flush buffers, close files/connections). */
40
+ close(): void;
41
+ }
42
+
43
+ export interface MemoryTopicLogOptions {
44
+ /** max frames retained PER TOPIC (drop-oldest beyond), default 10_000 */
45
+ maxPerTopic?: number;
46
+ }
47
+
48
+ /** Process-local durable log: survives ring overflow, not a restart. */
49
+ export function createMemoryTopicLog(
50
+ opts: MemoryTopicLogOptions = {},
51
+ ): TopicLog {
52
+ const max = Math.max(1, opts.maxPerTopic ?? 10_000);
53
+ const topics = new Map<string, { frames: RingBuffer<LoggedFrame>; latest: number }>();
54
+ const ensure = (topic: string) => {
55
+ let t = topics.get(topic);
56
+ if (!t) {
57
+ t = { frames: new RingBuffer<LoggedFrame>(max, true), latest: 0 };
58
+ topics.set(topic, t);
59
+ }
60
+ return t;
61
+ };
62
+ return {
63
+ append(topic, frame, seq) {
64
+ const t = ensure(topic);
65
+ t.frames.push({ seq, frame: frame.slice() });
66
+ if (seq > t.latest) t.latest = seq;
67
+ },
68
+ range(topic, afterSeq, limit) {
69
+ const t = topics.get(topic);
70
+ if (!t) return [];
71
+ const out: LoggedFrame[] = [];
72
+ for (const e of t.frames) {
73
+ if (e.seq <= afterSeq) continue;
74
+ out.push(e);
75
+ if (limit !== undefined && out.length >= limit) break;
76
+ }
77
+ return out;
78
+ },
79
+ latestSeq(topic) {
80
+ return topics.get(topic)?.latest ?? 0;
81
+ },
82
+ close() {
83
+ topics.clear();
84
+ },
85
+ };
86
+ }
@@ -67,6 +67,11 @@ export interface ClientStore {
67
67
  get(id: string): EventClient | undefined;
68
68
  all(): EventClient[];
69
69
  byUser(userId: string): EventClient[];
70
+ /**
71
+ * Invoke `each` for every live socket of `userId`; returns the count.
72
+ * Allocation-free variant of {@link byUser} for emit hot paths.
73
+ */
74
+ forEachByUser(userId: string, each: (client: EventClient) => void): number;
70
75
  setUserId(clientId: string, userId: string): boolean;
71
76
  onAttach(cb: (client: EventClient) => void): void;
72
77
  onDetach(cb: (client: EventClient) => void): void;
@@ -136,6 +141,19 @@ export function createClientStore(): ClientStore {
136
141
  }
137
142
  return out;
138
143
  },
144
+ forEachByUser(userId, each) {
145
+ const ids = byUser.get(userId);
146
+ if (ids === undefined || ids.size === 0) return 0;
147
+ let n = 0;
148
+ for (const id of ids) {
149
+ const c = byId.get(id);
150
+ if (c !== undefined) {
151
+ each(c);
152
+ n++;
153
+ }
154
+ }
155
+ return n;
156
+ },
139
157
  setUserId(clientId, userId) {
140
158
  const client = byId.get(clientId);
141
159
  if (!client) return false;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Broker-redelivery dedupe window — bounded recent-message-id tracking.
3
+ *
4
+ * Durable brokers may redeliver; every processed message id is recorded in a
5
+ * ring + set pair and duplicates are dropped. Encapsulated factory (like
6
+ * `createMetrics`) — the state is private, the surface is one pure predicate.
7
+ */
8
+ import { RingBuffer } from "../../core/ring";
9
+
10
+ export interface DedupeWindow {
11
+ /**
12
+ * Record `id` and report whether it was ALREADY seen (true → drop the
13
+ * message). Empty ids and a zero-size window disable tracking entirely.
14
+ */
15
+ markSeen(id: string): boolean;
16
+ }
17
+
18
+ /**
19
+ * @param size how many message ids to remember (0 disables; values < 16 are
20
+ * clamped up so the ring has usable capacity).
21
+ */
22
+ export function createDedupeWindow(size: number): DedupeWindow {
23
+ const window = Math.max(0, size);
24
+ if (window === 0) return { markSeen: () => false };
25
+
26
+ const ring = new RingBuffer<string>(Math.max(16, window), true);
27
+ const seen = new Set<string>();
28
+
29
+ return {
30
+ markSeen(id: string): boolean {
31
+ if (id === "") return false;
32
+ if (seen.has(id)) return true;
33
+ // evict the oldest id when the window is full (FIFO — matches redelivery)
34
+ if (ring.length >= window) {
35
+ const evict = ring.shift();
36
+ if (evict !== undefined) seen.delete(evict);
37
+ }
38
+ ring.push(id);
39
+ seen.add(id);
40
+ return false;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Cluster envelope codec — the self-describing binary frame published to the
3
+ * broker. Routing never depends on broker channel syntax; everything a peer
4
+ * needs (origin, kind, key, event name, dedupe id, trace id) rides in the
5
+ * header so any transport works identically.
6
+ *
7
+ * Wire layout (v2):
8
+ * [envVer:1]
9
+ * [originLen:u8][origin:utf8][kind:u8][keyLen:u8][key:utf8][nameLen:u8][name:utf8]
10
+ * [msgIdLen:u8][msgId:utf8][traceLen:u8][trace:utf8]
11
+ * [frame:bytes]
12
+ *
13
+ * v2 adds the envelope VERSION byte, a message id (broker-level redelivery
14
+ * dedupe) and an optional trace id (cross-instance trace correlation). A v1
15
+ * peer's frames fail the version check and are counted as errors.
16
+ *
17
+ * Pure functions — no I/O, no shared state.
18
+ */
19
+ import {
20
+ CLUSTER_ENV_VERSION,
21
+ CLUSTER_KINDS,
22
+ CLUSTER_KIND_ID,
23
+ type ClusterKind,
24
+ clusterKindFromId,
25
+ } from "./kinds";
26
+
27
+ // module-global codecs: allocation happens once per process, not per message
28
+ const enc = new TextEncoder();
29
+ const dec = new TextDecoder();
30
+
31
+ /** One decoded cluster message (`frame` is a view into the input buffer). */
32
+ export interface ClusterEnvelope {
33
+ origin: string;
34
+ kind: ClusterKind;
35
+ key: string;
36
+ name: string;
37
+ frame: Uint8Array;
38
+ /** producer-assigned unique message id (dedupe across broker redeliveries) */
39
+ msgId: string;
40
+ /** optional cross-instance trace id */
41
+ traceId: string;
42
+ }
43
+
44
+ /** Write `[len:u8][bytes]` at `p`; returns the offset after the payload. */
45
+ function putLenPrefixed(out: Uint8Array, p: number, bytes: Uint8Array): number {
46
+ out[p] = bytes.byteLength;
47
+ out.set(bytes, p + 1);
48
+ return p + 1 + bytes.byteLength;
49
+ }
50
+
51
+ /** Encode all length-prefixed header strings up-front (also validates sizes). */
52
+ function encodeHeaderStrings(
53
+ origin: string,
54
+ key: string,
55
+ name: string,
56
+ msgId: string,
57
+ traceId: string,
58
+ ): [Uint8Array, Uint8Array, Uint8Array, Uint8Array, Uint8Array] {
59
+ const o = enc.encode(origin);
60
+ const k = enc.encode(key);
61
+ const n = enc.encode(name);
62
+ const m = enc.encode(msgId);
63
+ const t = enc.encode(traceId);
64
+ // length fields are single bytes — anything longer would silently wrap
65
+ // mod 256 and CORRUPT the frame for every peer; fail loudly instead
66
+ if (
67
+ o.byteLength > 255 ||
68
+ k.byteLength > 255 ||
69
+ n.byteLength > 255 ||
70
+ m.byteLength > 255 ||
71
+ t.byteLength > 255
72
+ ) {
73
+ throw new RangeError(
74
+ "ignex cluster: origin/key/name/msgId/trace exceed the 255-byte envelope limit " +
75
+ `(got ${o.byteLength}/${k.byteLength}/${n.byteLength}/${m.byteLength}/${t.byteLength})`,
76
+ );
77
+ }
78
+ return [o, k, n, m, t];
79
+ }
80
+
81
+ export function encodeClusterMessage(
82
+ origin: string,
83
+ kind: ClusterKind,
84
+ key: string,
85
+ name: string,
86
+ frame: Uint8Array,
87
+ msgId = "",
88
+ traceId = "",
89
+ ): Uint8Array {
90
+ const [o, k, n, m, t] = encodeHeaderStrings(origin, key, name, msgId, traceId);
91
+ // fixed bytes: envVer(1) originLen(1) kind(1) keyLen(1) nameLen(1) msgIdLen(1) traceLen(1)
92
+ const headerLen = 7 + o.byteLength + k.byteLength + n.byteLength + m.byteLength + t.byteLength;
93
+ const out = new Uint8Array(headerLen + frame.byteLength);
94
+ let p = 0;
95
+ out[p] = CLUSTER_ENV_VERSION;
96
+ p++;
97
+ p = putLenPrefixed(out, p, o);
98
+ out[p] = CLUSTER_KIND_ID[kind];
99
+ p++;
100
+ p = putLenPrefixed(out, p, k);
101
+ p = putLenPrefixed(out, p, n);
102
+ p = putLenPrefixed(out, p, m);
103
+ p = putLenPrefixed(out, p, t);
104
+ out.set(frame, p);
105
+ return out;
106
+ }
107
+
108
+ /** Read one length-prefixed string at `at`; `null` when truncated/malformed. */
109
+ function readLenPrefixed(
110
+ bytes: Uint8Array,
111
+ at: number,
112
+ ): { str: string; next: number } | null {
113
+ if (at >= bytes.byteLength) return null;
114
+ const len = bytes[at]!;
115
+ if (at + 1 + len > bytes.byteLength) return null;
116
+ return { str: dec.decode(bytes.subarray(at + 1, at + 1 + len)), next: at + 1 + len };
117
+ }
118
+
119
+ /**
120
+ * Decode a cluster envelope. Returns `null` for undecodable input or a
121
+ * foreign/legacy envelope version (callers count those as errors).
122
+ */
123
+ export function decodeClusterMessage(bytes: Uint8Array): ClusterEnvelope | null {
124
+ if (bytes.byteLength < 4) return null;
125
+ if (bytes[0] !== CLUSTER_ENV_VERSION) return null; // foreign / legacy envelope
126
+ const o = readLenPrefixed(bytes, 1);
127
+ if (!o) return null;
128
+ const kindId = bytes[o.next];
129
+ if (kindId === undefined || kindId >= CLUSTER_KINDS.length) return null;
130
+ const k = readLenPrefixed(bytes, o.next + 1);
131
+ if (!k) return null;
132
+ const n = readLenPrefixed(bytes, k.next);
133
+ if (!n) return null;
134
+ const m = readLenPrefixed(bytes, n.next);
135
+ if (!m) return null;
136
+ const t = readLenPrefixed(bytes, m.next);
137
+ if (!t) return null;
138
+ const kind = clusterKindFromId(kindId);
139
+ if (kind === undefined) return null;
140
+ return {
141
+ origin: o.str,
142
+ kind,
143
+ key: k.str,
144
+ name: n.str,
145
+ msgId: m.str,
146
+ traceId: t.str,
147
+ frame: bytes.subarray(t.next),
148
+ };
149
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cluster sync — barrel. Horizontal scaling for the events layer, decomposed
3
+ * by concern:
4
+ *
5
+ * kinds — routing-kind constants + wire ids
6
+ * envelope — the self-describing binary frame codec (pure)
7
+ * subjects — broker channel names derived from the prefix
8
+ * presence — presence message codec (join/leave/sync)
9
+ * presence-table — in-memory remote-presence index with TTL pruning
10
+ * dedupe — bounded broker-redelivery dedupe window
11
+ * keys — shared-state key builders
12
+ * sync — createClusterSync composition root
13
+ * transport-nats / transport-redis — ClusterTransport adapters
14
+ * store-memory / store-redis — ClusterStateStore adapters
15
+ *
16
+ * All cross-instance work is deferred to the offload queue — the emit call
17
+ * never blocks on a broker.
18
+ */
19
+
20
+ export { CLUSTER_ENV_VERSION, CLUSTER_KINDS, CLUSTER_KIND_ID, clusterKindFromId, type ClusterKind } from "./kinds";
21
+ export { decodeClusterMessage, encodeClusterMessage, type ClusterEnvelope } from "./envelope";
22
+ export { createClusterSubjects, type ClusterSubjects } from "./subjects";
23
+ export {
24
+ decodePresence,
25
+ encodePresence,
26
+ type PresenceJoin,
27
+ type PresenceLeave,
28
+ type PresenceMessage,
29
+ type PresenceSync,
30
+ } from "./presence";
31
+ export { createPresenceTable, type PresenceTable } from "./presence-table";
32
+ export { createDedupeWindow, type DedupeWindow } from "./dedupe";
33
+ export {
34
+ clientDataKey,
35
+ clientGroupStateKey,
36
+ parsePresenceMember,
37
+ presenceInstanceKey,
38
+ presenceUserKey,
39
+ userGroupStateKey,
40
+ } from "./keys";
41
+ export {
42
+ createClusterSync,
43
+ type ClusterMsgMeta,
44
+ type ClusterSync,
45
+ type ClusterSyncOptions,
46
+ } from "./sync";
47
+ export { createNatsClusterTransport } from "./transport-nats";
48
+ export { createRedisClusterTransport } from "./transport-redis";
49
+ export { createMemoryStateStore } from "./store-memory";
50
+ export { createRedisStateStore } from "./store-redis";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared-state key builders — the canonical key names used in the
3
+ * `ClusterStateStore` (Redis in production). Centralized so every instance
4
+ * agrees on the layout; pure string functions.
5
+ */
6
+
7
+ /** User-group membership set (`member` = userId). */
8
+ export const userGroupStateKey = (name: string): string => `ignex:group-users:${name}`;
9
+
10
+ /** Client-group membership set (`member` = clientId). */
11
+ export const clientGroupStateKey = (name: string): string => `ignex:group:${name}`;
12
+
13
+ /** Per-user presence index (`member` = `{instanceId}:{clientId}`). */
14
+ export const presenceUserKey = (userId: string): string => `ignex:presence:user:${userId}`;
15
+
16
+ /** Per-instance presence index (`member` = clientId). */
17
+ export const presenceInstanceKey = (instanceId: string): string =>
18
+ `ignex:presence:instance:${instanceId}`;
19
+
20
+ /** Client data blob (JSON string). */
21
+ export const clientDataKey = (clientId: string): string => `ignex:client-data:${clientId}`;
22
+
23
+ /**
24
+ * Split a `{instanceId}:{clientId}` presence member back into its parts.
25
+ * Returns `null` for malformed members (never crashes on foreign data).
26
+ */
27
+ export function parsePresenceMember(
28
+ member: string,
29
+ ): { instanceId: string; clientId: string } | null {
30
+ const idx = member.indexOf(":");
31
+ if (idx <= 0) return null;
32
+ return { instanceId: member.slice(0, idx), clientId: member.slice(idx + 1) };
33
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cluster routing kinds — the semantic addressing modes carried in the
3
+ * cluster envelope. Numeric ids ride the wire; the strings are internal.
4
+ *
5
+ * Pure constants module (part of the `src/events/cluster` composition).
6
+ */
7
+
8
+ /** Every envelope kind, in wire order (index = kind id). */
9
+ export const CLUSTER_KINDS = ["broadcast", "topic", "group", "user", "client", "presence"] as const;
10
+ export type ClusterKind = (typeof CLUSTER_KINDS)[number];
11
+
12
+ /** Wire encoding of a {@link ClusterKind} (single byte). */
13
+ export const CLUSTER_KIND_ID: Record<ClusterKind, number> = {
14
+ broadcast: 0,
15
+ topic: 1,
16
+ group: 2,
17
+ user: 3,
18
+ client: 4,
19
+ presence: 5,
20
+ };
21
+
22
+ /**
23
+ * Envelope format version. A peer on a different version fails the version
24
+ * check and its frames are counted as errors — mixed-version clusters during
25
+ * a rolling upgrade degrade visibly instead of delivering corrupt frames.
26
+ */
27
+ export const CLUSTER_ENV_VERSION = 2;
28
+
29
+ /** Map a numeric wire kind back to its name (`undefined` when out of range). */
30
+ export function clusterKindFromId(id: number): ClusterKind | undefined {
31
+ return CLUSTER_KINDS[id];
32
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Presence table — in-memory index of OTHER instances' connections, learned
3
+ * from presence join/leave/sync messages and pruned by TTL.
4
+ *
5
+ * Encapsulated factory (like `createMetrics`): all mutation is private; the
6
+ * surface is queries + pure-ish updates. No timers, no I/O — the sync layer
7
+ * drives it from broker messages and the heartbeat tick.
8
+ */
9
+ import type { RemoteClient } from "../types";
10
+
11
+ export interface PresenceTable {
12
+ /** record that `instance` was heard from at epoch ms `at` */
13
+ touch(instance: string, at: number): void;
14
+ /** heartbeat from `instance`: refresh the instance AND its reported clients */
15
+ refreshInstance(instance: string, at: number): void;
16
+ /** a connection joined on a remote instance */
17
+ join(clientId: string, instanceId: string, userId: string | undefined, at: number): void;
18
+ /**
19
+ * a connection left a remote instance — only honored when the reporting
20
+ * instance still owns the record (stale leaves from older epochs are ignored)
21
+ */
22
+ leave(clientId: string, instanceId: string): void;
23
+ /** drop clients/instances not heard from within `ttlMs` */
24
+ prune(ttlMs: number, now?: number): void;
25
+ /** instances that currently hold `clientId` ([] = unknown) */
26
+ instancesForClient(clientId: string): string[];
27
+ /** unique instances holding any connection of `userId` */
28
+ instancesForUser(userId: string): string[];
29
+ /** every other instance heard from recently */
30
+ knownInstances(): string[];
31
+ /** snapshot of remote connection records */
32
+ remoteClients(): RemoteClient[];
33
+ }
34
+
35
+ export function createPresenceTable(): PresenceTable {
36
+ // clientId → remote record
37
+ const remote = new Map<string, RemoteClient>();
38
+ // other instanceId → last-seen epoch ms
39
+ const instanceSeen = new Map<string, number>();
40
+
41
+ return {
42
+ touch(instance, at) {
43
+ instanceSeen.set(instance, at);
44
+ },
45
+
46
+ refreshInstance(instance, at) {
47
+ instanceSeen.set(instance, at);
48
+ for (const r of remote.values()) {
49
+ if (r.instanceId === instance) r.lastSeen = at;
50
+ }
51
+ },
52
+
53
+ join(clientId, instanceId, userId, at) {
54
+ instanceSeen.set(instanceId, at);
55
+ remote.set(clientId, {
56
+ clientId,
57
+ instanceId,
58
+ ...(userId !== undefined ? { userId } : {}),
59
+ lastSeen: at,
60
+ });
61
+ },
62
+
63
+ leave(clientId, instanceId) {
64
+ instanceSeen.set(instanceId, Date.now());
65
+ const r = remote.get(clientId);
66
+ if (r && r.instanceId === instanceId) remote.delete(clientId);
67
+ },
68
+
69
+ prune(ttlMs, now = Date.now()) {
70
+ for (const [clientId, r] of remote) {
71
+ if (now - r.lastSeen > ttlMs) remote.delete(clientId);
72
+ }
73
+ for (const [inst, at] of instanceSeen) {
74
+ if (now - at > ttlMs) instanceSeen.delete(inst);
75
+ }
76
+ },
77
+
78
+ instancesForClient(clientId) {
79
+ const r = remote.get(clientId);
80
+ return r ? [r.instanceId] : [];
81
+ },
82
+
83
+ instancesForUser(userId) {
84
+ const out: string[] = [];
85
+ for (const r of remote.values()) {
86
+ if (r.userId === userId && !out.includes(r.instanceId)) out.push(r.instanceId);
87
+ }
88
+ return out;
89
+ },
90
+
91
+ knownInstances() {
92
+ return [...instanceSeen.keys()];
93
+ },
94
+
95
+ remoteClients() {
96
+ return [...remote.values()];
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Presence messages — the tiny JSON payloads exchanged on the `presence`
3
+ * channel (kind = "presence", frame = JSON) so instances learn about each
4
+ * other's connections WITHOUT any shared state.
5
+ *
6
+ * Pure codec module.
7
+ */
8
+
9
+ /** A connection joined on instance `i`. */
10
+ export interface PresenceJoin {
11
+ t: "j";
12
+ i: string;
13
+ c: string;
14
+ u?: string;
15
+ at: number;
16
+ }
17
+
18
+ /** A connection left instance `i`. */
19
+ export interface PresenceLeave {
20
+ t: "l";
21
+ i: string;
22
+ c: string;
23
+ }
24
+
25
+ /** Periodic per-instance heartbeat (also refreshes liveness). */
26
+ export interface PresenceSync {
27
+ t: "s";
28
+ i: string;
29
+ at: number;
30
+ }
31
+
32
+ export type PresenceMessage = PresenceJoin | PresenceLeave | PresenceSync;
33
+
34
+ // module-global codecs: allocated once, reused for every message
35
+ const enc = new TextEncoder();
36
+ const dec = new TextDecoder();
37
+
38
+ /** Encode a presence message into the envelope's frame bytes. */
39
+ export function encodePresence(msg: PresenceMessage): Uint8Array {
40
+ return enc.encode(JSON.stringify(msg));
41
+ }
42
+
43
+ /**
44
+ * Decode presence bytes; `null` when malformed (callers drop silently —
45
+ * presence is advisory and self-healing via heartbeat/TTL).
46
+ */
47
+ export function decodePresence(bytes: Uint8Array): PresenceMessage | null {
48
+ try {
49
+ return JSON.parse(dec.decode(bytes)) as PresenceMessage;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Lazy `ioredis` loader — shared by the Redis cluster transport and the Redis
3
+ * state store. Redis is an OPTIONAL peer dependency: it is never bundled and
4
+ * only loaded when a Redis option is actually used.
5
+ *
6
+ * The structural types below describe the small slice of ioredis nova uses;
7
+ * they are intentionally loose (the library is untyped here) but ABI-exact.
8
+ */
9
+ import { createRequire } from "node:module";
10
+ import type { RedisConnectionOptions } from "../types";
11
+
12
+ const nodeRequire = createRequire(import.meta.url);
13
+
14
+ /** Structural type of the ioredis client surface nova relies on. */
15
+ export interface IoredisClient {
16
+ publish(channel: string, data: Buffer): Promise<unknown>;
17
+ subscribe(...channels: string[]): Promise<unknown>;
18
+ psubscribe(...patterns: string[]): Promise<unknown>;
19
+ unsubscribe(...channels: string[]): Promise<unknown>;
20
+ punsubscribe(...patterns: string[]): Promise<unknown>;
21
+ on(event: string, cb: (...args: unknown[]) => void): unknown;
22
+ get(key: string): Promise<unknown>;
23
+ set(...args: unknown[]): Promise<unknown>;
24
+ del(...keys: string[]): Promise<unknown>;
25
+ sadd(key: string, member: string): Promise<unknown>;
26
+ srem(key: string, member: string): Promise<unknown>;
27
+ smembers(key: string): Promise<unknown>;
28
+ expire(key: string, seconds: number): Promise<unknown>;
29
+ quit(): Promise<unknown>;
30
+ readonly status: string;
31
+ }
32
+
33
+ /** Synchronously load the ioredis constructor (throws with install guidance). */
34
+ export function loadRedis(): new (...args: unknown[]) => IoredisClient {
35
+ try {
36
+ return nodeRequire("ioredis") as new (...args: unknown[]) => IoredisClient;
37
+ } catch {
38
+ throw new Error(
39
+ "ignex events cluster: Redis configured but 'ioredis' is not installed — run `bun add ioredis` (or pass a custom cluster.transport / cluster.state)",
40
+ );
41
+ }
42
+ }
43
+
44
+ /** Split connection options into ioredis ctor args ({url} vs {options}). */
45
+ export function redisConnArgs(opts: RedisConnectionOptions): {
46
+ url?: string;
47
+ options?: Record<string, unknown>;
48
+ } {
49
+ return typeof opts === "string" ? { url: opts } : { options: opts };
50
+ }