@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.
- package/README.md +136 -33
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +292 -0
- package/docs/architecture.md +101 -28
- package/docs/events.md +252 -0
- package/docs/generic-bindings.md +207 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +74 -20
- package/index.ts +75 -27
- package/package.json +13 -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 +510 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +52 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/generated/backend.rs +503 -0
- package/rust/src/transcode/generated.rs +377 -17
- 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/inbound.ts +46 -0
- package/src/bridge/nats/index.ts +131 -0
- package/src/bridge/nats/real-transport.ts +133 -0
- package/src/bridge/nats/types.ts +80 -0
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +28 -0
- package/src/codegen/direct-gen.ts +564 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +246 -0
- package/src/codegen/rust-glue-gen.ts +552 -0
- package/src/codegen/schema-model.ts +363 -0
- package/src/codegen/ts-ser-gen.ts +230 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +67 -5
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-rpc.ts +75 -0
- package/src/core/client-state.ts +63 -8
- package/src/core/client-wire.ts +148 -15
- package/src/core/client.ts +105 -31
- package/src/core/groups.ts +8 -0
- package/src/core/metrics.ts +42 -21
- package/src/core/outbound.ts +62 -11
- package/src/core/rate-limit.ts +69 -0
- package/src/core/replay.ts +41 -1
- package/src/core/resume.ts +181 -0
- package/src/core/rooms.ts +10 -3
- package/src/core/routing.ts +144 -12
- package/src/core/server/client-info.ts +37 -0
- package/src/core/server/http-routes.ts +59 -0
- package/src/core/server/index.ts +360 -0
- package/src/core/server/metrics-view.ts +53 -0
- package/src/core/server/socket-lifecycle.ts +57 -0
- package/src/core/state.ts +124 -14
- package/src/core/topic-log.ts +86 -0
- package/src/events/clients.ts +174 -0
- package/src/events/cluster/dedupe.ts +43 -0
- package/src/events/cluster/envelope.ts +149 -0
- package/src/events/cluster/index.ts +50 -0
- package/src/events/cluster/keys.ts +33 -0
- package/src/events/cluster/kinds.ts +32 -0
- package/src/events/cluster/presence-table.ts +99 -0
- package/src/events/cluster/presence.ts +53 -0
- package/src/events/cluster/redis-client.ts +50 -0
- package/src/events/cluster/store-memory.ts +67 -0
- package/src/events/cluster/store-redis.ts +44 -0
- package/src/events/cluster/subjects.ts +30 -0
- package/src/events/cluster/sync.ts +476 -0
- package/src/events/cluster/transport-nats.ts +24 -0
- package/src/events/cluster/transport-redis.ts +120 -0
- package/src/events/cluster-rpc.ts +196 -0
- package/src/events/data.ts +38 -0
- package/src/events/delivery.ts +83 -0
- package/src/events/emit.ts +173 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub/context-factory.ts +79 -0
- package/src/events/hub/dispatch.ts +86 -0
- package/src/events/hub/index.ts +536 -0
- package/src/events/hub/internal.ts +31 -0
- package/src/events/hub/metrics-snapshot.ts +84 -0
- package/src/events/hub/resolve-cluster.ts +49 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +123 -0
- package/src/events/registry.ts +214 -0
- package/src/events/schedule.ts +73 -0
- package/src/events/trace.ts +283 -0
- package/src/events/types/client.ts +68 -0
- package/src/events/types/cluster.ts +40 -0
- package/src/events/types/context.ts +50 -0
- package/src/events/types/emit-target.ts +29 -0
- package/src/events/types/groups.ts +35 -0
- package/src/events/types/hub.ts +124 -0
- package/src/events/types/index.ts +30 -0
- package/src/events/types/metrics.ts +52 -0
- package/src/events/types/options.ts +62 -0
- package/src/generated/direct-ser.ts +148 -60
- package/src/generated/fbs/backend.fbs +24 -1
- package/src/generated/registry.ts +94 -33
- package/src/generated/rust/backend_generated.rs +503 -0
- package/src/generated/ts/backend.ts +4 -0
- package/src/generated/ts/resume.ts +74 -0
- package/src/generated/ts/resumed.ts +88 -0
- package/src/generated/ts/rpc-call.ts +112 -0
- package/src/generated/ts/rpc-result.ts +126 -0
- package/src/generated/ts/snapshot-request.ts +19 -5
- package/src/generated/ts-ser.ts +110 -17
- package/src/generated/wire-registry.json +7 -2
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +49 -2
- package/src/server.ts +7 -3
- package/src/transport/transport.ts +200 -79
- package/src/bridge/nats.ts +0 -269
- package/src/core/server.ts +0 -294
- package/src/transport/stats.ts +0 -44
package/src/bridge/nats.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NATS bridge — bidirectional FlatBuffer transport over NATS.
|
|
3
|
-
*
|
|
4
|
-
* OUTBOUND: the server encodes each event ONCE (Rust FFI → scratch), fans the
|
|
5
|
-
* same frame out to WS clients, then hands a COPY to `bridge.publish(subject,
|
|
6
|
-
* frame)` so other applications consume the identical wire bytes. Best-effort:
|
|
7
|
-
* if NATS is down the frame is dropped and counted in `bridgeErrors` — it
|
|
8
|
-
* never blocks or throws on the WS hot path.
|
|
9
|
-
*
|
|
10
|
-
* INBOUND: when `inbound` is enabled the bridge subscribes to `{prefix}.
|
|
11
|
-
* inbound.>` and forwards decodable app events to `onInbound` (wired by the
|
|
12
|
-
* server to fan out to clients). Control frames and unknown ids are dropped.
|
|
13
|
-
*
|
|
14
|
-
* 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
|
-
}
|
package/src/core/server.ts
DELETED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server composition root — wires `createServerState` + the action modules
|
|
3
|
-
* (auth / rooms / outbound / routing) into `Bun.serve`, and returns a plain
|
|
4
|
-
* `IgnServer` API object (no class, no `this`). This is the ONLY place that
|
|
5
|
-
* knows how the pieces fit together.
|
|
6
|
-
*
|
|
7
|
-
* Public entry: `public/server.ts` re-exports `createServer` + the types.
|
|
8
|
-
*/
|
|
9
|
-
import type { ServerWebSocket } from "bun";
|
|
10
|
-
import { WIRE_VERSION } from "../generated/registry";
|
|
11
|
-
import type { Events, EventName } from "../schema";
|
|
12
|
-
import { getEncodeStats, encodeToScratch } from "../transport/transport";
|
|
13
|
-
import { setInt64GuardMode } from "./int64-guard";
|
|
14
|
-
import type { MetricsSnapshot } from "./metrics";
|
|
15
|
-
import { checkUpgrade } from "./auth";
|
|
16
|
-
import { drainSocket, sendControl, sendFrame } from "./outbound";
|
|
17
|
-
import { handleMessage } from "./routing";
|
|
18
|
-
import { joinRoom, leaveRoom, publishToRoom, roomTopics } from "./rooms";
|
|
19
|
-
import {
|
|
20
|
-
activeGroups,
|
|
21
|
-
groupMembers as groupMemberIds,
|
|
22
|
-
joinGroup as addToGroup,
|
|
23
|
-
leaveGroup as removeFromGroup,
|
|
24
|
-
publishToGroup as publishToGroupState,
|
|
25
|
-
} from "./groups";
|
|
26
|
-
import { createServerState, type IgnServerOptions, type WsData } from "./state";
|
|
27
|
-
import { createNatsBridge } from "../bridge/nats";
|
|
28
|
-
|
|
29
|
-
/** A snapshot of an active client (from `getClient` / `getClients` / GET /clients). */
|
|
30
|
-
export interface ClientInfo {
|
|
31
|
-
id: string;
|
|
32
|
-
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
33
|
-
meta?: Record<string, unknown>;
|
|
34
|
-
/** server-side groups this client belongs to */
|
|
35
|
-
groups: string[];
|
|
36
|
-
/** topics/rooms this client has joined */
|
|
37
|
-
topics: string[];
|
|
38
|
-
/** epoch ms the socket connected */
|
|
39
|
-
connectedAt: number;
|
|
40
|
-
/** remote IP (from the socket) */
|
|
41
|
-
ip: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function toClientInfo(ws: ServerWebSocket<WsData>): ClientInfo {
|
|
45
|
-
return {
|
|
46
|
-
id: ws.data.id,
|
|
47
|
-
meta: ws.data.meta,
|
|
48
|
-
groups: [...ws.data.groups],
|
|
49
|
-
topics: [...ws.data.topics],
|
|
50
|
-
connectedAt: ws.data.connectedAt,
|
|
51
|
-
ip: ws.remoteAddress,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The public server API (returned by `createServer`). */
|
|
56
|
-
export interface IgnServer {
|
|
57
|
-
readonly port: number;
|
|
58
|
-
readonly clientCount: number;
|
|
59
|
-
getMetrics(): MetricsSnapshot;
|
|
60
|
-
/** Broadcast a typed event to every connected client (zero-alloc on the happy path). */
|
|
61
|
-
publish<K extends EventName>(name: K, payload: Events[K]): void;
|
|
62
|
-
/** Send a typed event to a single socket (zero-alloc on the happy path). */
|
|
63
|
-
publishTo<K extends EventName>(ws: ServerWebSocket<WsData>, name: K, payload: Events[K]): void;
|
|
64
|
-
/** Publish a typed event to every socket subscribed to `topic`. */
|
|
65
|
-
publishToTopic<K extends EventName>(topic: string, name: K, payload: Events[K]): void;
|
|
66
|
-
/** Send a typed event to a specific client by id. Returns false if that client is offline. */
|
|
67
|
-
publishToClient<K extends EventName>(id: string, name: K, payload: Events[K]): boolean;
|
|
68
|
-
/** Programmatic room membership (clients can also join via subscribe frames). */
|
|
69
|
-
join(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
70
|
-
leave(topic: string, ws: ServerWebSocket<WsData>): void;
|
|
71
|
-
/** Live topic names (with at least one subscriber). */
|
|
72
|
-
topics(): string[];
|
|
73
|
-
/** Server-side group membership (targeted fan-out, no replay). */
|
|
74
|
-
joinGroup(id: string, group: string): void;
|
|
75
|
-
leaveGroup(id: string, group: string): void;
|
|
76
|
-
/** Publish a typed event to every client in a server-side group. */
|
|
77
|
-
publishToGroup<K extends EventName>(group: string, name: K, payload: Events[K]): void;
|
|
78
|
-
/** Live server-side group names (with at least one member). */
|
|
79
|
-
groups(): string[];
|
|
80
|
-
/** Client ids currently in `group`. */
|
|
81
|
-
groupMembers(group: string): string[];
|
|
82
|
-
/** Groups a client belongs to ([] if offline/unknown). */
|
|
83
|
-
clientGroups(id: string): string[];
|
|
84
|
-
/** Active-client introspection. */
|
|
85
|
-
getClient(id: string): ClientInfo | undefined;
|
|
86
|
-
getClients(): ClientInfo[];
|
|
87
|
-
/** Force-close a specific client socket (e.g. from a policy / admin action). */
|
|
88
|
-
closeClient(ws: ServerWebSocket<WsData>): void;
|
|
89
|
-
/** Disconnect a client by id. Returns false if that client is offline. */
|
|
90
|
-
disconnectClient(id: string): boolean;
|
|
91
|
-
/** Register a handler for an inbound app event (must be in `options.inbound`). */
|
|
92
|
-
on<K extends EventName>(name: K, handler: (payload: Events[K], ws: ServerWebSocket<WsData>) => void): IgnServer;
|
|
93
|
-
off<K extends EventName>(name: K): IgnServer;
|
|
94
|
-
/** Graceful drain: stop accepting, wait up to `timeoutMs` for queues to flush. */
|
|
95
|
-
drain(timeoutMs?: number): Promise<void>;
|
|
96
|
-
stop(force?: boolean): void;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function createServer(options: IgnServerOptions): IgnServer {
|
|
100
|
-
const state = createServerState(options);
|
|
101
|
-
setInt64GuardMode(options.int64Guard ?? "off");
|
|
102
|
-
|
|
103
|
-
// NATS bridge (optional, best-effort — created eagerly, connects in the background)
|
|
104
|
-
const natsOpt = options.nats;
|
|
105
|
-
if (natsOpt) state.bridge = "publish" in natsOpt ? natsOpt : createNatsBridge(natsOpt);
|
|
106
|
-
|
|
107
|
-
// Encode once + broadcast to every connected client (NO bridge) — the shared
|
|
108
|
-
// hot path for `publish` and NATS-inbound forwarding. Loop prevention: inbound
|
|
109
|
-
// events reach clients but are never re-bridged to NATS.
|
|
110
|
-
function fanOutAll(name: Parameters<typeof encodeToScratch>[0], payload: unknown): Uint8Array {
|
|
111
|
-
const frame = encodeToScratch(name, payload);
|
|
112
|
-
state.metrics.published++;
|
|
113
|
-
for (const ws of state.sockets) sendFrame(state, ws, frame);
|
|
114
|
-
return frame;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (state.bridge) {
|
|
118
|
-
state.bridge.setOnInbound((name, payload) => {
|
|
119
|
-
fanOutAll(name, payload);
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const bun = Bun.serve<WsData>({
|
|
124
|
-
port: options.port,
|
|
125
|
-
hostname: options.hostname,
|
|
126
|
-
idleTimeout: options.idleTimeout ?? 30,
|
|
127
|
-
tls: options.tls,
|
|
128
|
-
fetch: (req, srv) => {
|
|
129
|
-
const url = new URL(req.url);
|
|
130
|
-
if (url.pathname === state.path) return checkUpgrade(state, req, srv);
|
|
131
|
-
if (url.pathname === "/health") {
|
|
132
|
-
const h = state.metrics.snapshot(state.sockets.size);
|
|
133
|
-
return new Response(
|
|
134
|
-
JSON.stringify({ status: "ok", clients: h.connectedClients, uptimeMs: h.uptimeMs }),
|
|
135
|
-
{ headers: { "content-type": "application/json" } },
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
if (url.pathname === "/clients") {
|
|
139
|
-
return new Response(JSON.stringify([...state.clients.values()].map(toClientInfo)), {
|
|
140
|
-
headers: { "content-type": "application/json" },
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
if (options.fetch) return options.fetch(req);
|
|
144
|
-
return new Response("not found", { status: 404 });
|
|
145
|
-
},
|
|
146
|
-
websocket: {
|
|
147
|
-
open: (ws) => {
|
|
148
|
-
state.sockets.add(ws);
|
|
149
|
-
// belt-and-suspenders: an auth race could double-register an id — kick the stale session
|
|
150
|
-
const existing = state.clients.get(ws.data.id);
|
|
151
|
-
if (existing && existing !== ws) existing.close(1000, "replaced by newer session");
|
|
152
|
-
state.clients.set(ws.data.id, ws);
|
|
153
|
-
for (const g of ws.data.groups) addToGroup(state, ws, g);
|
|
154
|
-
// announce our wire version + capabilities so clients can negotiate
|
|
155
|
-
sendControl(state, ws, "hello", { version: WIRE_VERSION, caps: [], lastSeq: 0 });
|
|
156
|
-
// then assign identity so the client knows its id + server-side groups
|
|
157
|
-
sendControl(state, ws, "welcome", { clientId: ws.data.id, groups: [...ws.data.groups] });
|
|
158
|
-
},
|
|
159
|
-
close: (ws) => {
|
|
160
|
-
state.sockets.delete(ws);
|
|
161
|
-
state.clients.delete(ws.data.id);
|
|
162
|
-
for (const g of ws.data.groups) removeFromGroup(state, ws, g);
|
|
163
|
-
ws.data.groups.clear();
|
|
164
|
-
for (const t of ws.data.topics) leaveRoom(state, ws, t);
|
|
165
|
-
ws.data.topics.clear();
|
|
166
|
-
delete ws.data.queue;
|
|
167
|
-
},
|
|
168
|
-
message: (ws, msg) => handleMessage(state, ws, msg),
|
|
169
|
-
drain: (ws) => drainSocket(state, ws),
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const api: IgnServer = {
|
|
174
|
-
get port(): number {
|
|
175
|
-
return bun.port ?? 0;
|
|
176
|
-
},
|
|
177
|
-
get clientCount(): number {
|
|
178
|
-
return state.sockets.size;
|
|
179
|
-
},
|
|
180
|
-
getMetrics(): MetricsSnapshot {
|
|
181
|
-
const stats = getEncodeStats();
|
|
182
|
-
for (const [name, n] of Object.entries(stats.direct)) {
|
|
183
|
-
if (n > 0) state.metrics.countPath(name, "direct");
|
|
184
|
-
}
|
|
185
|
-
for (const [name, n] of Object.entries(stats.json)) {
|
|
186
|
-
if (n > 0) state.metrics.countPath(name, "json");
|
|
187
|
-
}
|
|
188
|
-
const snapshot = state.metrics.snapshot(state.sockets.size);
|
|
189
|
-
const b = state.bridge;
|
|
190
|
-
if (b) {
|
|
191
|
-
snapshot.bridged = b.stats.bridged;
|
|
192
|
-
snapshot.bridgedBytes = b.stats.bridgedBytes;
|
|
193
|
-
snapshot.bridgeErrors = b.stats.bridgeErrors;
|
|
194
|
-
snapshot.bridgeInbound = b.stats.bridgeInbound;
|
|
195
|
-
snapshot.bridgeInboundErrors = b.stats.bridgeInboundErrors;
|
|
196
|
-
snapshot.natsStatus = b.status;
|
|
197
|
-
}
|
|
198
|
-
return snapshot;
|
|
199
|
-
},
|
|
200
|
-
publish(name, payload) {
|
|
201
|
-
const frame = fanOutAll(name, payload);
|
|
202
|
-
state.bridge?.publish(state.bridge.subjects.broadcast(name), frame);
|
|
203
|
-
},
|
|
204
|
-
publishTo(ws, name, payload) {
|
|
205
|
-
state.metrics.published++;
|
|
206
|
-
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
207
|
-
},
|
|
208
|
-
publishToTopic(topic, name, payload) {
|
|
209
|
-
const frame = encodeToScratch(name, payload);
|
|
210
|
-
state.metrics.published++;
|
|
211
|
-
publishToRoom(state, topic, frame);
|
|
212
|
-
state.bridge?.publish(state.bridge.subjects.topic(topic, name), frame);
|
|
213
|
-
},
|
|
214
|
-
publishToClient(id, name, payload) {
|
|
215
|
-
const ws = state.clients.get(id);
|
|
216
|
-
if (!ws) return false;
|
|
217
|
-
state.metrics.published++;
|
|
218
|
-
sendFrame(state, ws, encodeToScratch(name, payload));
|
|
219
|
-
return true;
|
|
220
|
-
},
|
|
221
|
-
join(topic, ws) {
|
|
222
|
-
joinRoom(state, ws, topic);
|
|
223
|
-
},
|
|
224
|
-
leave(topic, ws) {
|
|
225
|
-
leaveRoom(state, ws, topic);
|
|
226
|
-
},
|
|
227
|
-
topics() {
|
|
228
|
-
return roomTopics(state);
|
|
229
|
-
},
|
|
230
|
-
joinGroup(id, group) {
|
|
231
|
-
const ws = state.clients.get(id);
|
|
232
|
-
if (ws) addToGroup(state, ws, group);
|
|
233
|
-
},
|
|
234
|
-
leaveGroup(id, group) {
|
|
235
|
-
const ws = state.clients.get(id);
|
|
236
|
-
if (ws) removeFromGroup(state, ws, group);
|
|
237
|
-
},
|
|
238
|
-
publishToGroup(group, name, payload) {
|
|
239
|
-
const frame = encodeToScratch(name, payload);
|
|
240
|
-
state.metrics.published++;
|
|
241
|
-
publishToGroupState(state, group, frame);
|
|
242
|
-
state.bridge?.publish(state.bridge.subjects.group(group, name), frame);
|
|
243
|
-
},
|
|
244
|
-
groups() {
|
|
245
|
-
return activeGroups(state);
|
|
246
|
-
},
|
|
247
|
-
groupMembers(group) {
|
|
248
|
-
return groupMemberIds(state, group);
|
|
249
|
-
},
|
|
250
|
-
clientGroups(id) {
|
|
251
|
-
const ws = state.clients.get(id);
|
|
252
|
-
return ws ? [...ws.data.groups] : [];
|
|
253
|
-
},
|
|
254
|
-
getClient(id) {
|
|
255
|
-
const ws = state.clients.get(id);
|
|
256
|
-
return ws ? toClientInfo(ws) : undefined;
|
|
257
|
-
},
|
|
258
|
-
getClients() {
|
|
259
|
-
return [...state.clients.values()].map(toClientInfo);
|
|
260
|
-
},
|
|
261
|
-
closeClient(ws) {
|
|
262
|
-
ws.close(1000, "closed by server");
|
|
263
|
-
},
|
|
264
|
-
disconnectClient(id) {
|
|
265
|
-
const ws = state.clients.get(id);
|
|
266
|
-
if (!ws) return false;
|
|
267
|
-
ws.close(1000, "closed by server");
|
|
268
|
-
return true;
|
|
269
|
-
},
|
|
270
|
-
on(name, handler) {
|
|
271
|
-
state.inboundHandlers.set(name, handler as (payload: unknown, ws: ServerWebSocket<WsData>) => void);
|
|
272
|
-
return api;
|
|
273
|
-
},
|
|
274
|
-
off(name) {
|
|
275
|
-
state.inboundHandlers.delete(name);
|
|
276
|
-
return api;
|
|
277
|
-
},
|
|
278
|
-
async drain(timeoutMs = 2000): Promise<void> {
|
|
279
|
-
bun.stop(false); // stop listening; keep active sockets draining
|
|
280
|
-
await state.bridge?.close();
|
|
281
|
-
const deadline = Date.now() + timeoutMs;
|
|
282
|
-
while (state.sockets.size > 0 && Date.now() < deadline) {
|
|
283
|
-
await Bun.sleep(10);
|
|
284
|
-
}
|
|
285
|
-
bun.stop(true);
|
|
286
|
-
},
|
|
287
|
-
stop(force = true): void {
|
|
288
|
-
bun.stop(force);
|
|
289
|
-
void state.bridge?.close();
|
|
290
|
-
},
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
return api;
|
|
294
|
-
}
|
package/src/transport/stats.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Encode-path statistics (direct vs JSON) accumulated per event name across the
|
|
3
|
-
* process. `createStats()` returns a small counter object; one instance lives
|
|
4
|
-
* in `transport.ts` and is surfaced via `getEncodeStats()` / `server.metrics()`.
|
|
5
|
-
*
|
|
6
|
-
* `bump` is O(1) on the hot path: each (event, path) pair owns a tiny mutable
|
|
7
|
-
* counter box that is allocated once (lazily), so a bump is a single number
|
|
8
|
-
* increment — no Map.get + Map.set churn per encode.
|
|
9
|
-
*/
|
|
10
|
-
export interface EncodeStats {
|
|
11
|
-
bump(name: string, path: "direct" | "json"): void;
|
|
12
|
-
get(): { direct: Record<string, number>; json: Record<string, number> };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Mutable counter box — allocated once per (event, path), incremented in place. */
|
|
16
|
-
interface Counter {
|
|
17
|
-
n: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function toObj(m: Map<string, Counter>): Record<string, number> {
|
|
21
|
-
const out: Record<string, number> = {};
|
|
22
|
-
for (const [name, c] of m) out[name] = c.n;
|
|
23
|
-
return out;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createStats(): EncodeStats {
|
|
27
|
-
const direct = new Map<string, Counter>();
|
|
28
|
-
const json = new Map<string, Counter>();
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
bump(name, path) {
|
|
32
|
-
const m = path === "direct" ? direct : json;
|
|
33
|
-
let c = m.get(name);
|
|
34
|
-
if (!c) {
|
|
35
|
-
c = { n: 0 };
|
|
36
|
-
m.set(name, c);
|
|
37
|
-
}
|
|
38
|
-
c.n++;
|
|
39
|
-
},
|
|
40
|
-
get() {
|
|
41
|
-
return { direct: toObj(direct), json: toObj(json) };
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|