@agentlip/hub 0.1.0

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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * WebSocket endpoint implementation for Agentlip Hub
3
+ *
4
+ * Implements the WS protocol from AGENTLIP_PLAN.md:
5
+ * - Hello handshake with token validation
6
+ * - Event replay with subscription filtering
7
+ * - Live event streaming with backpressure disconnect
8
+ * - Size validation and proper error handling
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+ import type { ServerWebSocket } from "bun";
13
+ import { requireWsToken } from "./authMiddleware";
14
+ import { parseWsMessage, validateWsMessageSize, SIZE_LIMITS } from "./bodyParser";
15
+ import {
16
+ getLatestEventId,
17
+ replayEvents,
18
+ getEventById,
19
+ type ParsedEvent,
20
+ } from "@agentlip/kernel";
21
+ import { randomBytes } from "node:crypto";
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // Types
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ interface HelloMessage {
28
+ type: "hello";
29
+ after_event_id: number;
30
+ subscriptions?: {
31
+ channels?: string[];
32
+ topics?: string[];
33
+ };
34
+ }
35
+
36
+ interface HelloOkMessage {
37
+ type: "hello_ok";
38
+ replay_until: number;
39
+ instance_id: string;
40
+ }
41
+
42
+ interface EventEnvelope {
43
+ type: "event";
44
+ event_id: number;
45
+ ts: string;
46
+ name: string;
47
+ scope: {
48
+ channel_id?: string | null;
49
+ topic_id?: string | null;
50
+ topic_id2?: string | null;
51
+ };
52
+ data: Record<string, unknown>;
53
+ }
54
+
55
+ interface WsConnectionData {
56
+ authenticated: boolean;
57
+ handshakeComplete: boolean;
58
+ /**
59
+ * Subscription filters:
60
+ * - `null` for channels/topics means "wildcard" (subscribe to all) - used when hello.subscriptions is omitted
61
+ * - Empty Set means "subscribe to none" - used when hello.subscriptions is provided but empty
62
+ * - Non-empty Set means "subscribe to specific IDs"
63
+ */
64
+ subscriptions: {
65
+ channels: Set<string> | null;
66
+ topics: Set<string> | null;
67
+ };
68
+ replayUntil?: number;
69
+ }
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // WebSocket Hub (manages connections and fanout)
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ export interface WsHub {
76
+ /**
77
+ * Notify hub of a new event for fanout to subscribed clients.
78
+ * Called after event is committed to DB.
79
+ */
80
+ publishEvent(event: ParsedEvent): void;
81
+
82
+ /**
83
+ * Notify hub of new events by ID (will fetch from DB).
84
+ * Alternative to publishEvent when you only have event IDs.
85
+ */
86
+ publishEventIds(eventIds: number[]): void;
87
+
88
+ /**
89
+ * Get current connection count (for monitoring).
90
+ */
91
+ getConnectionCount(): number;
92
+
93
+ /**
94
+ * Close all connections (for graceful shutdown).
95
+ */
96
+ closeAll(): void;
97
+ }
98
+
99
+ interface WsHubOptions {
100
+ db: Database;
101
+ instanceId?: string;
102
+ }
103
+
104
+ /**
105
+ * Create a WebSocket hub for managing connections and event fanout.
106
+ *
107
+ * @param options - Database and instance ID
108
+ * @returns WsHub instance
109
+ */
110
+ export function createWsHub(options: WsHubOptions): WsHub {
111
+ const { db } = options;
112
+ const instanceId = options.instanceId ?? randomBytes(16).toString("hex");
113
+ const connections = new Set<ServerWebSocket<WsConnectionData>>();
114
+
115
+ function publishEvent(event: ParsedEvent): void {
116
+ const envelope: EventEnvelope = {
117
+ type: "event",
118
+ event_id: event.event_id,
119
+ ts: event.ts,
120
+ name: event.name,
121
+ scope: event.scope,
122
+ data: event.data,
123
+ };
124
+
125
+ for (const ws of connections) {
126
+ if (!ws.data.handshakeComplete) {
127
+ continue;
128
+ }
129
+
130
+ // Check if event matches subscription
131
+ const matchesSubscription = isEventSubscribed(event, ws.data.subscriptions);
132
+ if (!matchesSubscription) {
133
+ continue;
134
+ }
135
+
136
+ // Only send live events (> replay_until)
137
+ if (ws.data.replayUntil !== undefined && event.event_id <= ws.data.replayUntil) {
138
+ continue;
139
+ }
140
+
141
+ // Send event with backpressure check
142
+ sendEventWithBackpressure(ws, envelope);
143
+ }
144
+ }
145
+
146
+ function publishEventIds(eventIds: number[]): void {
147
+ for (const eventId of eventIds) {
148
+ const event = getEventById(db, eventId);
149
+ if (event) {
150
+ publishEvent(event);
151
+ }
152
+ }
153
+ }
154
+
155
+ function getConnectionCount(): number {
156
+ return connections.size;
157
+ }
158
+
159
+ function closeAll(): void {
160
+ for (const ws of connections) {
161
+ try {
162
+ ws.close(1001, "Server shutting down");
163
+ } catch {
164
+ // Ignore errors during shutdown
165
+ }
166
+ }
167
+ connections.clear();
168
+ }
169
+
170
+ return {
171
+ publishEvent,
172
+ publishEventIds,
173
+ getConnectionCount,
174
+ closeAll,
175
+ // Internal: register/unregister connections (called by handlers)
176
+ _registerConnection: (ws: ServerWebSocket<WsConnectionData>) => connections.add(ws),
177
+ _unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => connections.delete(ws),
178
+ _getInstanceId: () => instanceId,
179
+ } as WsHub & {
180
+ _registerConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
181
+ _unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
182
+ _getInstanceId: () => string;
183
+ };
184
+ }
185
+
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ // WebSocket Handlers (for Bun.serve)
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+
190
+ interface CreateWsHandlersOptions {
191
+ db: Database;
192
+ authToken: string;
193
+ hub: WsHub & {
194
+ _registerConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
195
+ _unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
196
+ _getInstanceId: () => string;
197
+ };
198
+ }
199
+
200
+ export interface WsHandlers {
201
+ upgrade: (req: Request, server: unknown) => Response | undefined;
202
+ open: (ws: ServerWebSocket<WsConnectionData>) => void;
203
+ message: (ws: ServerWebSocket<WsConnectionData>, message: string | Buffer) => void;
204
+ close: (ws: ServerWebSocket<WsConnectionData>) => void;
205
+ }
206
+
207
+ /**
208
+ * Create WebSocket handlers for Bun.serve.
209
+ *
210
+ * Usage:
211
+ * ```ts
212
+ * const hub = createWsHub({ db });
213
+ * const handlers = createWsHandlers({ db, authToken, hub });
214
+ *
215
+ * Bun.serve({
216
+ * fetch(req, server) {
217
+ * if (url.pathname === "/ws") {
218
+ * return handlers.upgrade(req, server);
219
+ * }
220
+ * // ... other routes
221
+ * },
222
+ * websocket: {
223
+ * open: handlers.open,
224
+ * message: handlers.message,
225
+ * close: handlers.close,
226
+ * },
227
+ * });
228
+ * ```
229
+ */
230
+ export function createWsHandlers(options: CreateWsHandlersOptions): WsHandlers {
231
+ const { db, authToken, hub } = options;
232
+
233
+ function upgrade(req: Request, server: any): Response | undefined {
234
+ const url = new URL(req.url);
235
+
236
+ // Validate auth token
237
+ const authResult = requireWsToken(url, authToken);
238
+ if (!authResult.ok) {
239
+ // Return HTTP 401 for upgrade failures (before WS handshake)
240
+ return new Response("Unauthorized", { status: 401 });
241
+ }
242
+
243
+ // Upgrade to WebSocket
244
+ const upgraded = server.upgrade(req, {
245
+ data: {
246
+ authenticated: true,
247
+ handshakeComplete: false,
248
+ subscriptions: {
249
+ channels: null, // Will be set in handleHello
250
+ topics: null,
251
+ },
252
+ } as WsConnectionData,
253
+ });
254
+
255
+ if (!upgraded) {
256
+ return new Response("WebSocket upgrade failed", { status: 500 });
257
+ }
258
+
259
+ return undefined; // Upgrade successful
260
+ }
261
+
262
+ function open(ws: ServerWebSocket<WsConnectionData>): void {
263
+ hub._registerConnection(ws);
264
+ }
265
+
266
+ function message(ws: ServerWebSocket<WsConnectionData>, message: string | Buffer): void {
267
+ // Validate message size
268
+ if (!validateWsMessageSize(message, SIZE_LIMITS.WS_MESSAGE)) {
269
+ ws.close(1009, "Message too large");
270
+ return;
271
+ }
272
+
273
+ // Parse JSON
274
+ const parsed = parseWsMessage<HelloMessage>(message, SIZE_LIMITS.WS_MESSAGE);
275
+ if (!parsed) {
276
+ ws.close(1003, "Invalid JSON");
277
+ return;
278
+ }
279
+
280
+ // Only accept "hello" message before handshake
281
+ if (!ws.data.handshakeComplete) {
282
+ if (parsed.type !== "hello") {
283
+ ws.close(1003, "Expected hello message");
284
+ return;
285
+ }
286
+
287
+ handleHello(ws, parsed, db, hub._getInstanceId());
288
+ return;
289
+ }
290
+
291
+ // After handshake, we don't expect client messages in v1
292
+ // (Future: could support ping/pong, subscription updates, etc.)
293
+ ws.close(1003, "Unexpected message after handshake");
294
+ }
295
+
296
+ function close(ws: ServerWebSocket<WsConnectionData>): void {
297
+ hub._unregisterConnection(ws);
298
+ }
299
+
300
+ return { upgrade, open, message, close };
301
+ }
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────────
304
+ // Protocol Handlers
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+
307
+ function handleHello(
308
+ ws: ServerWebSocket<WsConnectionData>,
309
+ hello: HelloMessage,
310
+ db: Database,
311
+ instanceId: string
312
+ ): void {
313
+ // Validate hello message
314
+ if (typeof hello.after_event_id !== "number" || hello.after_event_id < 0) {
315
+ ws.close(1003, "Invalid after_event_id");
316
+ return;
317
+ }
318
+
319
+ // Determine subscription mode:
320
+ // - omitted subscriptions = wildcard (null) = subscribe to ALL
321
+ // - provided but empty = subscribe to NONE
322
+ // - provided with values = filter to those values
323
+ const subscriptionsOmitted = hello.subscriptions === undefined;
324
+
325
+ // Extract channel/topic arrays if subscriptions was provided
326
+ const channelIds = hello.subscriptions?.channels ?? [];
327
+ const topicIds = hello.subscriptions?.topics ?? [];
328
+
329
+ // Validate subscriptions are arrays of strings (only if provided)
330
+ if (!subscriptionsOmitted) {
331
+ if (!Array.isArray(channelIds) || !channelIds.every(id => typeof id === "string")) {
332
+ ws.close(1003, "Invalid channel subscriptions");
333
+ return;
334
+ }
335
+ if (!Array.isArray(topicIds) || !topicIds.every(id => typeof id === "string")) {
336
+ ws.close(1003, "Invalid topic subscriptions");
337
+ return;
338
+ }
339
+ }
340
+
341
+ // Store subscriptions:
342
+ // - null means wildcard (subscribe to all) - when subscriptions omitted
343
+ // - Set means filter to those IDs (empty Set = subscribe to none)
344
+ if (subscriptionsOmitted) {
345
+ ws.data.subscriptions.channels = null; // wildcard
346
+ ws.data.subscriptions.topics = null; // wildcard
347
+ } else {
348
+ ws.data.subscriptions.channels = new Set(channelIds);
349
+ ws.data.subscriptions.topics = new Set(topicIds);
350
+ }
351
+
352
+ // Compute replay boundary (snapshot of latest event_id)
353
+ const replayUntil = getLatestEventId(db);
354
+ ws.data.replayUntil = replayUntil;
355
+
356
+ // Send hello_ok
357
+ const helloOk: HelloOkMessage = {
358
+ type: "hello_ok",
359
+ replay_until: replayUntil,
360
+ instance_id: instanceId,
361
+ };
362
+
363
+ const sendStatus = ws.send(JSON.stringify(helloOk));
364
+ if (sendStatus === -1 || sendStatus === 0) {
365
+ ws.close(1008, "backpressure");
366
+ return;
367
+ }
368
+
369
+ // Mark handshake complete
370
+ ws.data.handshakeComplete = true;
371
+
372
+ // Determine if we should do replay:
373
+ // - If subscriptions was explicitly provided but both channels and topics are empty,
374
+ // that means "subscribe to none" - skip replay entirely
375
+ // - Otherwise, replay with appropriate filters
376
+ const subscribeToNone = !subscriptionsOmitted &&
377
+ channelIds.length === 0 && topicIds.length === 0;
378
+
379
+ if (subscribeToNone) {
380
+ // No replay when explicitly subscribing to nothing
381
+ return;
382
+ }
383
+
384
+ // Send replay events if any
385
+ if (replayUntil > hello.after_event_id) {
386
+ try {
387
+ // For wildcard (omitted subscriptions), pass undefined to get all events
388
+ // For filtered, pass the arrays (or undefined if that filter is empty but other isn't)
389
+ const events = replayEvents({
390
+ db,
391
+ afterEventId: hello.after_event_id,
392
+ replayUntil,
393
+ channelIds: subscriptionsOmitted ? undefined : (channelIds.length > 0 ? channelIds : undefined),
394
+ topicIds: subscriptionsOmitted ? undefined : (topicIds.length > 0 ? topicIds : undefined),
395
+ limit: 1000, // Plan default
396
+ });
397
+
398
+ for (const event of events) {
399
+ const envelope: EventEnvelope = {
400
+ type: "event",
401
+ event_id: event.event_id,
402
+ ts: event.ts,
403
+ name: event.name,
404
+ scope: event.scope,
405
+ data: event.data,
406
+ };
407
+
408
+ sendEventWithBackpressure(ws, envelope);
409
+
410
+ // If connection was closed due to backpressure, stop
411
+ if (ws.readyState !== 1) { // 1 = OPEN
412
+ return;
413
+ }
414
+ }
415
+ } catch (error) {
416
+ console.error("Replay error:", error);
417
+ ws.close(1011, "Internal error during replay");
418
+ return;
419
+ }
420
+ }
421
+ }
422
+
423
+ function sendEventWithBackpressure(
424
+ ws: ServerWebSocket<WsConnectionData>,
425
+ envelope: EventEnvelope
426
+ ): void {
427
+ try {
428
+ const serialized = JSON.stringify(envelope);
429
+ const sendStatus = ws.send(serialized);
430
+
431
+ // Backpressure detection (plan spec: close on -1 or 0)
432
+ if (sendStatus === -1 || sendStatus === 0) {
433
+ ws.close(1008, "backpressure");
434
+ }
435
+ } catch (error) {
436
+ // Send error (connection may be closed)
437
+ try {
438
+ ws.close(1011, "Send error");
439
+ } catch {
440
+ // Ignore double-close errors
441
+ }
442
+ }
443
+ }
444
+
445
+ function isEventSubscribed(
446
+ event: ParsedEvent,
447
+ subscriptions: { channels: Set<string> | null; topics: Set<string> | null }
448
+ ): boolean {
449
+ // Wildcard mode: if both channels and topics are null, match ALL events
450
+ // This happens when hello.subscriptions is omitted entirely
451
+ if (subscriptions.channels === null && subscriptions.topics === null) {
452
+ return true;
453
+ }
454
+
455
+ // If both are non-null Sets and both are empty, match NONE
456
+ // This happens when hello.subscriptions was provided but empty: { channels: [], topics: [] }
457
+ if (subscriptions.channels !== null && subscriptions.topics !== null &&
458
+ subscriptions.channels.size === 0 && subscriptions.topics.size === 0) {
459
+ return false;
460
+ }
461
+
462
+ // Filter mode: check if event matches any subscribed channel or topic
463
+ // Match by channel
464
+ if (subscriptions.channels !== null && subscriptions.channels.size > 0) {
465
+ if (event.scope.channel_id && subscriptions.channels.has(event.scope.channel_id)) {
466
+ return true;
467
+ }
468
+ }
469
+
470
+ // Match by topic (scope_topic_id or scope_topic_id2)
471
+ if (subscriptions.topics !== null && subscriptions.topics.size > 0) {
472
+ if (event.scope.topic_id && subscriptions.topics.has(event.scope.topic_id)) {
473
+ return true;
474
+ }
475
+ if (event.scope.topic_id2 && subscriptions.topics.has(event.scope.topic_id2)) {
476
+ return true;
477
+ }
478
+ }
479
+
480
+ return false;
481
+ }