@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/core/state.ts
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* PUBLIC surface and are re-exported by `public/server.ts`.
|
|
8
8
|
*/
|
|
9
9
|
import type { ServerWebSocket } from "bun";
|
|
10
|
-
import
|
|
10
|
+
import { defaultBindings } from "../bindings/default";
|
|
11
|
+
import type { Bindings, DefaultBindings, EventNameOf } from "../bindings/types";
|
|
12
|
+
import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
13
|
+
import { createEventTrace, type EventTrace, type EventTraceOptions } from "../events/trace";
|
|
14
|
+
import { createTransport, defaultTransport, type Transport } from "../transport/transport";
|
|
11
15
|
import type { Int64GuardMode } from "./int64-guard";
|
|
12
16
|
import { createMetrics, type Metrics } from "./metrics";
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
17
|
+
import { resolveRateLimit, type RateLimitOptions, type ResolvedRateLimit } from "./rate-limit";
|
|
18
|
+
import type { RingBuffer } from "./ring";
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Optional identity metadata a client may carry for targeting / grouping.
|
|
@@ -20,6 +24,12 @@ import type { NatsBridge, NatsBridgeOptions } from "../bridge/nats";
|
|
|
20
24
|
export interface ClientMeta {
|
|
21
25
|
/** explicit client id; omitted → auto-assigned `crypto.randomUUID()` */
|
|
22
26
|
id?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The identity this connection acts ON BEHALF OF (e.g. the logged-in user).
|
|
29
|
+
* Several connections may share a `userId` (multi-tab / multi-device) — the
|
|
30
|
+
* events layer groups them for user-targeted emits (`hub.emitToUser`).
|
|
31
|
+
*/
|
|
32
|
+
userId?: string;
|
|
23
33
|
/** server-side groups this client belongs to on connect */
|
|
24
34
|
groups?: string[];
|
|
25
35
|
/** arbitrary app metadata (exposed via `getClient` / `getClients`) */
|
|
@@ -41,12 +51,23 @@ export interface WsData {
|
|
|
41
51
|
groups: Set<string>;
|
|
42
52
|
/** stable client id (auth metadata or auto-generated UUID) */
|
|
43
53
|
id: string;
|
|
54
|
+
/** identity this connection acts on behalf of (undefined = anonymous) */
|
|
55
|
+
userId?: string;
|
|
44
56
|
/** arbitrary app metadata from `authenticate` (undefined if none) */
|
|
45
57
|
meta?: Record<string, unknown>;
|
|
46
58
|
/** epoch ms when the socket opened (for `getClients` ordering/uptime) */
|
|
47
59
|
connectedAt: number;
|
|
48
60
|
/** drop-oldest backpressure queue (only non-empty while the socket is saturated) */
|
|
49
61
|
queue?: RingBuffer<Uint8Array>;
|
|
62
|
+
/** per-connection inbound rate limiter (lazily created on first frame) */
|
|
63
|
+
rate?: import("./rate-limit").RateLimiter;
|
|
64
|
+
/**
|
|
65
|
+
* Next per-connection delivery seq to stamp (envelope v2). Starts at 1;
|
|
66
|
+
* continues a previous session's stream when a grave is adopted.
|
|
67
|
+
*/
|
|
68
|
+
sendSeq: number;
|
|
69
|
+
/** bounded sent-frame history for gap recovery (lazily created, resume only) */
|
|
70
|
+
history?: import("./ring").RingBuffer<import("./resume").SentFrame>;
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
/** Slow-consumer policy (see `IgnBackpressureOptions`). */
|
|
@@ -61,15 +82,22 @@ export interface IgnBackpressureOptions {
|
|
|
61
82
|
maxQueue?: number;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
|
-
export interface IgnServerOptions {
|
|
85
|
+
export interface IgnServerOptions<B extends Bindings = DefaultBindings> {
|
|
65
86
|
port: number;
|
|
66
87
|
hostname?: string;
|
|
67
88
|
/** seconds; 0 = no timeout */
|
|
68
89
|
idleTimeout?: number;
|
|
69
90
|
/** websocket path, default "/ws" */
|
|
70
91
|
path?: string;
|
|
92
|
+
/**
|
|
93
|
+
* The wire stack (event ids, decoders, encoders). Defaults to the built-in
|
|
94
|
+
* registry; pass your own (from `generateBindings` + `assembleBindings`) to
|
|
95
|
+
* serve YOUR schema. When provided, the server API (`publish` / `on` / ...)
|
|
96
|
+
* is typed against your `Events`.
|
|
97
|
+
*/
|
|
98
|
+
bindings?: B;
|
|
71
99
|
/** app events clients are ALLOWED to send; control events are always allowed. default [] */
|
|
72
|
-
inbound?:
|
|
100
|
+
inbound?: EventNameOf<B>[];
|
|
73
101
|
/** slow-consumer protection. default: off (unbounded buffering) */
|
|
74
102
|
backpressure?: IgnBackpressureOptions;
|
|
75
103
|
/**
|
|
@@ -80,9 +108,23 @@ export interface IgnServerOptions {
|
|
|
80
108
|
* traffic. Off by default to keep the hot path allocation-free.
|
|
81
109
|
*/
|
|
82
110
|
replay?: { historySize?: number };
|
|
111
|
+
/**
|
|
112
|
+
* Gap-free delivery (envelope v2 seq + resume). When set, every frame sent
|
|
113
|
+
* to a socket carries a per-connection delivery seq, the connection keeps a
|
|
114
|
+
* bounded sent-history ring, and closed sessions park that ring in a
|
|
115
|
+
* per-client-id graveyard so reconnects can resume missed frames.
|
|
116
|
+
*/
|
|
117
|
+
resume?: { historySize?: number; ttlMs?: number };
|
|
118
|
+
/**
|
|
119
|
+
* Durable topic log behind the replay ring (`src/core/topic-log.ts`). When
|
|
120
|
+
* set, every recorded topic frame is appended and `snapshotRequest`s older
|
|
121
|
+
* than the ring hydrate from the log. Default: none (ring-only).
|
|
122
|
+
*/
|
|
123
|
+
topicLog?: import("./topic-log").TopicLog;
|
|
83
124
|
/**
|
|
84
125
|
* Async auth hook run BEFORE the WebSocket upgrade. Return `false` to reject
|
|
85
|
-
* the connection (401)
|
|
126
|
+
* the connection (401) — a hook that throws (or rejects) denies it too.
|
|
127
|
+
* Return `true` to allow it (client gets an auto-
|
|
86
128
|
* generated id), or a `ClientMeta` object to pin the client id / seed its
|
|
87
129
|
* server-side groups / attach metadata. Inspect `req` as needed.
|
|
88
130
|
*/
|
|
@@ -98,6 +140,22 @@ export interface IgnServerOptions {
|
|
|
98
140
|
maxConnections?: number;
|
|
99
141
|
/** maximum inbound frame size in bytes (close 1009 beyond) */
|
|
100
142
|
maxMessageSize?: number;
|
|
143
|
+
/**
|
|
144
|
+
* Per-connection inbound rate limiting (token bucket over ALL frames — app
|
|
145
|
+
* AND control). Default: off (zero hot-path overhead). Over-limit frames are
|
|
146
|
+
* dropped (default) or the socket is closed (`policy: "close"`, code 1008);
|
|
147
|
+
* either way the event is counted in `metrics.rateLimited`.
|
|
148
|
+
*/
|
|
149
|
+
rateLimit?: RateLimitOptions;
|
|
150
|
+
/**
|
|
151
|
+
* Authorize a client's topic (room) join — enforced for EVERY join path:
|
|
152
|
+
* `subscribe` control frames, programmatic `server.join`, and auth-seeded
|
|
153
|
+
* topics. Return false to reject (the frame/ call is ignored and counted in
|
|
154
|
+
* `metrics.rejectedJoins`). Default: allow all.
|
|
155
|
+
*/
|
|
156
|
+
authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
157
|
+
/** Authorize a server-side group join (same contract as `authorizeTopic`). */
|
|
158
|
+
authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
101
159
|
/**
|
|
102
160
|
* Lossless-int64 guard for plain `number` int64 fields: values outside the
|
|
103
161
|
* safe-integer range (±2^53-1) throw / warn at encode time (default "off" —
|
|
@@ -117,6 +175,20 @@ export interface IgnServerOptions {
|
|
|
117
175
|
* NATS.
|
|
118
176
|
*/
|
|
119
177
|
nats?: NatsBridgeOptions | NatsBridge;
|
|
178
|
+
/**
|
|
179
|
+
* Enable the events layer (typed event handlers + the global emit, client
|
|
180
|
+
* records with per-connection data, groups, optional cluster sync). Exposed
|
|
181
|
+
* as `server.events`; the module-global `emit`/`on` singleton
|
|
182
|
+
* (`ignex-nova/events`) is bound by default.
|
|
183
|
+
*/
|
|
184
|
+
events?: import("../events/types").EventsOptions<B>;
|
|
185
|
+
/**
|
|
186
|
+
* Event trace ring — records every fired event (emitted / published /
|
|
187
|
+
* received) into a pre-allocated structure-of-arrays buffer so a debugger
|
|
188
|
+
* (ignex debugbar, MCP) can see what fired without any hot-path allocation.
|
|
189
|
+
* Default: on with capacity 1024; `IGNEX_NOVA_TRACE=0` disables globally.
|
|
190
|
+
*/
|
|
191
|
+
trace?: EventTraceOptions;
|
|
120
192
|
/** additional HTTP handler for non-ws routes (e.g. serving a static demo page) */
|
|
121
193
|
fetch?: (req: Request) => Response | Promise<Response>;
|
|
122
194
|
}
|
|
@@ -125,8 +197,12 @@ type InboundHandler = (payload: unknown, ws: ServerWebSocket<WsData>) => void;
|
|
|
125
197
|
|
|
126
198
|
/** The full, explicit server state — created once per server, passed to actions. */
|
|
127
199
|
export interface ServerState {
|
|
200
|
+
/** the wire stack this server speaks (ids / decoders / encoders). */
|
|
201
|
+
bindings: Bindings;
|
|
202
|
+
/** per-server encoder (scratch + FFI binding or pure-JS fallback). */
|
|
203
|
+
transport: Transport;
|
|
128
204
|
path: string;
|
|
129
|
-
inbound:
|
|
205
|
+
inbound: Set<string>;
|
|
130
206
|
bp: Required<IgnBackpressureOptions> | null;
|
|
131
207
|
metrics: Metrics;
|
|
132
208
|
startedAt: number;
|
|
@@ -135,7 +211,11 @@ export interface ServerState {
|
|
|
135
211
|
token?: string | ((token: string) => boolean);
|
|
136
212
|
maxConnections?: number;
|
|
137
213
|
maxMessageSize?: number;
|
|
214
|
+
rateLimit: ResolvedRateLimit | null;
|
|
215
|
+
authorizeTopic?: (topic: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
216
|
+
authorizeGroup?: (group: string, ws: ServerWebSocket<WsData>) => boolean;
|
|
138
217
|
replay: { historySize: number } | null;
|
|
218
|
+
resume: { historySize: number; ttlMs: number } | null;
|
|
139
219
|
sockets: Set<ServerWebSocket<WsData>>;
|
|
140
220
|
/** id → live socket (client registry for targeted sends / introspection) */
|
|
141
221
|
clients: Map<string, ServerWebSocket<WsData>>;
|
|
@@ -144,13 +224,35 @@ export interface ServerState {
|
|
|
144
224
|
groups: Map<string, Set<ServerWebSocket<WsData>>>;
|
|
145
225
|
/** optional NATS bridge (wired in createServer when `options.nats` is set) */
|
|
146
226
|
bridge?: NatsBridge;
|
|
147
|
-
inboundHandlers: Map<
|
|
227
|
+
inboundHandlers: Map<string, InboundHandler>;
|
|
148
228
|
topicHistory: Map<string, RingBuffer<{ seq: number; frame: Uint8Array }>>;
|
|
149
229
|
replaySeq: number;
|
|
230
|
+
/** optional durable topic log (wired in createServer when `options.topicLog` is set) */
|
|
231
|
+
topicLog?: import("./topic-log").TopicLog;
|
|
232
|
+
/**
|
|
233
|
+
* Responder registry for request/response (`rpcCall` control frames):
|
|
234
|
+
* inner event name → async responder. Registered via `server.handle` /
|
|
235
|
+
* `hub.onRequest`.
|
|
236
|
+
*/
|
|
237
|
+
rpcHandlers: Map<string, (payload: unknown, ws: ServerWebSocket<WsData>) => Promise<unknown>>;
|
|
238
|
+
/** parked sent-history rings of closed sessions (cross-connection resume) */
|
|
239
|
+
graves: Map<string, { history: RingBuffer<import("./resume").SentFrame>; nextSeq: number; expiresAt: number }>;
|
|
240
|
+
/** events-layer lifecycle hooks (wired by createServer when `events` is set) */
|
|
241
|
+
onConnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
242
|
+
onDisconnect?: (ws: ServerWebSocket<WsData>) => void;
|
|
243
|
+
/** fired on ANY group membership change (auth seed, control frames, programmatic) */
|
|
244
|
+
onGroupChange?: (group: string, ws: ServerWebSocket<WsData>, joined: boolean) => void;
|
|
245
|
+
/** event trace ring (debugger visibility; pre-allocated, zero-GC writes) */
|
|
246
|
+
trace: EventTrace;
|
|
150
247
|
}
|
|
151
248
|
|
|
152
|
-
export function createServerState
|
|
249
|
+
export function createServerState<B extends Bindings = DefaultBindings>(
|
|
250
|
+
options: IgnServerOptions<B>,
|
|
251
|
+
): ServerState {
|
|
252
|
+
const bindings = options.bindings ?? defaultBindings;
|
|
153
253
|
return {
|
|
254
|
+
bindings,
|
|
255
|
+
transport: bindings === defaultBindings ? defaultTransport : createTransport(bindings),
|
|
154
256
|
path: options.path ?? "/ws",
|
|
155
257
|
inbound: new Set(options.inbound ?? []),
|
|
156
258
|
bp: options.backpressure
|
|
@@ -162,12 +264,17 @@ export function createServerState(options: IgnServerOptions): ServerState {
|
|
|
162
264
|
: null,
|
|
163
265
|
metrics: createMetrics(),
|
|
164
266
|
startedAt: Date.now(),
|
|
165
|
-
authenticate: options.authenticate,
|
|
166
|
-
allowedOrigins: options.allowedOrigins,
|
|
167
|
-
token: options.token,
|
|
168
|
-
maxConnections: options.maxConnections,
|
|
169
|
-
maxMessageSize: options.maxMessageSize,
|
|
267
|
+
...(options.authenticate !== undefined ? { authenticate: options.authenticate } : {}),
|
|
268
|
+
...(options.allowedOrigins !== undefined ? { allowedOrigins: options.allowedOrigins } : {}),
|
|
269
|
+
...(options.token !== undefined ? { token: options.token } : {}),
|
|
270
|
+
...(options.maxConnections !== undefined ? { maxConnections: options.maxConnections } : {}),
|
|
271
|
+
...(options.maxMessageSize !== undefined ? { maxMessageSize: options.maxMessageSize } : {}),
|
|
272
|
+
rateLimit: resolveRateLimit(options.rateLimit),
|
|
273
|
+
...(options.authorizeTopic !== undefined ? { authorizeTopic: options.authorizeTopic } : {}),
|
|
274
|
+
...(options.authorizeGroup !== undefined ? { authorizeGroup: options.authorizeGroup } : {}),
|
|
275
|
+
...(options.topicLog !== undefined ? { topicLog: options.topicLog } : {}),
|
|
170
276
|
replay: options.replay ? { historySize: options.replay.historySize ?? 64 } : null,
|
|
277
|
+
resume: options.resume ? { historySize: options.resume.historySize ?? 256, ttlMs: options.resume.ttlMs ?? 60_000 } : null,
|
|
171
278
|
sockets: new Set(),
|
|
172
279
|
clients: new Map(),
|
|
173
280
|
rooms: new Map(),
|
|
@@ -175,5 +282,8 @@ export function createServerState(options: IgnServerOptions): ServerState {
|
|
|
175
282
|
inboundHandlers: new Map(),
|
|
176
283
|
topicHistory: new Map(),
|
|
177
284
|
replaySeq: 0,
|
|
285
|
+
rpcHandlers: new Map(),
|
|
286
|
+
graves: new Map(),
|
|
287
|
+
trace: createEventTrace(options.trace),
|
|
178
288
|
};
|
|
179
289
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable topic-log seam — the pluggable backend behind the bounded replay
|
|
3
|
+
* ring. The ring keeps the LAST N frames per topic in memory; a {@link TopicLog}
|
|
4
|
+
* receives the same frames so subscribers can resume from points the ring has
|
|
5
|
+
* already forgotten.
|
|
6
|
+
*
|
|
7
|
+
* Contract (deliberately narrow):
|
|
8
|
+
* - `append` is SYNCHRONOUS and must never throw onto the publish hot path —
|
|
9
|
+
* implementations buffer internally and flush on their own schedule (the
|
|
10
|
+
* memory impl appends to an array; a file impl would hand off to a writer;
|
|
11
|
+
* a NATS JetStream / Redis Streams impl would enqueue a publish).
|
|
12
|
+
* - `range(topic, afterSeq, limit?)` returns frames strictly AFTER `afterSeq`
|
|
13
|
+
* oldest → newest, synchronously. Adapters over remote stores should
|
|
14
|
+
* maintain a local read-through cache so this stays sync-friendly.
|
|
15
|
+
* - `latestSeq(topic)` mirrors the server's replay-seq counter for the topic
|
|
16
|
+
* (0 = unknown/empty).
|
|
17
|
+
*
|
|
18
|
+
* Ship-with implementation: {@link createMemoryTopicLog} — per-topic bounded
|
|
19
|
+
* array (drop-oldest), process-local durability (survives ring overflow, not a
|
|
20
|
+
* restart). Production adapters (JetStream / Redis Streams / filesystem)
|
|
21
|
+
* implement the same three methods — see docs/architecture.md ("Durability").
|
|
22
|
+
*/
|
|
23
|
+
import { RingBuffer } from "./ring";
|
|
24
|
+
|
|
25
|
+
/** One durably-retained topic frame. */
|
|
26
|
+
export interface LoggedFrame {
|
|
27
|
+
/** global replay seq (the same counter stamped into the topic history) */
|
|
28
|
+
seq: number;
|
|
29
|
+
frame: Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TopicLog {
|
|
33
|
+
/** Record a frame for `topic` (fire-and-forget; never throws). */
|
|
34
|
+
append(topic: string, frame: Uint8Array, seq: number): void;
|
|
35
|
+
/** Frames strictly after `afterSeq`, oldest → newest (at most `limit`). */
|
|
36
|
+
range(topic: string, afterSeq: number, limit?: number): LoggedFrame[];
|
|
37
|
+
/** Highest seq retained for `topic` (0 = none). */
|
|
38
|
+
latestSeq(topic: string): number;
|
|
39
|
+
/** Release resources (flush buffers, close files/connections). */
|
|
40
|
+
close(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MemoryTopicLogOptions {
|
|
44
|
+
/** max frames retained PER TOPIC (drop-oldest beyond), default 10_000 */
|
|
45
|
+
maxPerTopic?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Process-local durable log: survives ring overflow, not a restart. */
|
|
49
|
+
export function createMemoryTopicLog(
|
|
50
|
+
opts: MemoryTopicLogOptions = {},
|
|
51
|
+
): TopicLog {
|
|
52
|
+
const max = Math.max(1, opts.maxPerTopic ?? 10_000);
|
|
53
|
+
const topics = new Map<string, { frames: RingBuffer<LoggedFrame>; latest: number }>();
|
|
54
|
+
const ensure = (topic: string) => {
|
|
55
|
+
let t = topics.get(topic);
|
|
56
|
+
if (!t) {
|
|
57
|
+
t = { frames: new RingBuffer<LoggedFrame>(max, true), latest: 0 };
|
|
58
|
+
topics.set(topic, t);
|
|
59
|
+
}
|
|
60
|
+
return t;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
append(topic, frame, seq) {
|
|
64
|
+
const t = ensure(topic);
|
|
65
|
+
t.frames.push({ seq, frame: frame.slice() });
|
|
66
|
+
if (seq > t.latest) t.latest = seq;
|
|
67
|
+
},
|
|
68
|
+
range(topic, afterSeq, limit) {
|
|
69
|
+
const t = topics.get(topic);
|
|
70
|
+
if (!t) return [];
|
|
71
|
+
const out: LoggedFrame[] = [];
|
|
72
|
+
for (const e of t.frames) {
|
|
73
|
+
if (e.seq <= afterSeq) continue;
|
|
74
|
+
out.push(e);
|
|
75
|
+
if (limit !== undefined && out.length >= limit) break;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
latestSeq(topic) {
|
|
80
|
+
return topics.get(topic)?.latest ?? 0;
|
|
81
|
+
},
|
|
82
|
+
close() {
|
|
83
|
+
topics.clear();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client store — the live registry of active connections, "who is connected,
|
|
3
|
+
* on whose behalf, and what the app remembers about them".
|
|
4
|
+
*
|
|
5
|
+
* - `byId: Map<clientId, EventClient>` — one record per socket.
|
|
6
|
+
* - `byUser: Map<userId, Set<clientId>>` — the reverse "on what behalf" index,
|
|
7
|
+
* so a user with several tabs/devices is one logical target.
|
|
8
|
+
* - Each record carries a per-connection `data` store (created on attach,
|
|
9
|
+
* dropped on detach) — `hub.setClientData` / `client.data` read/write it.
|
|
10
|
+
*
|
|
11
|
+
* Pure local state: cross-instance presence / state sync lives in `cluster.ts`
|
|
12
|
+
* and is driven FROM this store via the `onAttach` / `onDetach` hooks (which
|
|
13
|
+
* the hub wires to offloaded queue work, keeping connect/disconnect O(1)).
|
|
14
|
+
*/
|
|
15
|
+
import type { ServerWebSocket } from "bun";
|
|
16
|
+
import type { WsData } from "../core/state";
|
|
17
|
+
import { createClientData } from "./data";
|
|
18
|
+
import type { ClientData, EventClient } from "./types";
|
|
19
|
+
|
|
20
|
+
/** Factory for a client record bound to a live socket. */
|
|
21
|
+
export function createEventClient(
|
|
22
|
+
ws: ServerWebSocket<WsData>,
|
|
23
|
+
): EventClient {
|
|
24
|
+
const client: MutableEventClient = {
|
|
25
|
+
get id(): string {
|
|
26
|
+
return ws.data.id;
|
|
27
|
+
},
|
|
28
|
+
// `userId` is the identity this connection acts on behalf of — read live
|
|
29
|
+
// from the socket data (single source of truth; `setUserId` writes there).
|
|
30
|
+
get userId(): string | undefined {
|
|
31
|
+
return ws.data.userId;
|
|
32
|
+
},
|
|
33
|
+
get meta(): Record<string, unknown> | undefined {
|
|
34
|
+
return ws.data.meta;
|
|
35
|
+
},
|
|
36
|
+
data: createClientData(),
|
|
37
|
+
get groups(): ReadonlySet<string> {
|
|
38
|
+
return ws.data.groups;
|
|
39
|
+
},
|
|
40
|
+
get topics(): ReadonlySet<string> {
|
|
41
|
+
return ws.data.topics;
|
|
42
|
+
},
|
|
43
|
+
get connectedAt(): number {
|
|
44
|
+
return ws.data.connectedAt;
|
|
45
|
+
},
|
|
46
|
+
get ip(): string {
|
|
47
|
+
return ws.remoteAddress;
|
|
48
|
+
},
|
|
49
|
+
// plain mutable property — the store flips it to true on detach
|
|
50
|
+
closed: false,
|
|
51
|
+
get ws(): ServerWebSocket<WsData> {
|
|
52
|
+
return ws;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return client;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The client record with readonly modifiers stripped (internal mutation). */
|
|
59
|
+
export type MutableEventClient = {
|
|
60
|
+
-readonly [K in keyof EventClient]: EventClient[K];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface ClientStore {
|
|
64
|
+
readonly size: number;
|
|
65
|
+
attach(ws: ServerWebSocket<WsData>): EventClient;
|
|
66
|
+
detach(ws: ServerWebSocket<WsData>): EventClient | undefined;
|
|
67
|
+
get(id: string): EventClient | undefined;
|
|
68
|
+
all(): EventClient[];
|
|
69
|
+
byUser(userId: string): EventClient[];
|
|
70
|
+
/**
|
|
71
|
+
* Invoke `each` for every live socket of `userId`; returns the count.
|
|
72
|
+
* Allocation-free variant of {@link byUser} for emit hot paths.
|
|
73
|
+
*/
|
|
74
|
+
forEachByUser(userId: string, each: (client: EventClient) => void): number;
|
|
75
|
+
setUserId(clientId: string, userId: string): boolean;
|
|
76
|
+
onAttach(cb: (client: EventClient) => void): void;
|
|
77
|
+
onDetach(cb: (client: EventClient) => void): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createClientStore(): ClientStore {
|
|
81
|
+
const byId = new Map<string, MutableEventClient>();
|
|
82
|
+
const byUser = new Map<string, Set<string>>();
|
|
83
|
+
const attachCbs: Array<(client: EventClient) => void> = [];
|
|
84
|
+
const detachCbs: Array<(client: EventClient) => void> = [];
|
|
85
|
+
|
|
86
|
+
const indexUser = (client: EventClient): void => {
|
|
87
|
+
const userId = client.userId;
|
|
88
|
+
if (!userId) return;
|
|
89
|
+
let set = byUser.get(userId);
|
|
90
|
+
if (!set) {
|
|
91
|
+
set = new Set();
|
|
92
|
+
byUser.set(userId, set);
|
|
93
|
+
}
|
|
94
|
+
set.add(client.id);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const unindexUser = (client: EventClient): void => {
|
|
98
|
+
const userId = client.userId;
|
|
99
|
+
if (!userId) return;
|
|
100
|
+
const set = byUser.get(userId);
|
|
101
|
+
if (!set) return;
|
|
102
|
+
set.delete(client.id);
|
|
103
|
+
if (set.size === 0) byUser.delete(userId);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
get size(): number {
|
|
108
|
+
return byId.size;
|
|
109
|
+
},
|
|
110
|
+
attach(ws) {
|
|
111
|
+
const existing = byId.get(ws.data.id);
|
|
112
|
+
if (existing) return existing;
|
|
113
|
+
const client = createEventClient(ws);
|
|
114
|
+
byId.set(client.id, client);
|
|
115
|
+
indexUser(client);
|
|
116
|
+
for (const cb of attachCbs) cb(client);
|
|
117
|
+
return client;
|
|
118
|
+
},
|
|
119
|
+
detach(ws) {
|
|
120
|
+
const client = byId.get(ws.data.id);
|
|
121
|
+
if (!client) return undefined;
|
|
122
|
+
byId.delete(client.id);
|
|
123
|
+
unindexUser(client);
|
|
124
|
+
client.closed = true;
|
|
125
|
+
for (const cb of detachCbs) cb(client);
|
|
126
|
+
return client;
|
|
127
|
+
},
|
|
128
|
+
get(id) {
|
|
129
|
+
return byId.get(id);
|
|
130
|
+
},
|
|
131
|
+
all() {
|
|
132
|
+
return [...byId.values()];
|
|
133
|
+
},
|
|
134
|
+
byUser(userId) {
|
|
135
|
+
const ids = byUser.get(userId);
|
|
136
|
+
if (!ids) return [];
|
|
137
|
+
const out: EventClient[] = [];
|
|
138
|
+
for (const id of ids) {
|
|
139
|
+
const c = byId.get(id);
|
|
140
|
+
if (c) out.push(c);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
},
|
|
144
|
+
forEachByUser(userId, each) {
|
|
145
|
+
const ids = byUser.get(userId);
|
|
146
|
+
if (ids === undefined || ids.size === 0) return 0;
|
|
147
|
+
let n = 0;
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
const c = byId.get(id);
|
|
150
|
+
if (c !== undefined) {
|
|
151
|
+
each(c);
|
|
152
|
+
n++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return n;
|
|
156
|
+
},
|
|
157
|
+
setUserId(clientId, userId) {
|
|
158
|
+
const client = byId.get(clientId);
|
|
159
|
+
if (!client) return false;
|
|
160
|
+
client.ws.data.userId = userId;
|
|
161
|
+
unindexUser(client);
|
|
162
|
+
indexUser(client);
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
165
|
+
onAttach(cb) {
|
|
166
|
+
attachCbs.push(cb as (client: EventClient) => void);
|
|
167
|
+
},
|
|
168
|
+
onDetach(cb) {
|
|
169
|
+
detachCbs.push(cb as (client: EventClient) => void);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type { ClientData };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broker-redelivery dedupe window — bounded recent-message-id tracking.
|
|
3
|
+
*
|
|
4
|
+
* Durable brokers may redeliver; every processed message id is recorded in a
|
|
5
|
+
* ring + set pair and duplicates are dropped. Encapsulated factory (like
|
|
6
|
+
* `createMetrics`) — the state is private, the surface is one pure predicate.
|
|
7
|
+
*/
|
|
8
|
+
import { RingBuffer } from "../../core/ring";
|
|
9
|
+
|
|
10
|
+
export interface DedupeWindow {
|
|
11
|
+
/**
|
|
12
|
+
* Record `id` and report whether it was ALREADY seen (true → drop the
|
|
13
|
+
* message). Empty ids and a zero-size window disable tracking entirely.
|
|
14
|
+
*/
|
|
15
|
+
markSeen(id: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param size how many message ids to remember (0 disables; values < 16 are
|
|
20
|
+
* clamped up so the ring has usable capacity).
|
|
21
|
+
*/
|
|
22
|
+
export function createDedupeWindow(size: number): DedupeWindow {
|
|
23
|
+
const window = Math.max(0, size);
|
|
24
|
+
if (window === 0) return { markSeen: () => false };
|
|
25
|
+
|
|
26
|
+
const ring = new RingBuffer<string>(Math.max(16, window), true);
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
markSeen(id: string): boolean {
|
|
31
|
+
if (id === "") return false;
|
|
32
|
+
if (seen.has(id)) return true;
|
|
33
|
+
// evict the oldest id when the window is full (FIFO — matches redelivery)
|
|
34
|
+
if (ring.length >= window) {
|
|
35
|
+
const evict = ring.shift();
|
|
36
|
+
if (evict !== undefined) seen.delete(evict);
|
|
37
|
+
}
|
|
38
|
+
ring.push(id);
|
|
39
|
+
seen.add(id);
|
|
40
|
+
return false;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|