@ignex/nova 0.1.1 → 0.1.3

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 (67) hide show
  1. package/README.md +132 -32
  2. package/docs/ai/LOCAL_DEV.md +81 -0
  3. package/docs/ai/TREE.md +232 -0
  4. package/docs/architecture.md +35 -10
  5. package/docs/events.md +170 -0
  6. package/docs/generic-bindings.md +197 -0
  7. package/docs/publishing.md +2 -2
  8. package/docs/wire-format.md +9 -2
  9. package/index.ts +75 -27
  10. package/package.json +12 -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 +416 -0
  16. package/public/internal.ts +16 -0
  17. package/public/nats.ts +9 -5
  18. package/public/server.ts +42 -16
  19. package/rust/src/ffi.rs +10 -0
  20. package/rust/src/transcode/generated.rs +2 -1
  21. package/src/bindings/assemble.ts +73 -0
  22. package/src/bindings/default.ts +65 -0
  23. package/src/bindings/types.ts +113 -0
  24. package/src/bridge/nats.ts +53 -13
  25. package/src/bridge/subjects.ts +3 -0
  26. package/src/codegen/constants.ts +18 -0
  27. package/src/codegen/direct-gen.ts +550 -0
  28. package/src/codegen/fingerprint.ts +44 -0
  29. package/src/codegen/hash.ts +25 -0
  30. package/src/codegen/registry-gen.ts +242 -0
  31. package/src/codegen/rust-glue-gen.ts +545 -0
  32. package/src/codegen/schema-model.ts +338 -0
  33. package/src/codegen/ts-ser-gen.ts +221 -0
  34. package/src/codegen/typebox-to-fbs.ts +60 -0
  35. package/src/core/auth.ts +2 -1
  36. package/src/core/client-heartbeat.ts +2 -1
  37. package/src/core/client-reconnect.ts +9 -2
  38. package/src/core/client-state.ts +21 -8
  39. package/src/core/client-wire.ts +10 -11
  40. package/src/core/client.ts +34 -29
  41. package/src/core/groups.ts +3 -0
  42. package/src/core/metrics.ts +7 -3
  43. package/src/core/outbound.ts +12 -5
  44. package/src/core/routing.ts +17 -8
  45. package/src/core/server.ts +108 -34
  46. package/src/core/state.ts +51 -13
  47. package/src/events/clients.ts +156 -0
  48. package/src/events/cluster.ts +732 -0
  49. package/src/events/data.ts +38 -0
  50. package/src/events/emit.ts +127 -0
  51. package/src/events/global.ts +117 -0
  52. package/src/events/groups.ts +118 -0
  53. package/src/events/hub.ts +481 -0
  54. package/src/events/index.ts +61 -0
  55. package/src/events/queue.ts +96 -0
  56. package/src/events/registry.ts +178 -0
  57. package/src/events/types.ts +378 -0
  58. package/src/generated/direct-ser.ts +2 -1
  59. package/src/generated/fbs/backend.fbs +1 -1
  60. package/src/generated/registry.ts +3 -1
  61. package/src/generated/ts-ser.ts +1 -1
  62. package/src/generated/wire-registry.json +1 -0
  63. package/src/native/ffi.ts +85 -28
  64. package/src/schema/index.ts +5 -2
  65. package/src/server.ts +7 -3
  66. package/src/transport/stats.ts +8 -4
  67. package/src/transport/transport.ts +149 -68
@@ -3,7 +3,9 @@
3
3
  * explicit state object the client action functions read/mutate. The option /
4
4
  * status types are the PUBLIC surface and are re-exported by `public/client.ts`.
5
5
  */
6
- import type { EventName, Events } from "../schema";
6
+
7
+ import { defaultBindings } from "../bindings/default";
8
+ import type { Bindings, DefaultBindings } from "../bindings/types";
7
9
 
8
10
  export type ClientStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "closed";
9
11
 
@@ -16,7 +18,14 @@ export interface IgnReconnectOptions {
16
18
  jitter?: boolean;
17
19
  }
18
20
 
19
- export interface IgnClientOptions {
21
+ export interface IgnClientOptions<B extends Bindings = DefaultBindings> {
22
+ /**
23
+ * The wire stack (event ids, decoders, encoders). Defaults to the built-in
24
+ * registry; pass your own (from `generateBindings` + `assembleBindings`) to
25
+ * speak YOUR schema. When provided, the client API (`on` / `send` / ...) is
26
+ * typed against your `Events`.
27
+ */
28
+ bindings?: B;
20
29
  /** auto-reconnect on unexpected close, default false (boolean or options) */
21
30
  reconnect?: boolean | IgnReconnectOptions;
22
31
  /** app-level ping interval in ms (0 disables), default 15000 */
@@ -25,14 +34,14 @@ export interface IgnClientOptions {
25
34
  heartbeatMisses?: number;
26
35
  }
27
36
 
28
- type Handler<K extends EventName> = (payload: Events[K]) => void;
29
-
30
37
  export interface ClientState {
31
38
  url: string;
32
- opts: IgnClientOptions;
39
+ opts: IgnClientOptions<Bindings>;
40
+ /** the wire stack this client speaks (ids / decoders / encoders). */
41
+ bindings: Bindings;
33
42
  ws: WebSocket | null;
34
- handlers: Map<EventName, Set<Handler<never>>>;
35
- anyHandlers: Set<(name: EventName, payload: unknown) => void>;
43
+ handlers: Map<string, Set<(payload: unknown) => void>>;
44
+ anyHandlers: Set<(name: string, payload: unknown) => void>;
36
45
  errorCbs: Set<(err: Error) => void>;
37
46
  statusCbs: Set<(status: ClientStatus) => void>;
38
47
  closed: boolean;
@@ -48,10 +57,14 @@ export interface ClientState {
48
57
  groups: string[];
49
58
  }
50
59
 
51
- export function createClientState(url: string, opts: IgnClientOptions = {}): ClientState {
60
+ export function createClientState<B extends Bindings = DefaultBindings>(
61
+ url: string,
62
+ opts: IgnClientOptions<B> = {},
63
+ ): ClientState {
52
64
  return {
53
65
  url,
54
66
  opts,
67
+ bindings: opts.bindings ?? defaultBindings,
55
68
  ws: null,
56
69
  handlers: new Map(),
57
70
  anyHandlers: new Set(),
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Client wire handling — outbound frame sends + inbound decode/dispatch.
3
3
  * `handleMessage` decodes the envelope, filters control frames, and fans app
4
- * events out to the registered handlers.
4
+ * events out to the registered handlers. All decode/encode goes through
5
+ * `state.bindings`, so a client speaks whatever wire stack it was given.
5
6
  */
6
- import { decodeFrame, isControlId, WIRE_VERSION } from "../generated/registry";
7
- import { encodeEventFrame } from "../generated/ts-ser";
8
- import type { ControlEventName, ControlEvents, EventName } from "../schema";
7
+ import type { ControlEventName, ControlEvents } from "../schema";
9
8
  import type { ClientState } from "./client-state";
10
9
 
11
10
  /** Send an encoded frame, if the socket is open. */
@@ -21,7 +20,7 @@ export function sendControl<K extends ControlEventName>(
21
20
  name: K,
22
21
  payload: ControlEvents[K],
23
22
  ): void {
24
- sendFrame(state, encodeEventFrame(name, payload));
23
+ sendFrame(state, state.bindings.encodeFrame(name, payload));
25
24
  }
26
25
 
27
26
  export function emitError(state: ClientState, err: Error): void {
@@ -32,10 +31,10 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
32
31
  switch (name) {
33
32
  case "hello": {
34
33
  const p = payload as ControlEvents["hello"];
35
- if (p.version !== WIRE_VERSION) {
34
+ if (p.version !== state.bindings.wireVersion) {
36
35
  // server speaks a different wire version — refuse + surface
37
36
  state.ws?.close(1002, "wire version mismatch");
38
- emitError(state, new Error(`ignex: server wire version ${p.version} does not match ${WIRE_VERSION}`));
37
+ emitError(state, new Error(`ignex: server wire version ${p.version} does not match ${state.bindings.wireVersion}`));
39
38
  }
40
39
  break;
41
40
  }
@@ -56,17 +55,17 @@ export function handleControl(state: ClientState, name: ControlEventName, payloa
56
55
  export function handleMessage(state: ClientState, data: ArrayBuffer | string): void {
57
56
  if (typeof data === "string") return; // ignore text frames
58
57
  const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : (data as Uint8Array);
59
- const frame = decodeFrame(bytes);
58
+ const frame = state.bindings.decodeFrame(bytes);
60
59
  if (!frame) {
61
60
  emitError(state, new Error("ignex: undecodable / version-mismatched frame dropped"));
62
61
  return;
63
62
  }
64
- if (isControlId(frame.id)) {
63
+ if (state.bindings.isControlId(frame.id)) {
65
64
  handleControl(state, frame.name as ControlEventName, frame.payload);
66
65
  return;
67
66
  }
68
- const name = frame.name as EventName;
67
+ const name = frame.name;
69
68
  const set = state.handlers.get(name);
70
- if (set) for (const cb of set) cb(frame.payload as never);
69
+ if (set) for (const cb of set) cb(frame.payload);
71
70
  for (const cb of state.anyHandlers) cb(name, frame.payload);
72
71
  }
@@ -4,40 +4,42 @@
4
4
  * ONLY place that knows how the pieces fit together; `connect` lives here
5
5
  * because it owns the socket lifecycle.
6
6
  *
7
+ * Generic over the wire stack: `createClient(url, { bindings })` with your own
8
+ * generated bindings types `on` / `send` / ... against YOUR events. The default
9
+ * is the built-in registry, so existing code keeps working unchanged.
10
+ *
7
11
  * Public entry: `public/client.ts` re-exports `createClient` + the types.
8
12
  */
9
- import { WIRE_VERSION } from "../generated/registry";
10
- import { encodeEventFrame } from "../generated/ts-ser";
11
- import type { Events, EventName } from "../schema";
13
+ import type { Bindings, DefaultBindings, EventNameOf, EventsOf } from "../bindings/types";
12
14
  import { createClientState, setStatus, type ClientState, type ClientStatus, type IgnClientOptions } from "./client-state";
13
15
  import { handleMessage, sendControl, sendFrame } from "./client-wire";
14
16
  import { startHeartbeat, stopHeartbeat } from "./client-heartbeat";
15
17
  import { scheduleReconnect } from "./client-reconnect";
16
18
 
17
19
  /** The public client API (returned by `createClient`). */
18
- export interface IgnClient {
19
- on<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
20
- off<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
20
+ export interface IgnClient<B extends Bindings = DefaultBindings> {
21
+ on<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
22
+ off<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
21
23
  /** Register a handler that fires once for the event, then removes itself. */
22
- once<K extends EventName>(name: K, handler: (payload: Events[K]) => void): IgnClient;
24
+ once<K extends EventNameOf<B>>(name: K, handler: (payload: EventsOf<B>[K]) => void): IgnClient<B>;
23
25
  /** Register a handler for EVERY incoming app event (name + payload). */
24
- onAny(cb: (name: EventName, payload: unknown) => void): IgnClient;
25
- offAny(cb: (name: EventName, payload: unknown) => void): IgnClient;
26
+ onAny(cb: (name: EventNameOf<B>, payload: unknown) => void): IgnClient<B>;
27
+ offAny(cb: (name: EventNameOf<B>, payload: unknown) => void): IgnClient<B>;
26
28
  /** Names that currently have at least one handler. */
27
- events(): EventName[];
29
+ events(): EventNameOf<B>[];
28
30
  /** Remove all handlers (optionally just for one event). */
29
- removeAllListeners(name?: EventName): IgnClient;
31
+ removeAllListeners(name?: EventNameOf<B>): IgnClient<B>;
30
32
  /** Register an error callback (decode failures, wire-version mismatch). */
31
- onError(cb: (err: Error) => void): IgnClient;
32
- offError(cb: (err: Error) => void): IgnClient;
33
+ onError(cb: (err: Error) => void): IgnClient<B>;
34
+ offError(cb: (err: Error) => void): IgnClient<B>;
33
35
  /** Watch connection lifecycle: "connecting" | "connected" | "disconnected" | "reconnecting" | "closed". */
34
- onStatus(cb: (status: ClientStatus) => void): IgnClient;
35
- offStatus(cb: (status: ClientStatus) => void): IgnClient;
36
+ onStatus(cb: (status: ClientStatus) => void): IgnClient<B>;
37
+ offStatus(cb: (status: ClientStatus) => void): IgnClient<B>;
36
38
  readonly currentStatus: ClientStatus;
37
- connect(): IgnClient;
39
+ connect(): IgnClient<B>;
38
40
  close(): void;
39
41
  /** Send a typed app event to the server (server must allow it via `inbound`). */
40
- send<K extends EventName>(name: K, payload: Events[K]): void;
42
+ send<K extends EventNameOf<B>>(name: K, payload: EventsOf<B>[K]): void;
41
43
  /** Ask the server to subscribe this socket to a topic (room membership + replay). */
42
44
  subscribe(topic: string): void;
43
45
  unsubscribe(topic: string): void;
@@ -50,10 +52,13 @@ export interface IgnClient {
50
52
  readonly groups: string[];
51
53
  }
52
54
 
53
- export function createClient(url: string, opts: IgnClientOptions = {}): IgnClient {
55
+ export function createClient<B extends Bindings = DefaultBindings>(
56
+ url: string,
57
+ opts: IgnClientOptions<B> = {},
58
+ ): IgnClient<B> {
54
59
  const state: ClientState = createClientState(url, opts);
55
60
 
56
- function connect(): IgnClient {
61
+ function connect(): IgnClient<B> {
57
62
  state.closed = false;
58
63
  setStatus(state, state.attempts === 0 ? "connecting" : "reconnecting");
59
64
  const ws = new WebSocket(url);
@@ -61,7 +66,7 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
61
66
  ws.onopen = () => {
62
67
  state.attempts = 0;
63
68
  setStatus(state, "connected");
64
- sendControl(state, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
69
+ sendControl(state, "hello", { version: state.bindings.wireVersion, caps: [], lastSeq: 0 });
65
70
  // re-subscribe topics from before the disconnect (server cleared them)
66
71
  for (const t of state.subscribedTopics) sendControl(state, "subscribe", { topic: t });
67
72
  startHeartbeat(state);
@@ -80,38 +85,38 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
80
85
  return api;
81
86
  }
82
87
 
83
- const api: IgnClient = {
88
+ const api: IgnClient<B> = {
84
89
  on(name, handler) {
85
90
  let set = state.handlers.get(name);
86
91
  if (!set) {
87
92
  set = new Set();
88
93
  state.handlers.set(name, set);
89
94
  }
90
- set.add(handler as never);
95
+ set.add(handler as (payload: unknown) => void);
91
96
  return api;
92
97
  },
93
98
  off(name, handler) {
94
- state.handlers.get(name)?.delete(handler as never);
99
+ state.handlers.get(name)?.delete(handler as (payload: unknown) => void);
95
100
  return api;
96
101
  },
97
102
  once(name, handler) {
98
- const wrap = (payload: never): void => {
103
+ const wrap = (payload: unknown): void => {
99
104
  api.off(name, wrap as never);
100
- handler(payload);
105
+ (handler as (payload: unknown) => void)(payload);
101
106
  };
102
107
  api.on(name, wrap as never);
103
108
  return api;
104
109
  },
105
110
  onAny(cb) {
106
- state.anyHandlers.add(cb);
111
+ state.anyHandlers.add(cb as (name: string, payload: unknown) => void);
107
112
  return api;
108
113
  },
109
114
  offAny(cb) {
110
- state.anyHandlers.delete(cb);
115
+ state.anyHandlers.delete(cb as (name: string, payload: unknown) => void);
111
116
  return api;
112
117
  },
113
118
  events() {
114
- return [...state.handlers.keys()];
119
+ return [...state.handlers.keys()] as EventNameOf<B>[];
115
120
  },
116
121
  removeAllListeners(name) {
117
122
  if (name) state.handlers.delete(name);
@@ -148,7 +153,7 @@ export function createClient(url: string, opts: IgnClientOptions = {}): IgnClien
148
153
  setStatus(state, "closed");
149
154
  },
150
155
  send(name, payload) {
151
- sendFrame(state, encodeEventFrame(name, payload));
156
+ sendFrame(state, state.bindings.encodeFrame(name, payload));
152
157
  },
153
158
  subscribe(topic) {
154
159
  state.subscribedTopics.add(topic);
@@ -21,6 +21,8 @@ export function joinGroup(state: ServerState, ws: ServerWebSocket<WsData>, group
21
21
  state.groups.set(group, set);
22
22
  }
23
23
  set.add(ws);
24
+ // events-layer hook (auth seed, control frames, programmatic joins all pass here)
25
+ state.onGroupChange?.(group, ws, true);
24
26
  }
25
27
 
26
28
  /** Remove `ws` from `group`; prune the group when it becomes empty. */
@@ -30,6 +32,7 @@ export function leaveGroup(state: ServerState, ws: ServerWebSocket<WsData>, grou
30
32
  if (!set) return;
31
33
  set.delete(ws);
32
34
  if (set.size === 0) state.groups.delete(group);
35
+ state.onGroupChange?.(group, ws, false);
33
36
  }
34
37
 
35
38
  /** Fan `frame` out to every member of `group` (no replay). */
@@ -9,6 +9,8 @@ export interface PathCounts {
9
9
  direct: number;
10
10
  /** encodes that fell back to the JSON path (nested/unions or disabled symbol) */
11
11
  json: number;
12
+ /** encodes via the pure-JS encoder (user schema without a native addon) */
13
+ js: number;
12
14
  }
13
15
 
14
16
  export interface MetricsSnapshot {
@@ -29,7 +31,7 @@ export interface MetricsSnapshot {
29
31
  /** undecodable / version-mismatched / unknown-id frames received */
30
32
  protocolErrors: number;
31
33
  bytesSent: number;
32
- /** per-event encode path counts (direct vs json) */
34
+ /** per-event encode path counts (direct vs json vs js) */
33
35
  pathCounts: Record<string, PathCounts>;
34
36
  connectedClients: number;
35
37
  uptimeMs: number;
@@ -41,6 +43,8 @@ export interface MetricsSnapshot {
41
43
  bridgeInboundErrors?: number;
42
44
  /** "connected" | "connecting" | "closed" (undefined when no bridge) */
43
45
  natsStatus?: string;
46
+ /** events-layer counters (present only when `createServer({ events })` is used) */
47
+ events?: import("../events/types").EventsMetricsSnapshot;
44
48
  }
45
49
 
46
50
  export interface Metrics {
@@ -55,7 +59,7 @@ export interface Metrics {
55
59
  bytesSent: number;
56
60
  readonly pathCounts: Map<string, PathCounts>;
57
61
  /** Count an encode on a given path for an event (direct = zero-alloc FFI). */
58
- countPath(name: string, path: "direct" | "json"): void;
62
+ countPath(name: string, path: "direct" | "json" | "js"): void;
59
63
  snapshot(connectedClients: number): MetricsSnapshot;
60
64
  }
61
65
 
@@ -76,7 +80,7 @@ export function createMetrics(startedAt = Date.now()): Metrics {
76
80
  countPath(name, path) {
77
81
  let pc = pathCounts.get(name);
78
82
  if (!pc) {
79
- pc = { direct: 0, json: 0 };
83
+ pc = { direct: 0, json: 0, js: 0 };
80
84
  pathCounts.set(name, pc);
81
85
  }
82
86
  pc[path]++;
@@ -4,11 +4,10 @@
4
4
  * `decide()` result; `doSend` is the single accounting point.
5
5
  */
6
6
  import type { ServerWebSocket } from "bun";
7
- import { encodeToScratch } from "../transport/transport";
7
+ import type { ControlEventName, ControlEvents } from "../schema";
8
8
  import { decide } from "./backpressure";
9
9
  import { RingBuffer } from "./ring";
10
10
  import type { ServerState, WsData } from "./state";
11
- import type { ControlEventName, ControlEvents } from "../schema";
12
11
 
13
12
  /** Actual socket write + counters (single accounting point). */
14
13
  export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
@@ -24,7 +23,11 @@ export function doSend(state: ServerState, ws: ServerWebSocket<WsData>, frame: U
24
23
  * replaced by a bounded queue (drop-oldest) / skip (drop-newest) / close
25
24
  * (disconnect).
26
25
  */
27
- export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame: Uint8Array): void {
26
+ export function sendFrame(
27
+ state: ServerState,
28
+ ws: ServerWebSocket<WsData>,
29
+ frame: Uint8Array,
30
+ ): void {
28
31
  const bp = state.bp;
29
32
  if (!bp) {
30
33
  doSend(state, ws, frame);
@@ -44,7 +47,11 @@ export function sendFrame(state: ServerState, ws: ServerWebSocket<WsData>, frame
44
47
  return;
45
48
  case "enqueue": {
46
49
  // RingBuffer: O(1) push + drop-from-head (no array shift() memmove).
47
- const q = (ws.data.queue ??= new RingBuffer<Uint8Array>());
50
+ let q = ws.data.queue;
51
+ if (q === undefined) {
52
+ q = new RingBuffer<Uint8Array>();
53
+ ws.data.queue = q;
54
+ }
48
55
  q.push(frame.slice()); // owned copy for the queue
49
56
  for (let i = 0; i < d.dropHead; i++) {
50
57
  q.shift();
@@ -72,5 +79,5 @@ export function sendControl<K extends ControlEventName>(
72
79
  name: K,
73
80
  payload: ControlEvents[K],
74
81
  ): void {
75
- sendFrame(state, ws, encodeToScratch(name, payload));
82
+ sendFrame(state, ws, state.transport.encodeToScratch(name, payload));
76
83
  }
@@ -10,8 +10,7 @@
10
10
  * going to discard.
11
11
  */
12
12
  import type { ServerWebSocket } from "bun";
13
- import { decodePayload, isControlId, readFrameHeader, WIRE_VERSION } from "../generated/registry";
14
- import type { ControlEventName, ControlEvents, EventName } from "../schema";
13
+ import type { ControlEventName, ControlEvents } from "../schema";
15
14
  import { sendControl } from "./outbound";
16
15
  import { joinRoom, leaveRoom } from "./rooms";
17
16
  import { joinGroup, leaveGroup } from "./groups";
@@ -31,20 +30,30 @@ export function handleMessage(
31
30
  ws.close(1009, "message too big");
32
31
  return;
33
32
  }
34
- const header = readFrameHeader(bytes);
33
+ const header = state.bindings.readFrameHeader(bytes);
35
34
  if (!header) {
36
35
  state.metrics.protocolErrors++;
37
36
  return; // undecodable / wrong version / unknown id — drop
38
37
  }
39
- if (isControlId(header.id)) {
38
+ if (state.bindings.isControlId(header.id)) {
40
39
  state.metrics.inboundControl++;
41
- handleControl(state, ws, header.name as ControlEventName, decodePayload(header.id, bytes) as never);
40
+ handleControl(state, ws, header.name as ControlEventName, state.bindings.decodePayload(header.id, bytes) as never);
42
41
  return;
43
42
  }
44
- const name = header.name as EventName;
43
+ const name = header.name;
45
44
  if (!state.inbound.has(name)) return; // not an allowed inbound event — no payload decode
46
45
  state.metrics.inbound++;
47
- state.inboundHandlers.get(name)?.(decodePayload(header.id, bytes), ws);
46
+ state.inboundHandlers.get(name)?.(state.bindings.decodePayload(header.id, bytes), ws);
47
+ // Horizontal scaling: when the bridge is configured with `bridgeClientEvents`,
48
+ // every accepted client event is re-published to `{prefix}.inbound.<event>` so
49
+ // OTHER server instances (and BE consumers) receive it. NATS-inbound frames
50
+ // arrive via `onInbound` → `fanOutAll` (never through this path), so there is
51
+ // no loop; this server's own clients are re-delivered exactly once through its
52
+ // own inbound subscription (the app handler should therefore not ALSO
53
+ // broadcast, or the event would be delivered twice locally).
54
+ if (state.bridge?.clientEvents) {
55
+ state.bridge.publish(state.bridge.subjects.inboundEvent(name), bytes);
56
+ }
48
57
  }
49
58
 
50
59
  export function handleControl(
@@ -58,7 +67,7 @@ export function handleControl(
58
67
  const p = payload as ControlEvents["hello"];
59
68
  ws.data.version = p.version;
60
69
  ws.data.lastSeq = p.lastSeq;
61
- if (p.version !== WIRE_VERSION) {
70
+ if (p.version !== state.bindings.wireVersion) {
62
71
  // protocol version mismatch — refuse this client
63
72
  ws.close(1002, "wire version mismatch");
64
73
  }