@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,73 @@
1
+ /**
2
+ * `assembleBindings` — build a runtime `Bindings` from generated parts.
3
+ *
4
+ * The generated artifacts (`registry.ts`, `ts-ser.ts`, `direct-ser.ts`) are
5
+ * schema-specific SOURCE produced by `generateBindings` (public/generate.ts).
6
+ * This function wires them into the single `Bindings` object the server /
7
+ * client / NATS bridge accept. It is also what the built-in registry uses
8
+ * (`src/bindings/default.ts`).
9
+ */
10
+ import type { TSchema } from "@sinclair/typebox";
11
+ import type { Bindings, DirectTables } from "./types";
12
+
13
+ /** Everything `assembleBindings` needs from the generated artifacts. */
14
+ export interface BindingsParts {
15
+ readonly wireVersion: number;
16
+ readonly wireHeaderLen: number;
17
+ readonly schemaFingerprint: number;
18
+ readonly eventNameToId: Readonly<Record<string, number>>;
19
+ readonly idToEventName: Readonly<Record<number, string>>;
20
+ readonly anyEventNameToId: Readonly<Record<string, number>>;
21
+ readonly idToAnyEventName: Readonly<Record<number, string>>;
22
+ readonly controlEventNameToId: Readonly<Record<string, number>>;
23
+ readFrameHeader(bytes: Uint8Array): { name: string; id: number } | null;
24
+ isControlId(id: number): boolean;
25
+ decodePayload(id: number, bytes: Uint8Array): unknown;
26
+ decodeFrame(bytes: Uint8Array): { name: string; id: number; payload: unknown } | null;
27
+ encodeFrame(name: string, payload: unknown): Uint8Array;
28
+ readonly direct?: DirectTables;
29
+ }
30
+
31
+ export interface AssembleOptions {
32
+ /** "required" | "optional" — see `Bindings.ffiMode`. */
33
+ ffiMode?: "required" | "optional";
34
+ /** NATS subject prefix for bridges built from these bindings. */
35
+ subjectPrefix?: string;
36
+ }
37
+
38
+ /**
39
+ * Wire generated parts + the schema registry into a `Bindings` object.
40
+ * `schema.events` / `schema.controlEvents` are the user's TypeBox schemas —
41
+ * they power the `EventsOf<B>` type derivation on the public API.
42
+ */
43
+ export function assembleBindings<
44
+ E extends Record<string, TSchema>,
45
+ C extends Record<string, TSchema>,
46
+ >(
47
+ parts: BindingsParts,
48
+ schema: { events: E; controlEvents?: C },
49
+ opts: AssembleOptions = {},
50
+ ): Omit<Bindings, "events" | "controlEvents"> & { events: E; controlEvents: C } {
51
+ const controlEvents = (schema.controlEvents ?? {}) as C;
52
+ const controlIds = new Set<number>(Object.values(parts.controlEventNameToId));
53
+ return {
54
+ wireVersion: parts.wireVersion,
55
+ wireHeaderLen: parts.wireHeaderLen,
56
+ schemaFingerprint: parts.schemaFingerprint,
57
+ ...(opts.subjectPrefix !== undefined ? { subjectPrefix: opts.subjectPrefix } : {}),
58
+ ffiMode: opts.ffiMode ?? "optional",
59
+ events: schema.events,
60
+ controlEvents,
61
+ eventNameToId: parts.eventNameToId,
62
+ idToEventName: parts.idToEventName,
63
+ anyEventNameToId: parts.anyEventNameToId,
64
+ idToAnyEventName: parts.idToAnyEventName,
65
+ controlIds,
66
+ readFrameHeader: parts.readFrameHeader,
67
+ isControlId: parts.isControlId,
68
+ decodePayload: parts.decodePayload,
69
+ decodeFrame: parts.decodeFrame,
70
+ encodeFrame: parts.encodeFrame,
71
+ ...(parts.direct ? { direct: parts.direct } : {}),
72
+ };
73
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The built-in `Bindings` — assembled from the repo's generated artifacts
3
+ * (`src/generated/*` + `src/schema`). This is the default for every entrypoint
4
+ * (`createServer` / `createClient` / `createNatsBridge`), so all existing code
5
+ * keeps working without passing `bindings`.
6
+ *
7
+ * For your own schema, see `generateBindings` (public/generate.ts) —
8
+ * `defaultBindings` is just the first, built-in instance of the same contract.
9
+ */
10
+
11
+ import {
12
+ directEncoders,
13
+ directSelfTest,
14
+ directSymbolNames,
15
+ directSymbols,
16
+ hasNulEncoders,
17
+ } from "../generated/direct-ser";
18
+ import {
19
+ anyEventNameToId,
20
+ controlEventNameToId,
21
+ decodeFrame,
22
+ decodePayload,
23
+ eventNameToId,
24
+ idToAnyEventName,
25
+ idToEventName,
26
+ isControlId,
27
+ readFrameHeader,
28
+ SCHEMA_FINGERPRINT,
29
+ WIRE_HEADER_LEN,
30
+ WIRE_VERSION,
31
+ } from "../generated/registry";
32
+ import { encodeEventFrame } from "../generated/ts-ser";
33
+ import { controlEvents, events } from "../schema";
34
+ import { assembleBindings } from "./assemble";
35
+
36
+ // NOTE: no explicit `: Bindings` annotation on purpose — the concrete
37
+ // `events` / `controlEvents` schema types must survive inference so
38
+ // `DefaultBindings` (and therefore `EventNameOf` / `EventsOf` on the default
39
+ // API) resolves to the built-in `Events` map.
40
+ export const defaultBindings = assembleBindings(
41
+ {
42
+ wireVersion: WIRE_VERSION,
43
+ wireHeaderLen: WIRE_HEADER_LEN,
44
+ schemaFingerprint: SCHEMA_FINGERPRINT,
45
+ eventNameToId,
46
+ idToEventName,
47
+ anyEventNameToId,
48
+ idToAnyEventName,
49
+ controlEventNameToId,
50
+ readFrameHeader,
51
+ isControlId,
52
+ decodePayload,
53
+ decodeFrame,
54
+ encodeFrame: encodeEventFrame as (name: string, payload: unknown) => Uint8Array,
55
+ direct: {
56
+ symbols: directSymbols,
57
+ symbolNames: directSymbolNames,
58
+ encoders: directEncoders,
59
+ hasNul: hasNulEncoders,
60
+ selfTest: directSelfTest,
61
+ },
62
+ },
63
+ { events, controlEvents },
64
+ { ffiMode: "required", subjectPrefix: "ignex" },
65
+ );
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Runtime `Bindings` — the per-schema bundle that decouples the transport from
3
+ * ANY particular event registry.
4
+ *
5
+ * Today the transport modules import the repo's generated artifacts directly
6
+ * (`src/generated/registry`, `src/generated/ts-ser`, `src/generated/direct-ser`
7
+ * and `src/schema`). `Bindings` is the generic contract: every schema-specific
8
+ * piece of the wire stack (event ids, decoders, encoders, direct fast-path
9
+ * tables, schema metadata) is grouped into one object, and the server / client /
10
+ * NATS bridge accept it via `options.bindings` (defaulting to the built-in
11
+ * registry, so existing code keeps working unchanged).
12
+ *
13
+ * Two ways to obtain a `Bindings`:
14
+ * - the built-in one: `defaultBindings` (see `src/bindings/default.ts`)
15
+ * - your own schema: run `generateBindings(schema)` (public/generate.ts),
16
+ * then assemble the emitted parts with `assembleBindings`
17
+ * (src/bindings/assemble.ts). Everything else — encode, decode, NATS
18
+ * subject naming, server/client APIs — is then typed against YOUR events.
19
+ */
20
+ import type { Static, TSchema } from "@sinclair/typebox";
21
+
22
+ /** Direct fast-path call signature (generated `direct-ser.ts`). */
23
+ export type DirectCall = (...args: unknown[]) => number;
24
+
25
+ /** Generated zero-alloc encoder: fields → FFI args → out buffer. */
26
+ export type DirectEncoder = (call: DirectCall, o: unknown, out: Uint8Array) => number;
27
+
28
+ /**
29
+ * The generated direct fast-path tables (Bun server only). Absent when the
30
+ * schema has no directable events or the codegen was run with `rust: false`.
31
+ */
32
+ export interface DirectTables {
33
+ /** dlopen specs in canonical form (see `src/native/ffi.ts` `abi()`). */
34
+ readonly symbols: Readonly<Record<string, { args: readonly string[]; returns: string }>>;
35
+ /** event → FFI symbol name. */
36
+ readonly symbolNames: Readonly<Record<string, string>>;
37
+ /** event → zero-alloc encoder. */
38
+ readonly encoders: Readonly<Record<string, DirectEncoder>>;
39
+ /** event → NUL pre-scan (true routes the payload to the JSON path). */
40
+ readonly hasNul: Readonly<Record<string, (o: unknown) => boolean>>;
41
+ /** bind-time per-symbol self-test; returns the symbol names to DISABLE. */
42
+ readonly selfTest: (
43
+ raw: Record<string, (...args: unknown[]) => number>,
44
+ scratch: Uint8Array,
45
+ ) => string[];
46
+ }
47
+
48
+ /**
49
+ * The complete per-schema wire stack. `events` / `controlEvents` are the
50
+ * TypeBox schemas — from them consumers derive `EventNameOf` / `EventsOf`.
51
+ */
52
+ export interface Bindings {
53
+ /** wire envelope version (see `scripts/constants.ts`). */
54
+ readonly wireVersion: number;
55
+ /** envelope header bytes: `[version:1][event_id:u32 LE]`. */
56
+ readonly wireHeaderLen: number;
57
+ /**
58
+ * Stable schema fingerprint (FNV-1a 32 over the canonical model). The Rust
59
+ * cdylib exports the same value (`fb_schema_fingerprint`), so a schema-
60
+ * mismatched addon fails the bind-time self-test instead of producing
61
+ * undecodable frames.
62
+ */
63
+ readonly schemaFingerprint: number;
64
+ /** NATS subject prefix used by bridges built from these bindings. */
65
+ readonly subjectPrefix?: string;
66
+ /**
67
+ * "required" — the Rust addon must exist and pass self-tests (the built-in
68
+ * registry: a missing addon throws). "optional" — generated for user
69
+ * schemas: the addon is used when `IGNEX_FFI_PATH` is set and passes
70
+ * self-tests; otherwise the pure-JS encoder is used (works without Rust).
71
+ */
72
+ readonly ffiMode: "required" | "optional";
73
+ /** app event schemas (name → TypeBox). */
74
+ readonly events: Readonly<Record<string, TSchema>>;
75
+ /** control event schemas (name → TypeBox). */
76
+ readonly controlEvents: Readonly<Record<string, TSchema>>;
77
+
78
+ // ── event id maps (stable FNV-1a 32 over the name) ────────────────────
79
+ readonly eventNameToId: Readonly<Record<string, number>>;
80
+ readonly idToEventName: Readonly<Record<number, string>>;
81
+ /** merged app + control maps (encode dispatch). */
82
+ readonly anyEventNameToId: Readonly<Record<string, number>>;
83
+ readonly idToAnyEventName: Readonly<Record<number, string>>;
84
+ readonly controlIds: ReadonlySet<number>;
85
+
86
+ // ── wire helpers (pure — run in browser + Bun) ────────────────────────
87
+ readFrameHeader(bytes: Uint8Array): { name: string; id: number } | null;
88
+ isControlId(id: number): boolean;
89
+ decodePayload(id: number, bytes: Uint8Array): unknown;
90
+ decodeFrame(bytes: Uint8Array): { name: string; id: number; payload: unknown } | null;
91
+
92
+ /** Pure-JS encoder: plain object → full wire frame. Works everywhere. */
93
+ encodeFrame(name: string, payload: unknown): Uint8Array;
94
+
95
+ /** Generated direct fast-path tables (optional — server only). */
96
+ readonly direct?: DirectTables;
97
+ }
98
+
99
+ // ── type-level derivation from a concrete Bindings ────────────────────────
100
+
101
+ export type EventNameOf<B extends Bindings> = Extract<keyof B["events"], string>;
102
+ export type ControlEventNameOf<B extends Bindings> = Extract<keyof B["controlEvents"], string>;
103
+
104
+ /** Plain-object payload map derived from a bindings' TypeBox schemas. */
105
+ export type EventsOf<B extends Bindings> = {
106
+ [K in EventNameOf<B>]: Static<B["events"][K]>;
107
+ };
108
+ export type ControlEventsOf<B extends Bindings> = {
109
+ [K in ControlEventNameOf<B>]: Static<B["controlEvents"][K]>;
110
+ };
111
+
112
+ /** The built-in registry's bindings type (see `src/bindings/default.ts`). */
113
+ export type DefaultBindings = typeof import("./default").defaultBindings;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Inbound pipeline — the bridge's NATS→clients forward. Subscribes a subject
3
+ * and, for each arriving frame: reads the envelope header (cheap), rejects
4
+ * transport-internal control frames and non-allowlisted events, then decodes
5
+ * the payload and hands `{name, payload}` to the wired `onInbound` callback.
6
+ *
7
+ * Decode failures are counted in `stats.bridgeInboundErrors`, never thrown —
8
+ * the WS hot path must not depend on broker input being well-formed.
9
+ */
10
+ import type { Bindings } from "../../bindings/types";
11
+ import type { NatsBridgeStats, NatsTransport } from "./types";
12
+
13
+ export function subscribeInboundSubject(deps: {
14
+ transport: NatsTransport;
15
+ bindings: Bindings;
16
+ stats: NatsBridgeStats;
17
+ subject: string;
18
+ /** allowlist; `null` = forward every app event */
19
+ allowlist: Set<string> | null;
20
+ /** set once by the server (the fan-out-to-clients callback) */
21
+ getOnInbound(): ((name: string, payload: unknown) => void) | null;
22
+ }): () => void {
23
+ const { transport, bindings, stats, subject, allowlist } = deps;
24
+ return transport.subscribe(subject, (data) => {
25
+ // header-first: reject junk/control frames before any payload decode
26
+ const header = bindings.readFrameHeader(data);
27
+ if (!header) {
28
+ stats.bridgeInboundErrors++;
29
+ return;
30
+ }
31
+ if (bindings.isControlId(header.id)) {
32
+ stats.bridgeInboundErrors++; // never forward transport-internal frames
33
+ return;
34
+ }
35
+ if (allowlist && !allowlist.has(header.name)) return;
36
+ let payload: unknown;
37
+ try {
38
+ payload = bindings.decodePayload(header.id, data);
39
+ } catch {
40
+ stats.bridgeInboundErrors++;
41
+ return;
42
+ }
43
+ stats.bridgeInbound++;
44
+ deps.getOnInbound()?.(header.name, payload);
45
+ });
46
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * NATS bridge — bidirectional FlatBuffer transport over NATS (composition
3
+ * root). Decomposed by concern:
4
+ *
5
+ * types.ts — options / stats / NatsTransport / NatsBridge contracts
6
+ * real-transport.ts — the eager, non-blocking connection with retry loop
7
+ * inbound.ts — the NATS→clients decode + filter pipeline
8
+ *
9
+ * OUTBOUND: the server encodes each event ONCE (Rust FFI → scratch), fans the
10
+ * same frame out to WS clients, then hands a COPY to `bridge.publish(subject,
11
+ * frame)` so other applications consume the identical wire bytes. Best-effort:
12
+ * if NATS is down the frame is dropped and counted in `bridgeErrors` — it
13
+ * never blocks or throws on the WS hot path.
14
+ *
15
+ * INBOUND: when `inbound` is enabled the bridge subscribes to `{prefix}.
16
+ * inbound.>` and forwards decodable app events to `onInbound` (wired by the
17
+ * server to fan out to clients). Control frames and unknown ids are dropped.
18
+ *
19
+ * HORIZONTAL SCALING: when `bridgeClientEvents` is set, the server re-publishes
20
+ * every accepted client-sent event to `{prefix}.inbound.<event>` so OTHER
21
+ * server instances (and BE consumers) receive it. Loop prevention: frames that
22
+ * arrive via NATS are forwarded through `onInbound` and never re-bridged.
23
+ *
24
+ * GENERIC: decodes inbound frames with the given `Bindings` (default: the
25
+ * built-in registry), so the bridge works for ANY schema. Tests can inject an
26
+ * `NatsTransport` fake — no broker needed in CI.
27
+ */
28
+ import { defaultBindings } from "../../bindings/default";
29
+ import type { Bindings } from "../../bindings/types";
30
+ import { createSubjectBuilder } from "../subjects";
31
+ import { subscribeInboundSubject } from "./inbound";
32
+ import { createRealTransport } from "./real-transport";
33
+ import type {
34
+ NatsBridge,
35
+ NatsBridgeOptions,
36
+ NatsBridgeStats,
37
+ NatsBridgeStatus,
38
+ NatsTransport,
39
+ } from "./types";
40
+
41
+ export type {
42
+ NatsBridge,
43
+ NatsBridgeOptions,
44
+ NatsBridgeStats,
45
+ NatsBridgeStatus,
46
+ NatsTransport,
47
+ } from "./types";
48
+
49
+ export function createNatsBridge(
50
+ opts: NatsBridgeOptions = {},
51
+ transport?: NatsTransport,
52
+ bindings?: Bindings,
53
+ ): NatsBridge {
54
+ const b = bindings ?? opts.bindings ?? defaultBindings;
55
+ const t = transport ?? createRealTransport(opts);
56
+ const subjects = createSubjectBuilder(opts.subjectPrefix ?? b.subjectPrefix ?? "ignex");
57
+ const stats: NatsBridgeStats = {
58
+ bridged: 0,
59
+ bridgedBytes: 0,
60
+ bridgeErrors: 0,
61
+ bridgeInbound: 0,
62
+ bridgeInboundErrors: 0,
63
+ };
64
+ let closed = false;
65
+ let onInbound: ((name: string, payload: unknown) => void) | null = null;
66
+ // stable accessor (the callback slot is set once by the server later)
67
+ const getOnInbound = (): ((name: string, payload: unknown) => void) | null => onInbound;
68
+ const allowlist = opts.inboundEvents ? new Set(opts.inboundEvents) : null;
69
+
70
+ // inbound subscriptions (lazy — the transport queues them until connected)
71
+ const unsubs: Array<() => void> = [];
72
+ if (opts.inbound) {
73
+ const subjectsList = opts.inboundSubjects?.length
74
+ ? opts.inboundSubjects
75
+ : [subjects.inboundPrefix()];
76
+ for (const subject of subjectsList) {
77
+ unsubs.push(
78
+ subscribeInboundSubject({
79
+ transport: t,
80
+ bindings: b,
81
+ stats,
82
+ subject,
83
+ allowlist,
84
+ getOnInbound,
85
+ }),
86
+ );
87
+ }
88
+ }
89
+
90
+ return {
91
+ get status(): NatsBridgeStatus {
92
+ if (closed) return "closed";
93
+ return t.connected ? "connected" : "connecting";
94
+ },
95
+ get subjects() {
96
+ return subjects;
97
+ },
98
+ get stats() {
99
+ return stats;
100
+ },
101
+ get clientEvents(): boolean {
102
+ return opts.bridgeClientEvents ?? false;
103
+ },
104
+ publish(subject, frame) {
105
+ if (!t.connected) {
106
+ stats.bridgeErrors++;
107
+ return;
108
+ }
109
+ // the frame view is a reused scratch — copy before handing to NATS
110
+ const copy = frame.slice();
111
+ try {
112
+ t.publish(subject, copy);
113
+ stats.bridged++;
114
+ stats.bridgedBytes += copy.byteLength;
115
+ } catch {
116
+ stats.bridgeErrors++;
117
+ }
118
+ },
119
+ setOnInbound(cb) {
120
+ onInbound = cb;
121
+ },
122
+ subscribeRaw(subject, cb) {
123
+ return t.subscribe(subject, (data) => cb(data));
124
+ },
125
+ async close() {
126
+ closed = true;
127
+ for (const u of unsubs) u();
128
+ await t.close();
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Real NATS transport — an eager, non-blocking `NatsConnection` wrapper with
3
+ * an initial-connect retry loop, so a server can start while NATS is down.
4
+ *
5
+ * Subscriptions are RE-SYNCED on every (re)connect: the desired subject set
6
+ * is kept locally and replayed onto whichever connection is current —
7
+ * nats.js re-subscribes automatically after a reconnect, but the resync
8
+ * guards against edge cases (e.g. a replaced connection).
9
+ */
10
+ import { connect, type NatsConnection } from "nats";
11
+ import type { NatsBridgeOptions, NatsTransport } from "./types";
12
+
13
+ interface PendingSub {
14
+ subject: string;
15
+ cb: (data: Uint8Array) => void;
16
+ }
17
+
18
+ export function createRealTransport(opts: NatsBridgeOptions): NatsTransport {
19
+ let nc: NatsConnection | null = null;
20
+ let connected = false;
21
+ let closed = false;
22
+ // desired subscriptions — replayed on every (re)connect via sync()
23
+ const subs: Array<PendingSub> = [];
24
+ let unsubs: Array<() => void> = [];
25
+
26
+ /** Tear down the current subscription iterators and re-subscribe all. */
27
+ const sync = (): void => {
28
+ for (const u of unsubs) u();
29
+ unsubs = [];
30
+ if (!nc) return;
31
+ for (const s of subs) {
32
+ const sub = nc.subscribe(s.subject);
33
+ unsubs.push(() => sub.unsubscribe());
34
+ void (async () => {
35
+ try {
36
+ for await (const m of sub) s.cb(new Uint8Array(m.data));
37
+ } catch {
38
+ // subscription ended / connection closed
39
+ }
40
+ })();
41
+ }
42
+ };
43
+
44
+ /** Track connection liveness + trigger resyncs from nats.js status events. */
45
+ const attachStatus = (conn: NatsConnection): void => {
46
+ void conn
47
+ .closed()
48
+ .then(() => {
49
+ connected = false;
50
+ if (nc === conn) nc = null;
51
+ })
52
+ .catch(() => {
53
+ connected = false;
54
+ });
55
+ void (async () => {
56
+ try {
57
+ for await (const st of conn.status()) {
58
+ if (st.type === "disconnect") connected = false;
59
+ else if (st.type === "reconnect") {
60
+ connected = true;
61
+ sync(); // nats.js re-subscribes automatically; resync to be safe
62
+ }
63
+ }
64
+ } catch {
65
+ connected = false;
66
+ }
67
+ })();
68
+ };
69
+
70
+ let connecting = false;
71
+ const tryConnect = async (): Promise<void> => {
72
+ // one attempt at a time; a live connection (even mid-reconnect, which
73
+ // nats.js drives internally) is never replaced by a duplicate dial
74
+ if (closed || connecting || nc !== null) return;
75
+ connecting = true;
76
+ try {
77
+ const conn = await connect({
78
+ servers: opts.servers ?? ["nats://localhost:4222"],
79
+ ...(opts.token !== undefined ? { token: opts.token } : {}),
80
+ timeout: opts.connectTimeout ?? 5000,
81
+ reconnect: opts.reconnect ?? true,
82
+ maxReconnectAttempts: -1,
83
+ });
84
+ nc = conn;
85
+ connected = true;
86
+ attachStatus(conn);
87
+ sync();
88
+ } catch {
89
+ connected = false;
90
+ } finally {
91
+ connecting = false;
92
+ }
93
+ };
94
+
95
+ // background retry loop — the caller NEVER awaits a dial
96
+ void (async () => {
97
+ while (!closed) {
98
+ if (nc === null) await tryConnect();
99
+ await Bun.sleep(opts.connectRetryMs ?? 2000);
100
+ }
101
+ })();
102
+
103
+ return {
104
+ get connected() {
105
+ return connected;
106
+ },
107
+ publish(subject, data) {
108
+ if (!nc) throw new Error("nats: not connected");
109
+ nc.publish(subject, data);
110
+ },
111
+ subscribe(subject, cb) {
112
+ subs.push({ subject, cb });
113
+ sync();
114
+ return () => {
115
+ const i = subs.findIndex((s) => s.subject === subject && s.cb === cb);
116
+ if (i >= 0) subs.splice(i, 1);
117
+ sync();
118
+ };
119
+ },
120
+ async close() {
121
+ closed = true;
122
+ if (nc) {
123
+ try {
124
+ await nc.close();
125
+ } catch {
126
+ // already closed
127
+ }
128
+ }
129
+ nc = null;
130
+ connected = false;
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * NATS bridge contracts — options, stats, and the transport/bridge surfaces.
3
+ *
4
+ * Type + contract module: the runtime lives in `real-transport.ts` (the
5
+ * eager connection) and `index.ts` (the bridge itself).
6
+ */
7
+ import type { SubjectBuilder } from "../subjects";
8
+ import type { Bindings } from "../../bindings/types";
9
+
10
+ export type NatsBridgeStatus = "connected" | "connecting" | "closed";
11
+
12
+ export interface NatsBridgeOptions {
13
+ /** NATS servers, default ["nats://localhost:4222"] */
14
+ servers?: string[];
15
+ /** subject prefix, default "ignex" (or the bindings' subjectPrefix) */
16
+ subjectPrefix?: string;
17
+ /**
18
+ * The wire stack used to decode inbound frames (default: built-in registry).
19
+ * Pass your own generated bindings so the bridge decodes YOUR events.
20
+ */
21
+ bindings?: Bindings;
22
+ /** connect timeout (ms), default 5000 */
23
+ connectTimeout?: number;
24
+ /** how long to wait before retrying a failed initial connect (ms), default 2000 */
25
+ connectRetryMs?: number;
26
+ /** reconnect handled by nats.js (core NATS, no durable queues), default true */
27
+ reconnect?: boolean;
28
+ /** optional NATS token (auth) */
29
+ token?: string;
30
+ /** subscribe to inbound subjects and forward events to clients, default false */
31
+ inbound?: boolean;
32
+ /** inbound subjects (default `{prefix}.inbound.>`), requires `inbound` */
33
+ inboundSubjects?: string[];
34
+ /** only forward these inbound events (default: every app event) */
35
+ inboundEvents?: string[];
36
+ /**
37
+ * Re-publish every accepted client-sent event to `{prefix}.inbound.<event>`
38
+ * so other servers in the cluster (and BE consumers) receive it, default
39
+ * false. See the horizontal-scaling docs.
40
+ */
41
+ bridgeClientEvents?: boolean;
42
+ }
43
+
44
+ /** Counters folded into `server.getMetrics()`. */
45
+ export interface NatsBridgeStats {
46
+ bridged: number;
47
+ bridgedBytes: number;
48
+ bridgeErrors: number;
49
+ bridgeInbound: number;
50
+ bridgeInboundErrors: number;
51
+ }
52
+
53
+ /** Minimal transport — a real NATS connection or a test fake. */
54
+ export interface NatsTransport {
55
+ readonly connected: boolean;
56
+ /** synchronously send bytes; throws when not connected (bridge catches + counts) */
57
+ publish(subject: string, data: Uint8Array): void;
58
+ /** subscribe; `cb` receives message bytes; returns an unsubscribe function */
59
+ subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
60
+ close(): Promise<void>;
61
+ }
62
+
63
+ export interface NatsBridge {
64
+ readonly status: NatsBridgeStatus;
65
+ readonly subjects: SubjectBuilder;
66
+ readonly stats: NatsBridgeStats;
67
+ /** whether client-sent events are re-published to `{prefix}.inbound.<event>` */
68
+ readonly clientEvents: boolean;
69
+ /** publish a frame to `subject` (copies the bytes — safe after scratch reuse) */
70
+ publish(subject: string, frame: Uint8Array): void;
71
+ /**
72
+ * Raw byte subscription (used by the events cluster layer). Unlike the
73
+ * inbound path this does NOT decode or forward — bytes are handed to `cb`
74
+ * verbatim, re-subscribed automatically after a NATS reconnect.
75
+ */
76
+ subscribeRaw(subject: string, cb: (data: Uint8Array) => void): () => void;
77
+ /** wire the inbound → clients forward (set once by the server) */
78
+ setOnInbound(cb: (name: string, payload: unknown) => void): void;
79
+ close(): Promise<void>;
80
+ }
@@ -18,6 +18,8 @@ export interface SubjectBuilder {
18
18
  group(group: string, name: string): string;
19
19
  /** wildcard subject the server subscribes to for inbound events */
20
20
  inboundPrefix(): string;
21
+ /** concrete inbound subject for one event — used to re-publish client events into the cluster */
22
+ inboundEvent(name: string): string;
21
23
  }
22
24
 
23
25
  export function createSubjectBuilder(prefix = "ignex"): SubjectBuilder {
@@ -26,5 +28,6 @@ export function createSubjectBuilder(prefix = "ignex"): SubjectBuilder {
26
28
  topic: (topic, name) => `${prefix}.topic.${topic}.${name}`,
27
29
  group: (group, name) => `${prefix}.group.${group}.${name}`,
28
30
  inboundPrefix: () => `${prefix}.inbound.>`,
31
+ inboundEvent: (name) => `${prefix}.inbound.${name}`,
29
32
  };
30
33
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared wire-format constants. These are the SINGLE source of truth for the
3
+ * transport envelope — every emitter (TS registry, Rust glue, direct serde)
4
+ * imports them so the TS and Rust sides stay in sync by construction.
5
+ *
6
+ * The Rust `fb_wire_version()` export is checked against `WIRE_VERSION` at
7
+ * bind time (`src/native/ffi.ts`) to catch any drift between a stale cdylib
8
+ * and the generated artifacts.
9
+ */
10
+
11
+ /** Wire format version. Bump on any BREAKING envelope change. */
12
+ export const WIRE_VERSION = 2;
13
+
14
+ /**
15
+ * Envelope header length in bytes:
16
+ * [version:1][event_id:u32 LE][flags:1][seq:u64 LE]
17
+ * The size-prefixed FlatBuffer payload follows immediately after.
18
+ *
19
+ * `flags` bit0 = seq-valid: the server stamps a per-CONNECTION delivery seq on
20
+ * every frame it writes to a socket (mutated in place just before `ws.send`,
21
+ * which copies). Clients use it for gap detection + resume. Frames that were
22
+ * not per-destination stamped (client-encoded, replay history copies before
23
+ * stamping) carry flags=0 / seq=0.
24
+ */
25
+ export const WIRE_HEADER_LEN = 14;
26
+
27
+ /** flags bit: `seq` field carries a valid per-connection delivery sequence. */
28
+ export const WIRE_FLAG_SEQ = 1;