@syncular/server-hono 0.0.1-60

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.
Files changed (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. package/src/ws.ts +789 -0
package/src/ws.ts ADDED
@@ -0,0 +1,789 @@
1
+ /**
2
+ * @syncular/server-hono - WebSocket helpers for realtime sync wake-ups
3
+ *
4
+ * WebSockets are used only as a "wake up" mechanism; clients must still pull.
5
+ * Also supports presence tracking for collaborative features.
6
+ */
7
+
8
+ import type { WSContext } from 'hono/ws';
9
+
10
+ /**
11
+ * Presence entry for a client connected to a scope
12
+ */
13
+ export interface PresenceEntry {
14
+ clientId: string;
15
+ actorId: string;
16
+ joinedAt: number;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ /**
21
+ * Push response data sent back to the client over WS
22
+ */
23
+ export interface WsPushResponseData {
24
+ requestId: string;
25
+ ok: boolean;
26
+ status: string;
27
+ commitSeq?: number;
28
+ results: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
29
+ }
30
+
31
+ /**
32
+ * WebSocket event data for sync notifications
33
+ */
34
+ export interface SyncWebSocketEvent {
35
+ /** Event type */
36
+ event: 'sync' | 'heartbeat' | 'error' | 'presence' | 'push-response';
37
+ /** Data payload */
38
+ data: {
39
+ /** New cursor position (for sync events) */
40
+ cursor?: number;
41
+ /** Error message (for error events) */
42
+ error?: string;
43
+ /** Presence data (for presence events) */
44
+ presence?: {
45
+ action: 'join' | 'leave' | 'update' | 'snapshot';
46
+ scopeKey: string;
47
+ clientId?: string;
48
+ actorId?: string;
49
+ metadata?: Record<string, unknown>;
50
+ entries?: PresenceEntry[];
51
+ };
52
+ /** Push response data (for push-response events) */
53
+ requestId?: string;
54
+ ok?: boolean;
55
+ status?: string;
56
+ commitSeq?: number;
57
+ results?: Array<{ opIndex: number; status: string; [k: string]: unknown }>;
58
+ /** Timestamp */
59
+ timestamp: number;
60
+ };
61
+ }
62
+
63
+ /**
64
+ * WebSocket connection controller for managing active connections
65
+ */
66
+ export interface WebSocketConnection {
67
+ /** Send a sync notification, optionally with inline change data */
68
+ sendSync(cursor: number, changes?: unknown[]): void;
69
+ /** Send a heartbeat */
70
+ sendHeartbeat(): void;
71
+ /** Send a presence event */
72
+ sendPresence(data: {
73
+ action: 'join' | 'leave' | 'update' | 'snapshot';
74
+ scopeKey: string;
75
+ clientId?: string;
76
+ actorId?: string;
77
+ metadata?: Record<string, unknown>;
78
+ entries?: PresenceEntry[];
79
+ }): void;
80
+ /** Send a push response back to the client */
81
+ sendPushResponse(data: WsPushResponseData): void;
82
+ /** Send an error and close */
83
+ sendError(message: string): void;
84
+ /** Close the connection */
85
+ close(code?: number, reason?: string): void;
86
+ /** Whether the connection is still open */
87
+ isOpen: boolean;
88
+ /** Actor ID for this connection */
89
+ actorId: string;
90
+ /** Client/device identifier for this connection */
91
+ clientId: string;
92
+ /** Transport path used by this connection. */
93
+ transportPath: 'direct' | 'relay';
94
+ }
95
+
96
+ function safeSend(ws: WSContext, message: string): boolean {
97
+ try {
98
+ ws.send(message);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ export function createWebSocketConnection(
106
+ ws: WSContext,
107
+ args: { actorId: string; clientId: string; transportPath: 'direct' | 'relay' }
108
+ ): WebSocketConnection {
109
+ let closed = false;
110
+
111
+ const connection: WebSocketConnection = {
112
+ get isOpen() {
113
+ if (closed) return false;
114
+ return ws.readyState === 1;
115
+ },
116
+ actorId: args.actorId,
117
+ clientId: args.clientId,
118
+ transportPath: args.transportPath,
119
+ sendSync(cursor: number, changes?: unknown[]) {
120
+ if (!connection.isOpen) return;
121
+ const payload: Record<string, unknown> = {
122
+ cursor,
123
+ timestamp: Date.now(),
124
+ };
125
+ if (changes && changes.length > 0) {
126
+ payload.changes = changes;
127
+ }
128
+ const ok = safeSend(ws, JSON.stringify({ event: 'sync', data: payload }));
129
+ if (!ok) closed = true;
130
+ },
131
+ sendHeartbeat() {
132
+ if (!connection.isOpen) return;
133
+ const ok = safeSend(
134
+ ws,
135
+ JSON.stringify({ event: 'heartbeat', data: { timestamp: Date.now() } })
136
+ );
137
+ if (!ok) closed = true;
138
+ },
139
+ sendPresence(data: {
140
+ action: 'join' | 'leave' | 'update' | 'snapshot';
141
+ scopeKey: string;
142
+ clientId?: string;
143
+ actorId?: string;
144
+ metadata?: Record<string, unknown>;
145
+ entries?: PresenceEntry[];
146
+ }) {
147
+ if (!connection.isOpen) return;
148
+ const ok = safeSend(
149
+ ws,
150
+ JSON.stringify({
151
+ event: 'presence',
152
+ data: { presence: data, timestamp: Date.now() },
153
+ })
154
+ );
155
+ if (!ok) closed = true;
156
+ },
157
+ sendPushResponse(data: WsPushResponseData) {
158
+ if (!connection.isOpen) return;
159
+ const ok = safeSend(
160
+ ws,
161
+ JSON.stringify({
162
+ event: 'push-response',
163
+ data: { ...data, timestamp: Date.now() },
164
+ })
165
+ );
166
+ if (!ok) closed = true;
167
+ },
168
+ sendError(message: string) {
169
+ if (connection.isOpen) {
170
+ safeSend(
171
+ ws,
172
+ JSON.stringify({
173
+ event: 'error',
174
+ data: { error: message, timestamp: Date.now() },
175
+ })
176
+ );
177
+ }
178
+ connection.close(1011, 'server error');
179
+ },
180
+ close(code?: number, reason?: string) {
181
+ if (closed) return;
182
+ closed = true;
183
+ try {
184
+ ws.close(code, reason);
185
+ } catch {
186
+ // ignore
187
+ }
188
+ },
189
+ };
190
+
191
+ return connection;
192
+ }
193
+
194
+ /**
195
+ * Connection manager for tracking active WebSocket connections.
196
+ * Scope-key based notifications and presence tracking.
197
+ */
198
+ export class WebSocketConnectionManager {
199
+ private connectionsByClientId = new Map<string, Set<WebSocketConnection>>();
200
+ private scopeKeysByClientId = new Map<string, Set<string>>();
201
+ private connectionsByScopeKey = new Map<string, Set<WebSocketConnection>>();
202
+
203
+ /**
204
+ * In-memory presence tracking by scope key.
205
+ * Map<scopeKey, Map<clientId, PresenceEntry>>
206
+ */
207
+ private presenceByScopeKey = new Map<string, Map<string, PresenceEntry>>();
208
+
209
+ /**
210
+ * Callback for presence changes - allows integration with SyncRealtimeBroadcaster
211
+ */
212
+ onPresenceChange?: (event: {
213
+ action: 'join' | 'leave' | 'update';
214
+ scopeKey: string;
215
+ clientId: string;
216
+ actorId: string;
217
+ metadata?: Record<string, unknown>;
218
+ }) => void;
219
+
220
+ private heartbeatIntervalMs: number;
221
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
222
+
223
+ constructor(options?: {
224
+ heartbeatIntervalMs?: number;
225
+ onPresenceChange?: WebSocketConnectionManager['onPresenceChange'];
226
+ }) {
227
+ this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
228
+ this.onPresenceChange = options?.onPresenceChange;
229
+ }
230
+
231
+ /**
232
+ * Register a connection for a client.
233
+ * Returns a cleanup function to unregister.
234
+ */
235
+ register(
236
+ connection: WebSocketConnection,
237
+ initialScopeKeys: string[] = []
238
+ ): () => void {
239
+ const clientId = connection.clientId;
240
+ let clientConns = this.connectionsByClientId.get(clientId);
241
+ if (!clientConns) {
242
+ clientConns = new Set();
243
+ this.connectionsByClientId.set(clientId, clientConns);
244
+ }
245
+ clientConns.add(connection);
246
+
247
+ if (!this.scopeKeysByClientId.has(clientId)) {
248
+ this.scopeKeysByClientId.set(clientId, new Set(initialScopeKeys));
249
+ }
250
+
251
+ const scopeKeys =
252
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
253
+ for (const k of scopeKeys) {
254
+ let scopeConns = this.connectionsByScopeKey.get(k);
255
+ if (!scopeConns) {
256
+ scopeConns = new Set();
257
+ this.connectionsByScopeKey.set(k, scopeConns);
258
+ }
259
+ scopeConns.add(connection);
260
+ }
261
+
262
+ this.ensureHeartbeat();
263
+
264
+ return () => {
265
+ this.unregister(connection);
266
+ this.ensureHeartbeat();
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Update the effective scopes for an already-connected client.
272
+ * If the client has no active connections, this is a no-op.
273
+ */
274
+ updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
275
+ const conns = this.connectionsByClientId.get(clientId);
276
+ if (!conns || conns.size === 0) return;
277
+
278
+ const next = new Set(scopeKeys);
279
+ const prev = this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
280
+
281
+ // No-op when unchanged (reduces write load when clients pull frequently).
282
+ if (prev.size === next.size) {
283
+ let unchanged = true;
284
+ for (const k of prev) {
285
+ if (!next.has(k)) {
286
+ unchanged = false;
287
+ break;
288
+ }
289
+ }
290
+ if (unchanged) return;
291
+ }
292
+
293
+ this.scopeKeysByClientId.set(clientId, next);
294
+
295
+ for (const k of prev) {
296
+ if (next.has(k)) continue;
297
+ const set = this.connectionsByScopeKey.get(k);
298
+ if (!set) continue;
299
+ for (const conn of conns) set.delete(conn);
300
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
301
+ }
302
+
303
+ for (const k of next) {
304
+ if (prev.has(k)) continue;
305
+ let set = this.connectionsByScopeKey.get(k);
306
+ if (!set) {
307
+ set = new Set();
308
+ this.connectionsByScopeKey.set(k, set);
309
+ }
310
+ for (const conn of conns) set.add(conn);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Check whether a client is currently authorized/subscribed for a scope key.
316
+ */
317
+ isClientSubscribedToScopeKey(clientId: string, scopeKey: string): boolean {
318
+ const scopeKeys = this.scopeKeysByClientId.get(clientId);
319
+ if (!scopeKeys || scopeKeys.size === 0) return false;
320
+ return scopeKeys.has(scopeKey);
321
+ }
322
+
323
+ // =========================================================================
324
+ // Presence Tracking
325
+ // =========================================================================
326
+
327
+ /**
328
+ * Join presence for a scope key.
329
+ * Called when a client wants to be visible to others in a scope.
330
+ */
331
+ joinPresence(
332
+ clientId: string,
333
+ scopeKey: string,
334
+ metadata?: Record<string, unknown>
335
+ ): boolean {
336
+ const conns = this.connectionsByClientId.get(clientId);
337
+ if (!conns || conns.size === 0) return false;
338
+ if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
339
+
340
+ // Get actorId from first connection
341
+ const conn = conns.values().next().value;
342
+ if (!conn) return false;
343
+ const actorId = conn.actorId;
344
+
345
+ // Add to presence map
346
+ let scopePresence = this.presenceByScopeKey.get(scopeKey);
347
+ if (!scopePresence) {
348
+ scopePresence = new Map();
349
+ this.presenceByScopeKey.set(scopeKey, scopePresence);
350
+ }
351
+
352
+ const entry: PresenceEntry = {
353
+ clientId,
354
+ actorId,
355
+ joinedAt: Date.now(),
356
+ metadata,
357
+ };
358
+ scopePresence.set(clientId, entry);
359
+
360
+ // Notify other clients in this scope
361
+ this.broadcastPresenceEvent(scopeKey, {
362
+ action: 'join',
363
+ scopeKey,
364
+ clientId,
365
+ actorId,
366
+ metadata,
367
+ });
368
+
369
+ // Callback for cross-instance broadcasting
370
+ this.onPresenceChange?.({
371
+ action: 'join',
372
+ scopeKey,
373
+ clientId,
374
+ actorId,
375
+ metadata,
376
+ });
377
+
378
+ return true;
379
+ }
380
+
381
+ /**
382
+ * Leave presence for a scope key.
383
+ * Called when a client no longer wants to be visible in a scope.
384
+ */
385
+ leavePresence(clientId: string, scopeKey: string): boolean {
386
+ const scopePresence = this.presenceByScopeKey.get(scopeKey);
387
+ if (!scopePresence) return false;
388
+
389
+ const entry = scopePresence.get(clientId);
390
+ if (!entry) return false;
391
+
392
+ scopePresence.delete(clientId);
393
+ if (scopePresence.size === 0) {
394
+ this.presenceByScopeKey.delete(scopeKey);
395
+ }
396
+
397
+ // Notify other clients in this scope
398
+ this.broadcastPresenceEvent(scopeKey, {
399
+ action: 'leave',
400
+ scopeKey,
401
+ clientId,
402
+ actorId: entry.actorId,
403
+ });
404
+
405
+ // Callback for cross-instance broadcasting
406
+ this.onPresenceChange?.({
407
+ action: 'leave',
408
+ scopeKey,
409
+ clientId,
410
+ actorId: entry.actorId,
411
+ });
412
+
413
+ return true;
414
+ }
415
+
416
+ /**
417
+ * Update presence metadata for a client in a scope.
418
+ * Used to update what entity a user is viewing/editing.
419
+ */
420
+ updatePresenceMetadata(
421
+ clientId: string,
422
+ scopeKey: string,
423
+ metadata: Record<string, unknown>
424
+ ): boolean {
425
+ if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
426
+ const scopePresence = this.presenceByScopeKey.get(scopeKey);
427
+ if (!scopePresence) return false;
428
+
429
+ const entry = scopePresence.get(clientId);
430
+ if (!entry) return false;
431
+
432
+ entry.metadata = metadata;
433
+
434
+ // Notify other clients in this scope
435
+ this.broadcastPresenceEvent(scopeKey, {
436
+ action: 'update',
437
+ scopeKey,
438
+ clientId,
439
+ actorId: entry.actorId,
440
+ metadata,
441
+ });
442
+
443
+ // Callback for cross-instance broadcasting
444
+ this.onPresenceChange?.({
445
+ action: 'update',
446
+ scopeKey,
447
+ clientId,
448
+ actorId: entry.actorId,
449
+ metadata,
450
+ });
451
+
452
+ return true;
453
+ }
454
+
455
+ /**
456
+ * Get presence entries for a scope key.
457
+ */
458
+ getPresence(scopeKey: string): PresenceEntry[] {
459
+ const scopePresence = this.presenceByScopeKey.get(scopeKey);
460
+ if (!scopePresence) return [];
461
+ return Array.from(scopePresence.values());
462
+ }
463
+
464
+ /**
465
+ * Get presence for multiple scopes.
466
+ */
467
+ getPresenceMultiple(scopeKeys: string[]): Record<string, PresenceEntry[]> {
468
+ const result: Record<string, PresenceEntry[]> = {};
469
+ for (const scopeKey of scopeKeys) {
470
+ result[scopeKey] = this.getPresence(scopeKey);
471
+ }
472
+ return result;
473
+ }
474
+
475
+ /**
476
+ * Send current presence snapshot to a specific connection.
477
+ * Called when a client first subscribes to presence for a scope.
478
+ */
479
+ sendPresenceSnapshot(
480
+ connection: WebSocketConnection,
481
+ scopeKey: string
482
+ ): void {
483
+ const entries = this.getPresence(scopeKey);
484
+ connection.sendPresence({
485
+ action: 'snapshot',
486
+ scopeKey,
487
+ entries,
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Handle a presence event from another server instance (via broadcaster).
493
+ * Updates local state and notifies local clients.
494
+ */
495
+ handleRemotePresenceEvent(event: {
496
+ action: 'join' | 'leave' | 'update';
497
+ scopeKey: string;
498
+ clientId: string;
499
+ actorId: string;
500
+ metadata?: Record<string, unknown>;
501
+ }): void {
502
+ const { action, scopeKey, clientId, actorId, metadata } = event;
503
+
504
+ // Update local presence state
505
+ let scopePresence = this.presenceByScopeKey.get(scopeKey);
506
+
507
+ switch (action) {
508
+ case 'join': {
509
+ if (!scopePresence) {
510
+ scopePresence = new Map();
511
+ this.presenceByScopeKey.set(scopeKey, scopePresence);
512
+ }
513
+ scopePresence.set(clientId, {
514
+ clientId,
515
+ actorId,
516
+ joinedAt: Date.now(),
517
+ metadata,
518
+ });
519
+ break;
520
+ }
521
+ case 'leave': {
522
+ if (scopePresence) {
523
+ scopePresence.delete(clientId);
524
+ if (scopePresence.size === 0) {
525
+ this.presenceByScopeKey.delete(scopeKey);
526
+ }
527
+ }
528
+ break;
529
+ }
530
+ case 'update': {
531
+ if (scopePresence) {
532
+ const entry = scopePresence.get(clientId);
533
+ if (entry) {
534
+ entry.metadata = metadata;
535
+ }
536
+ }
537
+ break;
538
+ }
539
+ }
540
+
541
+ // Notify local clients
542
+ this.broadcastPresenceEvent(scopeKey, event);
543
+ }
544
+
545
+ /**
546
+ * Broadcast a presence event to all clients subscribed to a scope key.
547
+ */
548
+ private broadcastPresenceEvent(
549
+ scopeKey: string,
550
+ event: {
551
+ action: 'join' | 'leave' | 'update';
552
+ scopeKey: string;
553
+ clientId?: string;
554
+ actorId?: string;
555
+ metadata?: Record<string, unknown>;
556
+ }
557
+ ): void {
558
+ const conns = this.connectionsByScopeKey.get(scopeKey);
559
+ if (!conns) return;
560
+
561
+ for (const conn of conns) {
562
+ if (!conn.isOpen) continue;
563
+ // Don't send presence events back to the source client
564
+ if (event.clientId && conn.clientId === event.clientId) continue;
565
+ conn.sendPresence(event);
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Clean up presence when a client fully disconnects (all connections closed).
571
+ */
572
+ private cleanupClientPresence(clientId: string): void {
573
+ // Find all scopes this client has presence in
574
+ for (const [scopeKey, scopePresence] of this.presenceByScopeKey) {
575
+ const entry = scopePresence.get(clientId);
576
+ if (!entry) continue;
577
+
578
+ scopePresence.delete(clientId);
579
+ if (scopePresence.size === 0) {
580
+ this.presenceByScopeKey.delete(scopeKey);
581
+ }
582
+
583
+ // Notify other clients
584
+ this.broadcastPresenceEvent(scopeKey, {
585
+ action: 'leave',
586
+ scopeKey,
587
+ clientId,
588
+ actorId: entry.actorId,
589
+ });
590
+
591
+ // Callback for cross-instance broadcasting
592
+ this.onPresenceChange?.({
593
+ action: 'leave',
594
+ scopeKey,
595
+ clientId,
596
+ actorId: entry.actorId,
597
+ });
598
+ }
599
+ }
600
+
601
+ // =========================================================================
602
+ // Sync Notifications
603
+ // =========================================================================
604
+
605
+ /**
606
+ * Notify clients that new data is available for the given scopes.
607
+ * Dedupes connections that match multiple scopes.
608
+ */
609
+ /**
610
+ * Maximum serialized size (bytes) for inline WS change delivery.
611
+ * Larger payloads fall back to cursor-only notification.
612
+ */
613
+ private static readonly WS_INLINE_MAX_BYTES = 64 * 1024;
614
+
615
+ notifyScopeKeys(
616
+ scopeKeys: string[],
617
+ cursor: number,
618
+ opts?: { excludeClientIds?: string[]; changes?: unknown[] }
619
+ ): void {
620
+ const exclude = new Set(opts?.excludeClientIds ?? []);
621
+ const targets = new Set<WebSocketConnection>();
622
+
623
+ for (const k of scopeKeys) {
624
+ const conns = this.connectionsByScopeKey.get(k);
625
+ if (!conns) continue;
626
+ for (const conn of conns) targets.add(conn);
627
+ }
628
+
629
+ // Size guard: only deliver inline changes if under threshold
630
+ let inlineChanges: unknown[] | undefined;
631
+ if (opts?.changes && opts.changes.length > 0) {
632
+ const serialized = JSON.stringify(opts.changes);
633
+ if (serialized.length <= WebSocketConnectionManager.WS_INLINE_MAX_BYTES) {
634
+ inlineChanges = opts.changes;
635
+ }
636
+ }
637
+
638
+ for (const conn of targets) {
639
+ if (!conn.isOpen) continue;
640
+ if (exclude.has(conn.clientId)) continue;
641
+ if (inlineChanges) {
642
+ conn.sendSync(cursor, inlineChanges);
643
+ } else {
644
+ conn.sendSync(cursor);
645
+ }
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Get the number of active connections for a client.
651
+ */
652
+ getConnectionCount(clientId: string): number {
653
+ return this.connectionsByClientId.get(clientId)?.size ?? 0;
654
+ }
655
+
656
+ /**
657
+ * Get the current transport path for a client if connected.
658
+ */
659
+ getClientTransportPath(clientId: string): 'direct' | 'relay' | null {
660
+ const conns = this.connectionsByClientId.get(clientId);
661
+ if (!conns || conns.size === 0) {
662
+ return null;
663
+ }
664
+
665
+ for (const conn of conns) {
666
+ if (conn.transportPath === 'relay') {
667
+ return 'relay';
668
+ }
669
+ }
670
+
671
+ return 'direct';
672
+ }
673
+
674
+ /**
675
+ * Get total number of active connections.
676
+ */
677
+ getTotalConnections(): number {
678
+ let total = 0;
679
+ for (const conns of this.connectionsByClientId.values()) {
680
+ total += conns.size;
681
+ }
682
+ return total;
683
+ }
684
+
685
+ /**
686
+ * Close all connections for a client.
687
+ */
688
+ closeClientConnections(clientId: string): void {
689
+ const conns = this.connectionsByClientId.get(clientId);
690
+ if (!conns) return;
691
+
692
+ const scopeKeys =
693
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
694
+ for (const k of scopeKeys) {
695
+ const set = this.connectionsByScopeKey.get(k);
696
+ if (!set) continue;
697
+ for (const conn of conns) set.delete(conn);
698
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
699
+ }
700
+
701
+ for (const conn of conns) {
702
+ conn.close(1000, 'client closed');
703
+ }
704
+ this.connectionsByClientId.delete(clientId);
705
+ this.scopeKeysByClientId.delete(clientId);
706
+ this.ensureHeartbeat();
707
+ }
708
+
709
+ /**
710
+ * Close all connections.
711
+ */
712
+ closeAll(): void {
713
+ for (const conns of this.connectionsByClientId.values()) {
714
+ for (const conn of conns) {
715
+ conn.close(1000, 'server shutdown');
716
+ }
717
+ }
718
+ this.connectionsByClientId.clear();
719
+ this.scopeKeysByClientId.clear();
720
+ this.connectionsByScopeKey.clear();
721
+ this.presenceByScopeKey.clear();
722
+ this.ensureHeartbeat();
723
+ }
724
+
725
+ private ensureHeartbeat(): void {
726
+ if (this.heartbeatIntervalMs <= 0) return;
727
+
728
+ const total = this.getTotalConnections();
729
+
730
+ if (total === 0) {
731
+ if (this.heartbeatTimer) {
732
+ clearInterval(this.heartbeatTimer);
733
+ this.heartbeatTimer = null;
734
+ }
735
+ return;
736
+ }
737
+
738
+ if (this.heartbeatTimer) return;
739
+
740
+ this.heartbeatTimer = setInterval(() => {
741
+ this.sendHeartbeats();
742
+ }, this.heartbeatIntervalMs);
743
+ }
744
+
745
+ private sendHeartbeats(): void {
746
+ const closed: WebSocketConnection[] = [];
747
+
748
+ for (const conns of this.connectionsByClientId.values()) {
749
+ for (const conn of conns) {
750
+ if (!conn.isOpen) {
751
+ closed.push(conn);
752
+ continue;
753
+ }
754
+ conn.sendHeartbeat();
755
+ }
756
+ }
757
+
758
+ for (const conn of closed) {
759
+ this.unregister(conn);
760
+ }
761
+
762
+ // Might have removed last connection.
763
+ this.ensureHeartbeat();
764
+ }
765
+
766
+ private unregister(connection: WebSocketConnection): void {
767
+ const clientId = connection.clientId;
768
+
769
+ const scopeKeys =
770
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
771
+ for (const k of scopeKeys) {
772
+ const set = this.connectionsByScopeKey.get(k);
773
+ if (!set) continue;
774
+ set.delete(connection);
775
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
776
+ }
777
+
778
+ const conns = this.connectionsByClientId.get(clientId);
779
+ if (!conns) return;
780
+ conns.delete(connection);
781
+ if (conns.size > 0) return;
782
+
783
+ // Client fully disconnected - clean up presence
784
+ this.cleanupClientPresence(clientId);
785
+
786
+ this.connectionsByClientId.delete(clientId);
787
+ this.scopeKeysByClientId.delete(clientId);
788
+ }
789
+ }