@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.
Files changed (120) hide show
  1. package/README.md +136 -33
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +292 -0
  4. package/docs/architecture.md +101 -28
  5. package/docs/events.md +252 -0
  6. package/docs/generic-bindings.md +207 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +74 -20
  9. package/index.ts +75 -27
  10. package/package.json +13 -2
  11. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  12. package/public/bindings.ts +24 -0
  13. package/public/client.ts +5 -1
  14. package/public/events.ts +71 -0
  15. package/public/generate.ts +510 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +52 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/generated/backend.rs +503 -0
  21. package/rust/src/transcode/generated.rs +377 -17
  22. package/src/bindings/assemble.ts +73 -0
  23. package/src/bindings/default.ts +65 -0
  24. package/src/bindings/types.ts +113 -0
  25. package/src/bridge/nats/inbound.ts +46 -0
  26. package/src/bridge/nats/index.ts +131 -0
  27. package/src/bridge/nats/real-transport.ts +133 -0
  28. package/src/bridge/nats/types.ts +80 -0
  29. package/src/bridge/subjects.ts +3 -0
  30. package/src/codegen/constants.ts +28 -0
  31. package/src/codegen/direct-gen.ts +564 -0
  32. package/src/codegen/fingerprint.ts +44 -0
  33. package/src/codegen/hash.ts +25 -0
  34. package/src/codegen/registry-gen.ts +246 -0
  35. package/src/codegen/rust-glue-gen.ts +552 -0
  36. package/src/codegen/schema-model.ts +363 -0
  37. package/src/codegen/ts-ser-gen.ts +230 -0
  38. package/src/codegen/typebox-to-fbs.ts +60 -0
  39. package/src/core/auth.ts +67 -5
  40. package/src/core/client-heartbeat.ts +2 -1
  41. package/src/core/client-reconnect.ts +9 -2
  42. package/src/core/client-rpc.ts +75 -0
  43. package/src/core/client-state.ts +63 -8
  44. package/src/core/client-wire.ts +148 -15
  45. package/src/core/client.ts +105 -31
  46. package/src/core/groups.ts +8 -0
  47. package/src/core/metrics.ts +42 -21
  48. package/src/core/outbound.ts +62 -11
  49. package/src/core/rate-limit.ts +69 -0
  50. package/src/core/replay.ts +41 -1
  51. package/src/core/resume.ts +181 -0
  52. package/src/core/rooms.ts +10 -3
  53. package/src/core/routing.ts +144 -12
  54. package/src/core/server/client-info.ts +37 -0
  55. package/src/core/server/http-routes.ts +59 -0
  56. package/src/core/server/index.ts +360 -0
  57. package/src/core/server/metrics-view.ts +53 -0
  58. package/src/core/server/socket-lifecycle.ts +57 -0
  59. package/src/core/state.ts +124 -14
  60. package/src/core/topic-log.ts +86 -0
  61. package/src/events/clients.ts +174 -0
  62. package/src/events/cluster/dedupe.ts +43 -0
  63. package/src/events/cluster/envelope.ts +149 -0
  64. package/src/events/cluster/index.ts +50 -0
  65. package/src/events/cluster/keys.ts +33 -0
  66. package/src/events/cluster/kinds.ts +32 -0
  67. package/src/events/cluster/presence-table.ts +99 -0
  68. package/src/events/cluster/presence.ts +53 -0
  69. package/src/events/cluster/redis-client.ts +50 -0
  70. package/src/events/cluster/store-memory.ts +67 -0
  71. package/src/events/cluster/store-redis.ts +44 -0
  72. package/src/events/cluster/subjects.ts +30 -0
  73. package/src/events/cluster/sync.ts +476 -0
  74. package/src/events/cluster/transport-nats.ts +24 -0
  75. package/src/events/cluster/transport-redis.ts +120 -0
  76. package/src/events/cluster-rpc.ts +196 -0
  77. package/src/events/data.ts +38 -0
  78. package/src/events/delivery.ts +83 -0
  79. package/src/events/emit.ts +173 -0
  80. package/src/events/global.ts +117 -0
  81. package/src/events/groups.ts +118 -0
  82. package/src/events/hub/context-factory.ts +79 -0
  83. package/src/events/hub/dispatch.ts +86 -0
  84. package/src/events/hub/index.ts +536 -0
  85. package/src/events/hub/internal.ts +31 -0
  86. package/src/events/hub/metrics-snapshot.ts +84 -0
  87. package/src/events/hub/resolve-cluster.ts +49 -0
  88. package/src/events/index.ts +61 -0
  89. package/src/events/queue.ts +123 -0
  90. package/src/events/registry.ts +214 -0
  91. package/src/events/schedule.ts +73 -0
  92. package/src/events/trace.ts +283 -0
  93. package/src/events/types/client.ts +68 -0
  94. package/src/events/types/cluster.ts +40 -0
  95. package/src/events/types/context.ts +50 -0
  96. package/src/events/types/emit-target.ts +29 -0
  97. package/src/events/types/groups.ts +35 -0
  98. package/src/events/types/hub.ts +124 -0
  99. package/src/events/types/index.ts +30 -0
  100. package/src/events/types/metrics.ts +52 -0
  101. package/src/events/types/options.ts +62 -0
  102. package/src/generated/direct-ser.ts +148 -60
  103. package/src/generated/fbs/backend.fbs +24 -1
  104. package/src/generated/registry.ts +94 -33
  105. package/src/generated/rust/backend_generated.rs +503 -0
  106. package/src/generated/ts/backend.ts +4 -0
  107. package/src/generated/ts/resume.ts +74 -0
  108. package/src/generated/ts/resumed.ts +88 -0
  109. package/src/generated/ts/rpc-call.ts +112 -0
  110. package/src/generated/ts/rpc-result.ts +126 -0
  111. package/src/generated/ts/snapshot-request.ts +19 -5
  112. package/src/generated/ts-ser.ts +110 -17
  113. package/src/generated/wire-registry.json +7 -2
  114. package/src/native/ffi.ts +85 -28
  115. package/src/schema/index.ts +49 -2
  116. package/src/server.ts +7 -3
  117. package/src/transport/transport.ts +200 -79
  118. package/src/bridge/nats.ts +0 -269
  119. package/src/core/server.ts +0 -294
  120. package/src/transport/stats.ts +0 -44
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * In-memory state store — per-process default `ClusterStateStore`.
3
+ * Pass a SHARED `Map` to simulate a cross-instance store in tests /
4
+ * single-process multi-instance setups.
5
+ *
6
+ * TTLs are modeled with a parallel `__ttl:` key checked lazily on read.
7
+ */
8
+ import type { ClusterStateStore } from "../types";
9
+
10
+ export function createMemoryStateStore(
11
+ shared?: Map<string, unknown>,
12
+ ): ClusterStateStore & { close(): Promise<void> } {
13
+ const data = shared ?? new Map<string, unknown>();
14
+
15
+ const ttlKey = (key: string): string => `__ttl:${key}`;
16
+
17
+ /** Lazily expire: drop the key when its TTL has passed; report liveness. */
18
+ const alive = (key: string): boolean => {
19
+ const ttl = data.get(ttlKey(key));
20
+ if (ttl === undefined) return true;
21
+ if (Date.now() > Number(ttl)) {
22
+ data.delete(key);
23
+ data.delete(ttlKey(key));
24
+ return false;
25
+ }
26
+ return true;
27
+ };
28
+
29
+ return {
30
+ async get(key) {
31
+ if (!alive(key)) return null;
32
+ const v = data.get(key);
33
+ return typeof v === "string" ? v : null;
34
+ },
35
+ async set(key, value, ttlMs) {
36
+ data.set(key, value);
37
+ if (ttlMs !== undefined) data.set(ttlKey(key), Date.now() + ttlMs);
38
+ },
39
+ async del(key) {
40
+ data.delete(key);
41
+ data.delete(ttlKey(key));
42
+ },
43
+ async sadd(key, member) {
44
+ let s = data.get(key);
45
+ if (!(s instanceof Set)) {
46
+ s = new Set<string>();
47
+ data.set(key, s);
48
+ }
49
+ (s as Set<string>).add(member);
50
+ },
51
+ async srem(key, member) {
52
+ const s = data.get(key);
53
+ if (s instanceof Set) (s as Set<string>).delete(member);
54
+ },
55
+ async smembers(key) {
56
+ if (!alive(key)) return [];
57
+ const s = data.get(key);
58
+ return s instanceof Set ? [...(s as Set<string>)] : [];
59
+ },
60
+ async expire(key, ttlMs) {
61
+ data.set(ttlKey(key), Date.now() + ttlMs);
62
+ },
63
+ async close() {
64
+ data.clear();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Redis state store (lazy `ioredis`). The production `ClusterStateStore` for
3
+ * horizontally scaled deployments — every call is issued from the offload
4
+ * queue, never from the WS hot path.
5
+ */
6
+ import type { ClusterStateStore, RedisConnectionOptions } from "../types";
7
+ import { loadRedis } from "./redis-client";
8
+
9
+ export function createRedisStateStore(
10
+ opts: RedisConnectionOptions = {},
11
+ ): ClusterStateStore & { close(): Promise<void> } {
12
+ const Redis = loadRedis();
13
+ const r = new Redis(opts);
14
+ return {
15
+ async get(key) {
16
+ const v = await r.get(key);
17
+ return v == null ? null : String(v);
18
+ },
19
+ async set(key, value, ttlMs) {
20
+ if (ttlMs !== undefined) await r.set(key, value, "PX", ttlMs);
21
+ else await r.set(key, value);
22
+ },
23
+ async del(key) {
24
+ await r.del(key);
25
+ },
26
+ async sadd(key, member) {
27
+ await r.sadd(key, member);
28
+ },
29
+ async srem(key, member) {
30
+ await r.srem(key, member);
31
+ },
32
+ async smembers(key) {
33
+ const v = await r.smembers(key);
34
+ return Array.isArray(v) ? v.map(String) : [];
35
+ },
36
+ // redis EXPIRE is second-granularity — round up so short TTLs still hold
37
+ async expire(key, ttlMs) {
38
+ await r.expire(key, Math.max(1, Math.ceil(ttlMs / 1000)));
39
+ },
40
+ async close() {
41
+ await r.quit();
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cluster subject space — the broker channel names derived from the prefix.
3
+ *
4
+ * Every instance subscribes ONE wildcard channel (full-mesh kinds + presence)
5
+ * plus its own per-instance channel (routed targeted delivery). The envelope
6
+ * carries routing, so these names are pure syntax.
7
+ *
8
+ * Pure factory — no I/O.
9
+ */
10
+ import type { EmitTargetKind } from "../types";
11
+
12
+ /** The three subject shapes used by cluster messaging. */
13
+ export interface ClusterSubjects {
14
+ /** the one channel every instance subscribes to (full-mesh kinds) */
15
+ all(): string;
16
+ /** per-instance channel — routed targeted delivery lands here only */
17
+ instance(id: string): string;
18
+ /** publish channel for a target kind (also the external visibility of the subject space) */
19
+ event(kind: EmitTargetKind, key: string | undefined, name: string): string;
20
+ }
21
+
22
+ export function createClusterSubjects(prefix: string): ClusterSubjects {
23
+ const base = `${prefix}.cluster`;
24
+ return {
25
+ all: () => `${base}.>`,
26
+ instance: (id) => `${base}.instance.${id}`,
27
+ event: (kind, key, name) =>
28
+ key === undefined ? `${base}.${kind}.${name}` : `${base}.${kind}.${key}.${name}`,
29
+ };
30
+ }