@ignex/nova 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +53 -0
  26. package/src/core/client-wire.ts +183 -8
  27. package/src/core/client.ts +84 -4
  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
@@ -14,18 +14,48 @@
14
14
  * encoder (`bindings.encodeFrame`) — correct everywhere, slower, and the
15
15
  * default when `ffiMode: "optional"` and no addon is available.
16
16
  *
17
- * `createTransport(bindings)` is a per-schema factory: it owns its own scratch +
18
- * stats + FFI binding, so several servers with different schemas can coexist.
19
- * The module-level `defaultTransport` (built-in registry, Rust required) keeps
20
- * the historical singleton behavior `encodeToScratch` / `encodeEvent` /
21
- * `getEncodeStats` remain re-exported for backwards compatibility.
17
+ * ALL per-event dispatch state is resolved EAGERLY at `createTransport()`
18
+ * (instantiation time), not lazily on first encode: every known event name
19
+ * app AND control gets an {@link EncodeRecord} up front, holding its event
20
+ * id, generated encoder, NUL pre-scan and (once the addon binds) its direct
21
+ * FFI symbol + encode-path counters. The hot path is then one Map hit plus a
22
+ * couple of monomorphic field reads/increments — no lazy-init branches, no
23
+ * second stats Map, no per-encode counter allocation.
24
+ *
25
+ * `createTransport(bindings)` is a per-schema factory: it owns its own scratch
26
+ * + records + FFI binding, so several servers with different schemas can
27
+ * coexist. The module-level `defaultTransport` (built-in registry, Rust
28
+ * required) keeps the historical singleton behavior — `encodeToScratch` /
29
+ * `encodeEvent` / `getEncodeStats` remain re-exported for backwards compat.
22
30
  */
23
31
 
24
32
  import { defaultBindings } from "../bindings/default";
25
33
  import type { Bindings, DirectEncoder } from "../bindings/types";
26
34
  import { createFfi, type FfiDl } from "../native/ffi";
27
35
  import { createScratch, MIN_CAP } from "./scratch";
28
- import { createStats } from "./stats";
36
+
37
+ /** Everything the hot path needs for one event — built once at instantiation. */
38
+ interface EncodeRecord {
39
+ /** stable wire id (anyEventNameToId) — used by the JSON fallback */
40
+ readonly id: number;
41
+ /** generated zero-alloc encoder (absent for JSON-only events) */
42
+ readonly encoder: DirectEncoder | undefined;
43
+ /** FFI symbol name in the addon (absent when there is no direct table) */
44
+ readonly symName: string | undefined;
45
+ /** per-event NUL pre-scan (absent when the event has no string fields) */
46
+ readonly hasNul: ((o: unknown) => boolean) | undefined;
47
+ /**
48
+ * Resolved FFI symbol: undefined = not yet bound OR disabled/JSON-only.
49
+ * Flipped to a function once the addon binds; set back to undefined when a
50
+ * runtime failure (`ffiMode: "optional"`) permanently demotes this event to
51
+ * the JSON path.
52
+ */
53
+ call: ((...args: unknown[]) => number) | undefined;
54
+ /** encode-path counters — incremented IN PLACE on the record (no Maps) */
55
+ directCount: number;
56
+ jsonCount: number;
57
+ jsCount: number;
58
+ }
29
59
 
30
60
  export interface Transport {
31
61
  /**
@@ -44,24 +74,26 @@ export interface Transport {
44
74
  };
45
75
  }
46
76
 
47
- /**
48
- * Resolved direct-path record for one event — populated lazily on first encode
49
- * and immutable afterwards (a direct symbol that was disabled by the bind-time
50
- * self-test never re-enables, so caching the resolved call is safe).
51
- */
52
- interface ResolvedDirect {
53
- /** generated zero-alloc encoder (absent for JSON-only events) */
54
- encoder?: DirectEncoder;
55
- /** resolved FFI symbol (undefined = symbol disabled → JSON fallback) */
56
- call: ((...args: unknown[]) => number) | undefined;
57
- /** per-event NUL pre-scan (absent when the event has no string fields) */
58
- hasNul?: (o: unknown) => boolean;
59
- }
60
-
61
77
  export function createTransport(bindings: Bindings): Transport {
62
78
  const scratch = createScratch();
63
- const stats = createStats();
64
- const resolvedDirect = new Map<string, ResolvedDirect>();
79
+
80
+ // ── instantiation-time resolution: one record per known event ────────────
81
+ const records = new Map<string, EncodeRecord>();
82
+ for (const name of Object.keys(bindings.anyEventNameToId)) {
83
+ const direct = bindings.direct;
84
+ const encoder = direct?.encoders[name];
85
+ const hasNul = direct?.hasNul[name];
86
+ records.set(name, {
87
+ id: bindings.anyEventNameToId[name] as number,
88
+ encoder,
89
+ ...(encoder !== undefined ? { symName: direct?.symbolNames[name] } : { symName: undefined }),
90
+ hasNul,
91
+ call: undefined,
92
+ directCount: 0,
93
+ jsonCount: 0,
94
+ jsCount: 0,
95
+ });
96
+ }
65
97
 
66
98
  // Lazily bound once: undefined = not yet resolved, null = JS-only mode,
67
99
  // FfiDl = bound addon. For ffiMode "required" the bind throws (missing /
@@ -73,77 +105,75 @@ export function createTransport(bindings: Bindings): Transport {
73
105
  return ffi;
74
106
  };
75
107
 
76
- const guard = bindings.ffiMode === "optional";
77
-
78
- function resolveDirect(name: string): ResolvedDirect {
79
- let r = resolvedDirect.get(name);
80
- if (r === undefined) {
81
- r = { call: undefined };
82
- const encoder = bindings.direct?.encoders[name];
83
- if (encoder) {
84
- r.encoder = encoder;
85
- const dl = getFfiDl();
86
- const symName = bindings.direct?.symbolNames[name];
87
- r.call = dl ? dl.raw[symName ?? ""] : undefined;
88
- const hasNul = bindings.direct?.hasNul[name];
89
- if (hasNul !== undefined) r.hasNul = hasNul;
90
- }
91
- resolvedDirect.set(name, r);
108
+ /**
109
+ * Bind the addon once and fan the resolved symbols out across ALL records
110
+ * in a single pass — the first encode pays the dlopen + self-test, every
111
+ * later encode sees a plain populated field. Self-test-disabled symbols are
112
+ * left undefined (their events take the JSON path).
113
+ */
114
+ const bindSymbols = (): void => {
115
+ const dl = getFfiDl();
116
+ if (dl === null) return;
117
+ const disabled = dl.disabledDirect;
118
+ for (const r of records.values()) {
119
+ if (r.encoder === undefined || r.symName === undefined) continue;
120
+ if (disabled.has(r.symName)) continue;
121
+ const sym = dl.raw[r.symName];
122
+ if (sym !== undefined) r.call = sym;
92
123
  }
93
- return r;
94
- }
124
+ };
95
125
 
96
126
  function encodeToScratch(name: string, payload: unknown): Uint8Array {
97
- const r = resolveDirect(name);
127
+ const r = records.get(name);
128
+ if (r === undefined) throw new Error(`ignex: unknown event "${name}"`);
98
129
  const encoder = r.encoder;
99
- if (encoder && r.call) {
100
- // `call` is undefined when the bind-time self-test disabled the symbol —
101
- // fall through to the JSON path (graceful degradation). Embedded NULs route
102
- // to JSON too: the `cstring` direct path truncates them (silent data loss),
103
- // the JSON path preserves them exactly.
104
- if (!(r.hasNul?.(payload) ?? false)) {
105
- if (!guard) {
106
- // required mode zero-alloc hot path, no try/catch
107
- scratch.grow(MIN_CAP);
108
- const w = scratch.neededSize(name, encoder(r.call, payload, scratch.view), () =>
109
- encoder(r.call!, payload, scratch.view),
110
- );
111
- stats.bump(name, "direct");
112
- return scratch.view.subarray(0, w);
113
- }
114
- try {
115
- scratch.grow(MIN_CAP);
116
- const w = scratch.neededSize(name, encoder(r.call, payload, scratch.view), () =>
117
- encoder(r.call!, payload, scratch.view),
118
- );
119
- stats.bump(name, "direct");
120
- return scratch.view.subarray(0, w);
121
- } catch {
122
- // optional mode: a direct-call failure (e.g. ABI drift at runtime)
123
- // permanently demotes this event to the JSON path.
124
- r.call = undefined;
130
+ if (encoder !== undefined) {
131
+ if (r.call === undefined && ffi === undefined) bindSymbols();
132
+ const call = r.call;
133
+ if (call !== undefined) {
134
+ // Embedded NULs route to JSON: the `cstring` direct path truncates
135
+ // them (silent data loss); the JSON path preserves them exactly.
136
+ if (!(r.hasNul?.(payload) ?? false)) {
137
+ if (bindings.ffiMode !== "optional") {
138
+ // required mode — zero-alloc hot path, no try/catch
139
+ scratch.grow(MIN_CAP);
140
+ const w = scratch.neededSize(name, encoder(call, payload, scratch.view), () =>
141
+ encoder(r.call as (...args: unknown[]) => number, payload, scratch.view),
142
+ );
143
+ r.directCount++;
144
+ return scratch.view.subarray(0, w);
145
+ }
146
+ try {
147
+ scratch.grow(MIN_CAP);
148
+ const w = scratch.neededSize(name, encoder(call, payload, scratch.view), () =>
149
+ encoder(r.call as (...args: unknown[]) => number, payload, scratch.view),
150
+ );
151
+ r.directCount++;
152
+ return scratch.view.subarray(0, w);
153
+ } catch {
154
+ // optional mode: a direct-call failure (e.g. ABI drift at runtime)
155
+ // permanently demotes this event to the JSON path.
156
+ r.call = undefined;
157
+ }
125
158
  }
126
159
  }
127
160
  }
128
161
 
129
- const dl = getFfiDl();
130
- if (dl) {
162
+ const dl = ffi === undefined ? getFfiDl() : ffi;
163
+ if (dl !== null) {
131
164
  // JSON fallback (vector/nested events, or a disabled direct symbol).
132
- const id = bindings.anyEventNameToId[name];
133
- if (id === undefined) throw new Error(`ignex: unknown event "${name}"`);
134
165
  const json = JSON.stringify(payload);
135
- const ffi2 = dl.bindings;
136
166
  scratch.grow(Math.max(MIN_CAP, json.length * 2 + 128));
137
- const w = scratch.neededSize(name, ffi2.fb_serialize(id, json, scratch.view), () =>
138
- ffi2.fb_serialize(id, json, scratch.view),
167
+ const w = scratch.neededSize(name, dl.bindings.fb_serialize(r.id, json, scratch.view), () =>
168
+ dl.bindings.fb_serialize(r.id, json, scratch.view),
139
169
  );
140
- stats.bump(name, "json");
170
+ r.jsonCount++;
141
171
  return scratch.view.subarray(0, w);
142
172
  }
143
173
 
144
174
  // JS-only mode (user schema, no native addon) — the pure-JS encoder.
145
175
  const frame = bindings.encodeFrame(name, payload);
146
- stats.bump(name, "js");
176
+ r.jsCount++;
147
177
  return frame;
148
178
  }
149
179
 
@@ -157,7 +187,17 @@ export function createTransport(bindings: Bindings): Transport {
157
187
  return {
158
188
  encodeToScratch,
159
189
  encodeEvent,
160
- getEncodeStats: () => stats.get(),
190
+ getEncodeStats() {
191
+ const direct: Record<string, number> = {};
192
+ const json: Record<string, number> = {};
193
+ const js: Record<string, number> = {};
194
+ for (const [name, r] of records) {
195
+ if (r.directCount > 0) direct[name] = r.directCount;
196
+ if (r.jsonCount > 0) json[name] = r.jsonCount;
197
+ if (r.jsCount > 0) js[name] = r.jsCount;
198
+ }
199
+ return { direct, json, js };
200
+ },
161
201
  };
162
202
  }
163
203
 
@@ -1,309 +0,0 @@
1
- /**
2
- * NATS bridge — bidirectional FlatBuffer transport over NATS.
3
- *
4
- * OUTBOUND: the server encodes each event ONCE (Rust FFI → scratch), fans the
5
- * same frame out to WS clients, then hands a COPY to `bridge.publish(subject,
6
- * frame)` so other applications consume the identical wire bytes. Best-effort:
7
- * if NATS is down the frame is dropped and counted in `bridgeErrors` — it
8
- * never blocks or throws on the WS hot path.
9
- *
10
- * INBOUND: when `inbound` is enabled the bridge subscribes to `{prefix}.
11
- * inbound.>` and forwards decodable app events to `onInbound` (wired by the
12
- * server to fan out to clients). Control frames and unknown ids are dropped.
13
- *
14
- * HORIZONTAL SCALING: when `bridgeClientEvents` is set, the server re-publishes
15
- * every accepted client-sent event to `{prefix}.inbound.<event>` so OTHER
16
- * server instances (and BE consumers) receive it — a cluster of servers sharing
17
- * a prefix behaves as one hub (see docs/generic-bindings.md). Loop prevention:
18
- * frames that arrive via NATS are forwarded to clients through `onInbound` and
19
- * never re-bridged.
20
- *
21
- * GENERIC: `createNatsBridge(opts, transport?, bindings?)` decodes inbound
22
- * frames with the given `Bindings` (default: the built-in registry), so the
23
- * bridge works for ANY schema — the same wire bytes the server speaks.
24
- *
25
- * The connection is created eagerly but non-blocking: `connect()` runs in the
26
- * background with a retry loop, so a server can start while NATS is down.
27
- * `createNatsBridge(opts, transport?)` accepts an injectable `NatsTransport`
28
- * so tests can fake NATS entirely (no server needed in CI).
29
- */
30
- import { connect, type NatsConnection } from "nats";
31
- import { defaultBindings } from "../bindings/default";
32
- import type { Bindings } from "../bindings/types";
33
- import { createSubjectBuilder, type SubjectBuilder } from "./subjects";
34
-
35
- export type NatsBridgeStatus = "connected" | "connecting" | "closed";
36
-
37
- export interface NatsBridgeOptions {
38
- /** NATS servers, default ["nats://localhost:4222"] */
39
- servers?: string[];
40
- /** subject prefix, default "ignex" (or the bindings' subjectPrefix) */
41
- subjectPrefix?: string;
42
- /**
43
- * The wire stack used to decode inbound frames (default: built-in registry).
44
- * Pass your own generated bindings so the bridge decodes YOUR events.
45
- */
46
- bindings?: Bindings;
47
- /** connect timeout (ms), default 5000 */
48
- connectTimeout?: number;
49
- /** how long to wait before retrying a failed initial connect (ms), default 2000 */
50
- connectRetryMs?: number;
51
- /** reconnect handled by nats.js (core NATS, no durable queues), default true */
52
- reconnect?: boolean;
53
- /** optional NATS token (auth) */
54
- token?: string;
55
- /** subscribe to inbound subjects and forward events to clients, default false */
56
- inbound?: boolean;
57
- /** inbound subjects (default `{prefix}.inbound.>`), requires `inbound` */
58
- inboundSubjects?: string[];
59
- /** only forward these inbound events (default: every app event) */
60
- inboundEvents?: string[];
61
- /**
62
- * Re-publish every accepted client-sent event to `{prefix}.inbound.<event>`
63
- * so other servers in the cluster (and BE consumers) receive it, default
64
- * false. See the horizontal-scaling docs.
65
- */
66
- bridgeClientEvents?: boolean;
67
- }
68
-
69
- /** Counters folded into `server.getMetrics()`. */
70
- export interface NatsBridgeStats {
71
- bridged: number;
72
- bridgedBytes: number;
73
- bridgeErrors: number;
74
- bridgeInbound: number;
75
- bridgeInboundErrors: number;
76
- }
77
-
78
- /** Minimal transport — a real NATS connection or a test fake. */
79
- export interface NatsTransport {
80
- readonly connected: boolean;
81
- /** synchronously send bytes; throws when not connected (bridge catches + counts) */
82
- publish(subject: string, data: Uint8Array): void;
83
- /** subscribe; `cb` receives message bytes; returns an unsubscribe function */
84
- subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
85
- close(): Promise<void>;
86
- }
87
-
88
- export interface NatsBridge {
89
- readonly status: NatsBridgeStatus;
90
- readonly subjects: SubjectBuilder;
91
- readonly stats: NatsBridgeStats;
92
- /** whether client-sent events are re-published to `{prefix}.inbound.<event>` */
93
- readonly clientEvents: boolean;
94
- /** publish a frame to `subject` (copies the bytes — safe after scratch reuse) */
95
- publish(subject: string, frame: Uint8Array): void;
96
- /**
97
- * Raw byte subscription (used by the events cluster layer). Unlike the
98
- * inbound path this does NOT decode or forward — bytes are handed to `cb`
99
- * verbatim, re-subscribed automatically after a NATS reconnect.
100
- */
101
- subscribeRaw(subject: string, cb: (data: Uint8Array) => void): () => void;
102
- /** wire the inbound → clients forward (set once by the server) */
103
- setOnInbound(cb: (name: string, payload: unknown) => void): void;
104
- close(): Promise<void>;
105
- }
106
-
107
- /** Eager, non-blocking real transport with an initial-connect retry loop. */
108
- function createRealTransport(opts: NatsBridgeOptions): NatsTransport {
109
- let nc: NatsConnection | null = null;
110
- let connected = false;
111
- let closed = false;
112
- const subs: Array<{ subject: string; cb: (data: Uint8Array) => void }> = [];
113
- let unsubs: Array<() => void> = [];
114
-
115
- const sync = (): void => {
116
- for (const u of unsubs) u();
117
- unsubs = [];
118
- if (!nc) return;
119
- for (const s of subs) {
120
- const sub = nc.subscribe(s.subject);
121
- unsubs.push(() => sub.unsubscribe());
122
- void (async () => {
123
- try {
124
- for await (const m of sub) s.cb(new Uint8Array(m.data));
125
- } catch {
126
- // subscription ended / connection closed
127
- }
128
- })();
129
- }
130
- };
131
-
132
- const attachStatus = (conn: NatsConnection): void => {
133
- void conn
134
- .closed()
135
- .then(() => {
136
- connected = false;
137
- if (nc === conn) nc = null;
138
- })
139
- .catch(() => {
140
- connected = false;
141
- });
142
- void (async () => {
143
- try {
144
- for await (const st of conn.status()) {
145
- if (st.type === "disconnect") connected = false;
146
- else if (st.type === "reconnect") {
147
- connected = true;
148
- sync(); // nats.js re-subscribes automatically; resync to be safe
149
- }
150
- }
151
- } catch {
152
- connected = false;
153
- }
154
- })();
155
- };
156
-
157
- const tryConnect = async (): Promise<void> => {
158
- if (closed) return;
159
- try {
160
- const conn = await connect({
161
- servers: opts.servers ?? ["nats://localhost:4222"],
162
- ...(opts.token !== undefined ? { token: opts.token } : {}),
163
- timeout: opts.connectTimeout ?? 5000,
164
- reconnect: opts.reconnect ?? true,
165
- maxReconnectAttempts: -1,
166
- });
167
- nc = conn;
168
- connected = true;
169
- attachStatus(conn);
170
- sync();
171
- } catch {
172
- connected = false;
173
- }
174
- };
175
-
176
- void (async () => {
177
- while (!closed) {
178
- if (!nc || !connected) await tryConnect();
179
- await Bun.sleep(opts.connectRetryMs ?? 2000);
180
- }
181
- })();
182
-
183
- return {
184
- get connected() {
185
- return connected;
186
- },
187
- publish(subject, data) {
188
- if (!nc) throw new Error("nats: not connected");
189
- nc.publish(subject, data);
190
- },
191
- subscribe(subject, cb) {
192
- subs.push({ subject, cb });
193
- sync();
194
- return () => {
195
- const i = subs.findIndex((s) => s.subject === subject && s.cb === cb);
196
- if (i >= 0) subs.splice(i, 1);
197
- sync();
198
- };
199
- },
200
- async close() {
201
- closed = true;
202
- if (nc) {
203
- try {
204
- await nc.close();
205
- } catch {
206
- // already closed
207
- }
208
- }
209
- nc = null;
210
- connected = false;
211
- },
212
- };
213
- }
214
-
215
- export function createNatsBridge(
216
- opts: NatsBridgeOptions = {},
217
- transport?: NatsTransport,
218
- bindings?: Bindings,
219
- ): NatsBridge {
220
- const b = bindings ?? opts.bindings ?? defaultBindings;
221
- const t = transport ?? createRealTransport(opts);
222
- const subjects = createSubjectBuilder(opts.subjectPrefix ?? b.subjectPrefix ?? "ignex");
223
- const stats: NatsBridgeStats = {
224
- bridged: 0,
225
- bridgedBytes: 0,
226
- bridgeErrors: 0,
227
- bridgeInbound: 0,
228
- bridgeInboundErrors: 0,
229
- };
230
- let closed = false;
231
- let onInbound: ((name: string, payload: unknown) => void) | null = null;
232
- const allowlist = opts.inboundEvents ? new Set(opts.inboundEvents) : null;
233
-
234
- // inbound subscriptions (lazy — the transport queues them until connected)
235
- const subscribeInbound = (subject: string): (() => void) => {
236
- return t.subscribe(subject, (data) => {
237
- const header = b.readFrameHeader(data);
238
- if (!header) {
239
- stats.bridgeInboundErrors++;
240
- return;
241
- }
242
- if (b.isControlId(header.id)) {
243
- stats.bridgeInboundErrors++; // never forward transport-internal frames
244
- return;
245
- }
246
- const name = header.name;
247
- if (allowlist && !allowlist.has(name)) return;
248
- let payload: unknown;
249
- try {
250
- payload = b.decodePayload(header.id, data);
251
- } catch {
252
- stats.bridgeInboundErrors++;
253
- return;
254
- }
255
- stats.bridgeInbound++;
256
- onInbound?.(name, payload);
257
- });
258
- };
259
-
260
- const unsubs: Array<() => void> = [];
261
- if (opts.inbound) {
262
- const subjectsList = opts.inboundSubjects?.length
263
- ? opts.inboundSubjects
264
- : [subjects.inboundPrefix()];
265
- for (const subject of subjectsList) unsubs.push(subscribeInbound(subject));
266
- }
267
-
268
- return {
269
- get status(): NatsBridgeStatus {
270
- if (closed) return "closed";
271
- return t.connected ? "connected" : "connecting";
272
- },
273
- get subjects() {
274
- return subjects;
275
- },
276
- get stats() {
277
- return stats;
278
- },
279
- get clientEvents(): boolean {
280
- return opts.bridgeClientEvents ?? false;
281
- },
282
- publish(subject, frame) {
283
- if (!t.connected) {
284
- stats.bridgeErrors++;
285
- return;
286
- }
287
- // the frame view is a reused scratch — copy before handing to NATS
288
- const copy = frame.slice();
289
- try {
290
- t.publish(subject, copy);
291
- stats.bridged++;
292
- stats.bridgedBytes += copy.byteLength;
293
- } catch {
294
- stats.bridgeErrors++;
295
- }
296
- },
297
- setOnInbound(cb) {
298
- onInbound = cb;
299
- },
300
- subscribeRaw(subject, cb) {
301
- return t.subscribe(subject, (data) => cb(data));
302
- },
303
- async close() {
304
- closed = true;
305
- for (const u of unsubs) u();
306
- await t.close();
307
- },
308
- };
309
- }