@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.
- package/README.md +132 -32
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +232 -0
- package/docs/architecture.md +35 -10
- package/docs/events.md +170 -0
- package/docs/generic-bindings.md +197 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +9 -2
- package/index.ts +75 -27
- package/package.json +12 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +416 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +42 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/transcode/generated.rs +2 -1
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats.ts +53 -13
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +18 -0
- package/src/codegen/direct-gen.ts +550 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +242 -0
- package/src/codegen/rust-glue-gen.ts +545 -0
- package/src/codegen/schema-model.ts +338 -0
- package/src/codegen/ts-ser-gen.ts +221 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +2 -1
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-state.ts +21 -8
- package/src/core/client-wire.ts +10 -11
- package/src/core/client.ts +34 -29
- package/src/core/groups.ts +3 -0
- package/src/core/metrics.ts +7 -3
- package/src/core/outbound.ts +12 -5
- package/src/core/routing.ts +17 -8
- package/src/core/server.ts +108 -34
- package/src/core/state.ts +51 -13
- package/src/events/clients.ts +156 -0
- package/src/events/cluster.ts +732 -0
- package/src/events/data.ts +38 -0
- package/src/events/emit.ts +127 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub.ts +481 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +96 -0
- package/src/events/registry.ts +178 -0
- package/src/events/types.ts +378 -0
- package/src/generated/direct-ser.ts +2 -1
- package/src/generated/fbs/backend.fbs +1 -1
- package/src/generated/registry.ts +3 -1
- package/src/generated/ts-ser.ts +1 -1
- package/src/generated/wire-registry.json +1 -0
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +5 -2
- package/src/server.ts +7 -3
- package/src/transport/stats.ts +8 -4
- package/src/transport/transport.ts +149 -68
package/src/core/client-state.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
35
|
-
anyHandlers: Set<(name:
|
|
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
|
|
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(),
|
package/src/core/client-wire.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 !==
|
|
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 ${
|
|
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
|
|
67
|
+
const name = frame.name;
|
|
69
68
|
const set = state.handlers.get(name);
|
|
70
|
-
if (set) for (const cb of set) cb(frame.payload
|
|
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
|
}
|
package/src/core/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
20
|
-
off<K extends
|
|
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
|
|
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:
|
|
25
|
-
offAny(cb: (name:
|
|
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():
|
|
29
|
+
events(): EventNameOf<B>[];
|
|
28
30
|
/** Remove all handlers (optionally just for one event). */
|
|
29
|
-
removeAllListeners(name?:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
156
|
+
sendFrame(state, state.bindings.encodeFrame(name, payload));
|
|
152
157
|
},
|
|
153
158
|
subscribe(topic) {
|
|
154
159
|
state.subscribedTopics.add(topic);
|
package/src/core/groups.ts
CHANGED
|
@@ -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). */
|
package/src/core/metrics.ts
CHANGED
|
@@ -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]++;
|
package/src/core/outbound.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/core/routing.ts
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
* going to discard.
|
|
11
11
|
*/
|
|
12
12
|
import type { ServerWebSocket } from "bun";
|
|
13
|
-
import {
|
|
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
|
|
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 !==
|
|
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
|
}
|