@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.
- package/README.md +4 -1
- package/docs/ai/TREE.md +69 -9
- package/docs/architecture.md +75 -27
- package/docs/events.md +83 -1
- package/docs/generic-bindings.md +10 -0
- package/docs/wire-format.md +65 -18
- package/package.json +2 -1
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/generate.ts +97 -3
- package/public/server.ts +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +376 -17
- package/src/bridge/nats/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/codegen/constants.ts +14 -4
- package/src/codegen/direct-gen.ts +20 -6
- package/src/codegen/registry-gen.ts +10 -6
- package/src/codegen/rust-glue-gen.ts +10 -3
- package/src/codegen/schema-model.ts +28 -3
- package/src/codegen/ts-ser-gen.ts +12 -3
- package/src/core/auth.ts +65 -4
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +42 -0
- package/src/core/client-wire.ts +142 -8
- package/src/core/client.ts +72 -3
- package/src/core/groups.ts +5 -0
- package/src/core/metrics.ts +38 -21
- package/src/core/outbound.ts +50 -6
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +128 -5
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/{server.ts → server/index.ts} +112 -120
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +73 -1
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +18 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +57 -11
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/queue.ts +36 -9
- package/src/events/registry.ts +90 -54
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +146 -59
- package/src/generated/fbs/backend.fbs +23 -0
- package/src/generated/registry.ts +92 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +109 -16
- package/src/generated/wire-registry.json +7 -3
- package/src/schema/index.ts +45 -1
- package/src/transport/transport.ts +117 -77
- package/src/bridge/nats.ts +0 -309
- package/src/events/cluster.ts +0 -732
- package/src/events/hub.ts +0 -481
- package/src/events/types.ts +0 -378
- 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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
}
|
|
124
|
+
};
|
|
95
125
|
|
|
96
126
|
function encodeToScratch(name: string, payload: unknown): Uint8Array {
|
|
97
|
-
const r =
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
encoder(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
encoder(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/bridge/nats.ts
DELETED
|
@@ -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
|
-
}
|