@ignex/nova 0.1.1
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/LICENSE +21 -0
- package/README.md +313 -0
- package/docs/architecture.md +146 -0
- package/docs/publishing.md +119 -0
- package/docs/wire-format.md +170 -0
- package/index.ts +61 -0
- package/package.json +89 -0
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/client.ts +23 -0
- package/public/nats.ts +19 -0
- package/public/server.ts +35 -0
- package/rust/Cargo.toml +19 -0
- package/rust/src/ffi.rs +135 -0
- package/rust/src/generated/backend.rs +2817 -0
- package/rust/src/generated/mod.rs +2 -0
- package/rust/src/lib.rs +9 -0
- package/rust/src/transcode/generated.rs +1352 -0
- package/rust/src/transcode/mod.rs +2 -0
- package/src/bridge/nats.ts +269 -0
- package/src/bridge/subjects.ts +30 -0
- package/src/core/auth.ts +56 -0
- package/src/core/backpressure.ts +39 -0
- package/src/core/client-heartbeat.ts +27 -0
- package/src/core/client-reconnect.ts +35 -0
- package/src/core/client-state.ts +76 -0
- package/src/core/client-wire.ts +72 -0
- package/src/core/client.ts +176 -0
- package/src/core/groups.ts +52 -0
- package/src/core/int64-guard.ts +44 -0
- package/src/core/metrics.ts +105 -0
- package/src/core/outbound.ts +76 -0
- package/src/core/replay.ts +31 -0
- package/src/core/ring.ts +85 -0
- package/src/core/rooms.ts +44 -0
- package/src/core/routing.ts +94 -0
- package/src/core/server.ts +294 -0
- package/src/core/state.ts +179 -0
- package/src/generated/direct-ser.ts +495 -0
- package/src/generated/fbs/backend.fbs +139 -0
- package/src/generated/registry.ts +341 -0
- package/src/generated/rust/backend_generated.rs +2817 -0
- package/src/generated/ts/backend.ts +25 -0
- package/src/generated/ts/big-val.ts +106 -0
- package/src/generated/ts/complex.ts +303 -0
- package/src/generated/ts/customer.ts +137 -0
- package/src/generated/ts/hello.ts +123 -0
- package/src/generated/ts/join-group.ts +78 -0
- package/src/generated/ts/leave-group.ts +78 -0
- package/src/generated/ts/order-billing.ts +137 -0
- package/src/generated/ts/order-line.ts +144 -0
- package/src/generated/ts/order.ts +236 -0
- package/src/generated/ts/ping.ts +74 -0
- package/src/generated/ts/pong.ts +74 -0
- package/src/generated/ts/portfolio-position.ts +120 -0
- package/src/generated/ts/portfolio-snapshot.ts +170 -0
- package/src/generated/ts/quote.ts +148 -0
- package/src/generated/ts/side.ts +8 -0
- package/src/generated/ts/snapshot-request.ts +78 -0
- package/src/generated/ts/subscribe.ts +78 -0
- package/src/generated/ts/tags.ts +9 -0
- package/src/generated/ts/trade.ts +135 -0
- package/src/generated/ts/unsubscribe.ts +78 -0
- package/src/generated/ts/welcome.ts +112 -0
- package/src/generated/ts-ser.ts +465 -0
- package/src/generated/wire-registry.json +20 -0
- package/src/native/codec.ts +35 -0
- package/src/native/ffi.ts +214 -0
- package/src/native/loader.ts +55 -0
- package/src/schema/index.ts +217 -0
- package/src/server.ts +87 -0
- package/src/transport/byte-buffer-pool.ts +63 -0
- package/src/transport/scratch.ts +48 -0
- package/src/transport/stats.ts +44 -0
- package/src/transport/transport.ts +106 -0
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
* The connection is created eagerly but non-blocking: `connect()` runs in the
|
|
15
|
+
* background with a retry loop, so a server can start while NATS is down.
|
|
16
|
+
* `createNatsBridge(opts, transport?)` accepts an injectable `NatsTransport`
|
|
17
|
+
* so tests can fake NATS entirely (no server needed in CI).
|
|
18
|
+
*/
|
|
19
|
+
import { connect, type NatsConnection } from "nats";
|
|
20
|
+
import { decodePayload, isControlId, readFrameHeader } from "../generated/registry";
|
|
21
|
+
import type { EventName } from "../schema";
|
|
22
|
+
import { createSubjectBuilder, type SubjectBuilder } from "./subjects";
|
|
23
|
+
|
|
24
|
+
export type NatsBridgeStatus = "connected" | "connecting" | "closed";
|
|
25
|
+
|
|
26
|
+
export interface NatsBridgeOptions {
|
|
27
|
+
/** NATS servers, default ["nats://localhost:4222"] */
|
|
28
|
+
servers?: string[];
|
|
29
|
+
/** subject prefix, default "ignex" */
|
|
30
|
+
subjectPrefix?: string;
|
|
31
|
+
/** connect timeout (ms), default 5000 */
|
|
32
|
+
connectTimeout?: number;
|
|
33
|
+
/** how long to wait before retrying a failed initial connect (ms), default 2000 */
|
|
34
|
+
connectRetryMs?: number;
|
|
35
|
+
/** reconnect handled by nats.js (core NATS, no durable queues), default true */
|
|
36
|
+
reconnect?: boolean;
|
|
37
|
+
/** optional NATS token (auth) */
|
|
38
|
+
token?: string;
|
|
39
|
+
/** subscribe to inbound subjects and forward events to clients, default false */
|
|
40
|
+
inbound?: boolean;
|
|
41
|
+
/** inbound subjects (default `{prefix}.inbound.>`), requires `inbound` */
|
|
42
|
+
inboundSubjects?: string[];
|
|
43
|
+
/** only forward these inbound events (default: every app event) */
|
|
44
|
+
inboundEvents?: EventName[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Counters folded into `server.getMetrics()`. */
|
|
48
|
+
export interface NatsBridgeStats {
|
|
49
|
+
bridged: number;
|
|
50
|
+
bridgedBytes: number;
|
|
51
|
+
bridgeErrors: number;
|
|
52
|
+
bridgeInbound: number;
|
|
53
|
+
bridgeInboundErrors: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Minimal transport — a real NATS connection or a test fake. */
|
|
57
|
+
export interface NatsTransport {
|
|
58
|
+
readonly connected: boolean;
|
|
59
|
+
/** synchronously send bytes; throws when not connected (bridge catches + counts) */
|
|
60
|
+
publish(subject: string, data: Uint8Array): void;
|
|
61
|
+
/** subscribe; `cb` receives message bytes; returns an unsubscribe function */
|
|
62
|
+
subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NatsBridge {
|
|
67
|
+
readonly status: NatsBridgeStatus;
|
|
68
|
+
readonly subjects: SubjectBuilder;
|
|
69
|
+
readonly stats: NatsBridgeStats;
|
|
70
|
+
/** publish a frame to `subject` (copies the bytes — safe after scratch reuse) */
|
|
71
|
+
publish(subject: string, frame: Uint8Array): void;
|
|
72
|
+
/** wire the inbound → clients forward (set once by the server) */
|
|
73
|
+
setOnInbound(cb: (name: EventName, payload: unknown) => void): void;
|
|
74
|
+
close(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Eager, non-blocking real transport with an initial-connect retry loop. */
|
|
78
|
+
function createRealTransport(opts: NatsBridgeOptions): NatsTransport {
|
|
79
|
+
let nc: NatsConnection | null = null;
|
|
80
|
+
let connected = false;
|
|
81
|
+
let closed = false;
|
|
82
|
+
const subs: Array<{ subject: string; cb: (data: Uint8Array) => void }> = [];
|
|
83
|
+
let unsubs: Array<() => void> = [];
|
|
84
|
+
|
|
85
|
+
const sync = (): void => {
|
|
86
|
+
for (const u of unsubs) u();
|
|
87
|
+
unsubs = [];
|
|
88
|
+
if (!nc) return;
|
|
89
|
+
for (const s of subs) {
|
|
90
|
+
const sub = nc.subscribe(s.subject);
|
|
91
|
+
unsubs.push(() => sub.unsubscribe());
|
|
92
|
+
void (async () => {
|
|
93
|
+
try {
|
|
94
|
+
for await (const m of sub) s.cb(new Uint8Array(m.data));
|
|
95
|
+
} catch {
|
|
96
|
+
// subscription ended / connection closed
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const attachStatus = (conn: NatsConnection): void => {
|
|
103
|
+
void conn
|
|
104
|
+
.closed()
|
|
105
|
+
.then(() => {
|
|
106
|
+
connected = false;
|
|
107
|
+
if (nc === conn) nc = null;
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {
|
|
110
|
+
connected = false;
|
|
111
|
+
});
|
|
112
|
+
void (async () => {
|
|
113
|
+
try {
|
|
114
|
+
for await (const st of conn.status()) {
|
|
115
|
+
if (st.type === "disconnect") connected = false;
|
|
116
|
+
else if (st.type === "reconnect") {
|
|
117
|
+
connected = true;
|
|
118
|
+
sync(); // nats.js re-subscribes automatically; resync to be safe
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
connected = false;
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const tryConnect = async (): Promise<void> => {
|
|
128
|
+
if (closed) return;
|
|
129
|
+
try {
|
|
130
|
+
const conn = await connect({
|
|
131
|
+
servers: opts.servers ?? ["nats://localhost:4222"],
|
|
132
|
+
token: opts.token,
|
|
133
|
+
timeout: opts.connectTimeout ?? 5000,
|
|
134
|
+
reconnect: opts.reconnect ?? true,
|
|
135
|
+
maxReconnectAttempts: -1,
|
|
136
|
+
});
|
|
137
|
+
nc = conn;
|
|
138
|
+
connected = true;
|
|
139
|
+
attachStatus(conn);
|
|
140
|
+
sync();
|
|
141
|
+
} catch {
|
|
142
|
+
connected = false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
void (async () => {
|
|
147
|
+
while (!closed) {
|
|
148
|
+
if (!nc || !connected) await tryConnect();
|
|
149
|
+
await Bun.sleep(opts.connectRetryMs ?? 2000);
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
get connected() {
|
|
155
|
+
return connected;
|
|
156
|
+
},
|
|
157
|
+
publish(subject, data) {
|
|
158
|
+
if (!nc) throw new Error("nats: not connected");
|
|
159
|
+
nc.publish(subject, data);
|
|
160
|
+
},
|
|
161
|
+
subscribe(subject, cb) {
|
|
162
|
+
subs.push({ subject, cb });
|
|
163
|
+
sync();
|
|
164
|
+
return () => {
|
|
165
|
+
const i = subs.findIndex((s) => s.subject === subject && s.cb === cb);
|
|
166
|
+
if (i >= 0) subs.splice(i, 1);
|
|
167
|
+
sync();
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
async close() {
|
|
171
|
+
closed = true;
|
|
172
|
+
if (nc) {
|
|
173
|
+
try {
|
|
174
|
+
await nc.close();
|
|
175
|
+
} catch {
|
|
176
|
+
// already closed
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
nc = null;
|
|
180
|
+
connected = false;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createNatsBridge(
|
|
186
|
+
opts: NatsBridgeOptions = {},
|
|
187
|
+
transport?: NatsTransport,
|
|
188
|
+
): NatsBridge {
|
|
189
|
+
const t = transport ?? createRealTransport(opts);
|
|
190
|
+
const subjects = createSubjectBuilder(opts.subjectPrefix);
|
|
191
|
+
const stats: NatsBridgeStats = {
|
|
192
|
+
bridged: 0,
|
|
193
|
+
bridgedBytes: 0,
|
|
194
|
+
bridgeErrors: 0,
|
|
195
|
+
bridgeInbound: 0,
|
|
196
|
+
bridgeInboundErrors: 0,
|
|
197
|
+
};
|
|
198
|
+
let closed = false;
|
|
199
|
+
let onInbound: ((name: EventName, payload: unknown) => void) | null = null;
|
|
200
|
+
const allowlist = opts.inboundEvents ? new Set(opts.inboundEvents) : null;
|
|
201
|
+
|
|
202
|
+
// inbound subscriptions (lazy — the transport queues them until connected)
|
|
203
|
+
const subscribeInbound = (subject: string): (() => void) => {
|
|
204
|
+
return t.subscribe(subject, (data) => {
|
|
205
|
+
const header = readFrameHeader(data);
|
|
206
|
+
if (!header) {
|
|
207
|
+
stats.bridgeInboundErrors++;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (isControlId(header.id)) {
|
|
211
|
+
stats.bridgeInboundErrors++; // never forward transport-internal frames
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const name = header.name as EventName;
|
|
215
|
+
if (allowlist && !allowlist.has(name)) return;
|
|
216
|
+
let payload: unknown;
|
|
217
|
+
try {
|
|
218
|
+
payload = decodePayload(header.id, data);
|
|
219
|
+
} catch {
|
|
220
|
+
stats.bridgeInboundErrors++;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
stats.bridgeInbound++;
|
|
224
|
+
onInbound?.(name, payload);
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const unsubs: Array<() => void> = [];
|
|
229
|
+
if (opts.inbound) {
|
|
230
|
+
const subjectsList = opts.inboundSubjects?.length ? opts.inboundSubjects : [subjects.inboundPrefix()];
|
|
231
|
+
for (const subject of subjectsList) unsubs.push(subscribeInbound(subject));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
get status(): NatsBridgeStatus {
|
|
236
|
+
if (closed) return "closed";
|
|
237
|
+
return t.connected ? "connected" : "connecting";
|
|
238
|
+
},
|
|
239
|
+
get subjects() {
|
|
240
|
+
return subjects;
|
|
241
|
+
},
|
|
242
|
+
get stats() {
|
|
243
|
+
return stats;
|
|
244
|
+
},
|
|
245
|
+
publish(subject, frame) {
|
|
246
|
+
if (!t.connected) {
|
|
247
|
+
stats.bridgeErrors++;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// the frame view is a reused scratch — copy before handing to NATS
|
|
251
|
+
const copy = frame.slice();
|
|
252
|
+
try {
|
|
253
|
+
t.publish(subject, copy);
|
|
254
|
+
stats.bridged++;
|
|
255
|
+
stats.bridgedBytes += copy.byteLength;
|
|
256
|
+
} catch {
|
|
257
|
+
stats.bridgeErrors++;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
setOnInbound(cb) {
|
|
261
|
+
onInbound = cb;
|
|
262
|
+
},
|
|
263
|
+
async close() {
|
|
264
|
+
closed = true;
|
|
265
|
+
for (const u of unsubs) u();
|
|
266
|
+
await t.close();
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NATS subject naming for the ignex bridge.
|
|
3
|
+
*
|
|
4
|
+
* Every publish the server fans out to WS clients can ALSO be bridged to NATS
|
|
5
|
+
* so other applications consume the exact same FlatBuffer wire frame. Subjects
|
|
6
|
+
* are derived from the routing context:
|
|
7
|
+
*
|
|
8
|
+
* - global publish → `{prefix}.broadcast.{event}`
|
|
9
|
+
* - publishToTopic(topic,..) → `{prefix}.topic.{topic}.{event}`
|
|
10
|
+
* - publishToGroup(group,..) → `{prefix}.group.{group}.{event}`
|
|
11
|
+
*
|
|
12
|
+
* External apps push events INTO the hub by publishing on `{prefix}.inbound.>`
|
|
13
|
+
* (the default inbound subscription; the server forwards them to all clients).
|
|
14
|
+
*/
|
|
15
|
+
export interface SubjectBuilder {
|
|
16
|
+
broadcast(name: string): string;
|
|
17
|
+
topic(topic: string, name: string): string;
|
|
18
|
+
group(group: string, name: string): string;
|
|
19
|
+
/** wildcard subject the server subscribes to for inbound events */
|
|
20
|
+
inboundPrefix(): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createSubjectBuilder(prefix = "ignex"): SubjectBuilder {
|
|
24
|
+
return {
|
|
25
|
+
broadcast: (name) => `${prefix}.broadcast.${name}`,
|
|
26
|
+
topic: (topic, name) => `${prefix}.topic.${topic}.${name}`,
|
|
27
|
+
group: (group, name) => `${prefix}.group.${group}.${name}`,
|
|
28
|
+
inboundPrefix: () => `${prefix}.inbound.>`,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/core/auth.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade gate — pure-ish decision logic for who may open a WebSocket:
|
|
3
|
+
* connection limit → origin allowlist → bearer token → custom `authenticate`.
|
|
4
|
+
* Returns a `Response` to reject the upgrade, or `undefined` to allow it
|
|
5
|
+
* (after calling `srv.upgrade`). Mirrors the former `IgnServer.tryUpgrade`.
|
|
6
|
+
*
|
|
7
|
+
* `authenticate` may return a `ClientMeta` (`{id, groups, meta}`) to pin the
|
|
8
|
+
* client's identity for targeted sends / grouping; otherwise a UUID is
|
|
9
|
+
* auto-assigned. A duplicate explicit id rejects the new connection (409).
|
|
10
|
+
*/
|
|
11
|
+
import type { ClientMeta, ServerState, WsData } from "./state";
|
|
12
|
+
|
|
13
|
+
export async function checkUpgrade(
|
|
14
|
+
state: ServerState,
|
|
15
|
+
req: Request,
|
|
16
|
+
srv: ReturnType<typeof Bun.serve<WsData>>,
|
|
17
|
+
): Promise<Response | undefined> {
|
|
18
|
+
if (state.maxConnections !== undefined && state.sockets.size >= state.maxConnections) {
|
|
19
|
+
return new Response("too many connections", { status: 503 });
|
|
20
|
+
}
|
|
21
|
+
if (state.allowedOrigins) {
|
|
22
|
+
const origin = req.headers.get("origin") ?? "";
|
|
23
|
+
if (!state.allowedOrigins.includes(origin)) {
|
|
24
|
+
return new Response("origin not allowed", { status: 403 });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (state.token) {
|
|
28
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
29
|
+
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
30
|
+
const ok = typeof state.token === "function" ? state.token(bearer) : bearer === state.token;
|
|
31
|
+
if (!ok) return new Response("unauthorized", { status: 401 });
|
|
32
|
+
}
|
|
33
|
+
let authMeta: ClientMeta | undefined;
|
|
34
|
+
if (state.authenticate) {
|
|
35
|
+
const res = await state.authenticate(req);
|
|
36
|
+
if (!res) return new Response("unauthorized", { status: 401 });
|
|
37
|
+
if (typeof res === "object") authMeta = res;
|
|
38
|
+
}
|
|
39
|
+
// identity: explicit id from auth, else a fresh UUID. Duplicate ids are
|
|
40
|
+
// rejected up-front (an admin can `disconnectClient` the stale session).
|
|
41
|
+
const id = authMeta?.id ?? crypto.randomUUID();
|
|
42
|
+
if (state.clients.has(id)) {
|
|
43
|
+
return new Response("client id already in use", { status: 409 });
|
|
44
|
+
}
|
|
45
|
+
const data: WsData = {
|
|
46
|
+
lastSeq: 0,
|
|
47
|
+
topics: new Set(),
|
|
48
|
+
groups: new Set(authMeta?.groups ?? []),
|
|
49
|
+
id,
|
|
50
|
+
meta: authMeta?.meta,
|
|
51
|
+
connectedAt: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
// bun-types requires the WebSocketData options arg when Data != undefined
|
|
54
|
+
if (srv.upgrade(req, { data })) return undefined;
|
|
55
|
+
return new Response("upgrade failed", { status: 400 });
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backpressure decision — a PURE function: given the policy + the socket's
|
|
3
|
+
* current state, return what `outbound.sendFrame` should do. No side effects
|
|
4
|
+
* here (no queue mutation, no counters) — that makes each policy testable in
|
|
5
|
+
* isolation and keeps the actual effects in exactly one place (`outbound.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerWebSocket } from "bun";
|
|
8
|
+
import type { IgnBackpressureOptions, WsData } from "./state";
|
|
9
|
+
|
|
10
|
+
export type SendDecision =
|
|
11
|
+
| { kind: "send" }
|
|
12
|
+
| { kind: "close" }
|
|
13
|
+
| { kind: "drop-newest" }
|
|
14
|
+
/** push the frame to the socket's drop-oldest queue, then trim `dropHead` oldest */
|
|
15
|
+
| { kind: "enqueue"; dropHead: number };
|
|
16
|
+
|
|
17
|
+
export function decide(
|
|
18
|
+
bp: Required<IgnBackpressureOptions>,
|
|
19
|
+
ws: ServerWebSocket<WsData>,
|
|
20
|
+
): SendDecision {
|
|
21
|
+
const hwm = bp.highWaterMark;
|
|
22
|
+
switch (bp.policy) {
|
|
23
|
+
case "disconnect":
|
|
24
|
+
return ws.getBufferedAmount() > hwm ? { kind: "close" } : { kind: "send" };
|
|
25
|
+
case "drop-newest":
|
|
26
|
+
return ws.getBufferedAmount() > hwm ? { kind: "drop-newest" } : { kind: "send" };
|
|
27
|
+
case "drop-oldest":
|
|
28
|
+
default: {
|
|
29
|
+
const q = ws.data.queue;
|
|
30
|
+
if (q && q.length > 0) {
|
|
31
|
+
// already backed up — enqueue an owned copy; while over maxQueue, drop from the head
|
|
32
|
+
const dropHead = Math.max(0, q.length + 1 - bp.maxQueue);
|
|
33
|
+
return { kind: "enqueue", dropHead };
|
|
34
|
+
}
|
|
35
|
+
if (ws.getBufferedAmount() > hwm) return { kind: "enqueue", dropHead: 0 };
|
|
36
|
+
return { kind: "send" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client app-level Ping/Pong heartbeat — detects half-open connections and
|
|
3
|
+
* forces a close so the reconnect path re-establishes the socket.
|
|
4
|
+
*/
|
|
5
|
+
import { sendControl } from "./client-wire";
|
|
6
|
+
import type { ClientState } from "./client-state";
|
|
7
|
+
|
|
8
|
+
export function startHeartbeat(state: ClientState): void {
|
|
9
|
+
const ms = state.opts.heartbeatMs ?? 15000;
|
|
10
|
+
if (ms <= 0) return;
|
|
11
|
+
state.lastPong = Date.now();
|
|
12
|
+
sendControl(state, "ping", { ts: Date.now() });
|
|
13
|
+
const misses = Math.max(1, state.opts.heartbeatMisses ?? 2);
|
|
14
|
+
state.heartbeatTimer = setInterval(() => {
|
|
15
|
+
if (Date.now() - state.lastPong > ms * misses) {
|
|
16
|
+
// connection is dead — force close so onclose triggers reconnect
|
|
17
|
+
state.ws?.close();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
sendControl(state, "ping", { ts: Date.now() });
|
|
21
|
+
}, ms);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stopHeartbeat(state: ClientState): void {
|
|
25
|
+
if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
|
|
26
|
+
state.heartbeatTimer = null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client auto-reconnect — pure backoff math (`nextDelay`) + a scheduler that
|
|
3
|
+
* drives the state machine. `connect` is passed in by the composition root so
|
|
4
|
+
* the timer can re-establish the socket.
|
|
5
|
+
*/
|
|
6
|
+
import { setStatus, type ClientState, type IgnClientOptions, type IgnReconnectOptions } from "./client-state";
|
|
7
|
+
|
|
8
|
+
/** Resolve the effective reconnect options (defaults applied). */
|
|
9
|
+
export function reconnectOpts(opts: IgnClientOptions): IgnReconnectOptions | null {
|
|
10
|
+
const rc = opts.reconnect;
|
|
11
|
+
if (rc === undefined || rc === false) return null;
|
|
12
|
+
if (rc === true) return { initialDelay: 250, maxDelay: 30000, jitter: true };
|
|
13
|
+
return { initialDelay: 250, maxDelay: 30000, jitter: true, ...rc };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pure exponential-backoff delay (ms) for a given attempt count. */
|
|
17
|
+
export function nextDelay(attempts: number, opts: IgnReconnectOptions): number {
|
|
18
|
+
const initial = opts.initialDelay ?? 250;
|
|
19
|
+
const max = opts.maxDelay ?? 30000;
|
|
20
|
+
const base = Math.min(initial * 2 ** attempts, max);
|
|
21
|
+
return opts.jitter === false ? base : base * (0.5 + Math.random());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function scheduleReconnect(state: ClientState, connect: () => void): void {
|
|
25
|
+
const rc = reconnectOpts(state.opts);
|
|
26
|
+
if (!rc) {
|
|
27
|
+
setStatus(state, "closed");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
setStatus(state, "disconnected");
|
|
31
|
+
const delay = nextDelay(state.attempts, rc);
|
|
32
|
+
state.attempts++;
|
|
33
|
+
setStatus(state, "reconnecting");
|
|
34
|
+
state.reconnectTimer = setTimeout(() => connect(), delay);
|
|
35
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client state + option types. `createClientState(url, opts)` builds the single
|
|
3
|
+
* explicit state object the client action functions read/mutate. The option /
|
|
4
|
+
* status types are the PUBLIC surface and are re-exported by `public/client.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import type { EventName, Events } from "../schema";
|
|
7
|
+
|
|
8
|
+
export type ClientStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "closed";
|
|
9
|
+
|
|
10
|
+
export interface IgnReconnectOptions {
|
|
11
|
+
/** initial reconnect delay (ms), default 250 */
|
|
12
|
+
initialDelay?: number;
|
|
13
|
+
/** maximum reconnect delay (ms), default 30000 */
|
|
14
|
+
maxDelay?: number;
|
|
15
|
+
/** randomize each delay (×0.5–1.5), default true */
|
|
16
|
+
jitter?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IgnClientOptions {
|
|
20
|
+
/** auto-reconnect on unexpected close, default false (boolean or options) */
|
|
21
|
+
reconnect?: boolean | IgnReconnectOptions;
|
|
22
|
+
/** app-level ping interval in ms (0 disables), default 15000 */
|
|
23
|
+
heartbeatMs?: number;
|
|
24
|
+
/** miss this many heartbeats before assuming the connection is dead, default 2 */
|
|
25
|
+
heartbeatMisses?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Handler<K extends EventName> = (payload: Events[K]) => void;
|
|
29
|
+
|
|
30
|
+
export interface ClientState {
|
|
31
|
+
url: string;
|
|
32
|
+
opts: IgnClientOptions;
|
|
33
|
+
ws: WebSocket | null;
|
|
34
|
+
handlers: Map<EventName, Set<Handler<never>>>;
|
|
35
|
+
anyHandlers: Set<(name: EventName, payload: unknown) => void>;
|
|
36
|
+
errorCbs: Set<(err: Error) => void>;
|
|
37
|
+
statusCbs: Set<(status: ClientStatus) => void>;
|
|
38
|
+
closed: boolean;
|
|
39
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
40
|
+
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
|
41
|
+
attempts: number;
|
|
42
|
+
status: ClientStatus;
|
|
43
|
+
subscribedTopics: Set<string>;
|
|
44
|
+
lastPong: number;
|
|
45
|
+
/** id the server assigned to this connection (from `welcome`; "" until known) */
|
|
46
|
+
clientId: string;
|
|
47
|
+
/** server-side groups this client belongs to (from `welcome`; [] until known) */
|
|
48
|
+
groups: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createClientState(url: string, opts: IgnClientOptions = {}): ClientState {
|
|
52
|
+
return {
|
|
53
|
+
url,
|
|
54
|
+
opts,
|
|
55
|
+
ws: null,
|
|
56
|
+
handlers: new Map(),
|
|
57
|
+
anyHandlers: new Set(),
|
|
58
|
+
errorCbs: new Set(),
|
|
59
|
+
statusCbs: new Set(),
|
|
60
|
+
closed: false,
|
|
61
|
+
reconnectTimer: null,
|
|
62
|
+
heartbeatTimer: null,
|
|
63
|
+
attempts: 0,
|
|
64
|
+
status: "closed",
|
|
65
|
+
subscribedTopics: new Set(),
|
|
66
|
+
lastPong: 0,
|
|
67
|
+
clientId: "",
|
|
68
|
+
groups: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function setStatus(state: ClientState, s: ClientStatus): void {
|
|
73
|
+
if (state.status === s) return;
|
|
74
|
+
state.status = s;
|
|
75
|
+
for (const cb of state.statusCbs) cb(s);
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client wire handling — outbound frame sends + inbound decode/dispatch.
|
|
3
|
+
* `handleMessage` decodes the envelope, filters control frames, and fans app
|
|
4
|
+
* events out to the registered handlers.
|
|
5
|
+
*/
|
|
6
|
+
import { decodeFrame, isControlId, WIRE_VERSION } from "../generated/registry";
|
|
7
|
+
import { encodeEventFrame } from "../generated/ts-ser";
|
|
8
|
+
import type { ControlEventName, ControlEvents, EventName } from "../schema";
|
|
9
|
+
import type { ClientState } from "./client-state";
|
|
10
|
+
|
|
11
|
+
/** Send an encoded frame, if the socket is open. */
|
|
12
|
+
export function sendFrame(state: ClientState, frame: Uint8Array): void {
|
|
13
|
+
const ws = state.ws;
|
|
14
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("ignex: client is not connected");
|
|
15
|
+
// Bun's send() wants an ArrayBuffer-backed view; cast the (owned) frame.
|
|
16
|
+
ws.send(frame as Uint8Array<ArrayBuffer>);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sendControl<K extends ControlEventName>(
|
|
20
|
+
state: ClientState,
|
|
21
|
+
name: K,
|
|
22
|
+
payload: ControlEvents[K],
|
|
23
|
+
): void {
|
|
24
|
+
sendFrame(state, encodeEventFrame(name, payload));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emitError(state: ClientState, err: Error): void {
|
|
28
|
+
for (const cb of state.errorCbs) cb(err);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function handleControl(state: ClientState, name: ControlEventName, payload: unknown): void {
|
|
32
|
+
switch (name) {
|
|
33
|
+
case "hello": {
|
|
34
|
+
const p = payload as ControlEvents["hello"];
|
|
35
|
+
if (p.version !== WIRE_VERSION) {
|
|
36
|
+
// server speaks a different wire version — refuse + surface
|
|
37
|
+
state.ws?.close(1002, "wire version mismatch");
|
|
38
|
+
emitError(state, new Error(`ignex: server wire version ${p.version} does not match ${WIRE_VERSION}`));
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case "welcome": {
|
|
43
|
+
const p = payload as ControlEvents["welcome"];
|
|
44
|
+
state.clientId = p.clientId;
|
|
45
|
+
state.groups = [...p.groups];
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case "pong":
|
|
49
|
+
state.lastPong = Date.now();
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function handleMessage(state: ClientState, data: ArrayBuffer | string): void {
|
|
57
|
+
if (typeof data === "string") return; // ignore text frames
|
|
58
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : (data as Uint8Array);
|
|
59
|
+
const frame = decodeFrame(bytes);
|
|
60
|
+
if (!frame) {
|
|
61
|
+
emitError(state, new Error("ignex: undecodable / version-mismatched frame dropped"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (isControlId(frame.id)) {
|
|
65
|
+
handleControl(state, frame.name as ControlEventName, frame.payload);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const name = frame.name as EventName;
|
|
69
|
+
const set = state.handlers.get(name);
|
|
70
|
+
if (set) for (const cb of set) cb(frame.payload as never);
|
|
71
|
+
for (const cb of state.anyHandlers) cb(name, frame.payload);
|
|
72
|
+
}
|