@syncular/server-hono 0.0.6-171 → 0.0.6-177

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/src/ws.ts CHANGED
@@ -18,6 +18,14 @@ export interface PresenceEntry {
18
18
  metadata?: Record<string, unknown>;
19
19
  }
20
20
 
21
+ export function createWebSocketConnectionOwnerKey(args: {
22
+ partitionId: string;
23
+ actorId: string;
24
+ clientId: string;
25
+ }): string {
26
+ return JSON.stringify([args.partitionId, args.actorId, args.clientId]);
27
+ }
28
+
21
29
  /**
22
30
  * Push response data sent back to the client over WS
23
31
  */
@@ -49,6 +57,7 @@ export interface SyncWebSocketEvent {
49
57
  presence?: {
50
58
  action: 'join' | 'leave' | 'update' | 'snapshot';
51
59
  scopeKey: string;
60
+ ownerKey?: string;
52
61
  clientId?: string;
53
62
  actorId?: string;
54
63
  metadata?: Record<string, unknown>;
@@ -81,6 +90,7 @@ export interface WebSocketConnection {
81
90
  sendPresence(data: {
82
91
  action: 'join' | 'leave' | 'update' | 'snapshot';
83
92
  scopeKey: string;
93
+ ownerKey?: string;
84
94
  clientId?: string;
85
95
  actorId?: string;
86
96
  metadata?: Record<string, unknown>;
@@ -98,6 +108,8 @@ export interface WebSocketConnection {
98
108
  actorId: string;
99
109
  /** Client/device identifier for this connection */
100
110
  clientId: string;
111
+ /** Stable owner identity for this connection */
112
+ ownerKey: string;
101
113
  /** Transport path used by this connection. */
102
114
  transportPath: 'direct' | 'relay';
103
115
  }
@@ -113,7 +125,12 @@ function safeSend(ws: WSContext, message: string): boolean {
113
125
 
114
126
  export function createWebSocketConnection(
115
127
  ws: WSContext,
116
- args: { actorId: string; clientId: string; transportPath: 'direct' | 'relay' }
128
+ args: {
129
+ actorId: string;
130
+ clientId: string;
131
+ ownerKey: string;
132
+ transportPath: 'direct' | 'relay';
133
+ }
117
134
  ): WebSocketConnection {
118
135
  let closed = false;
119
136
 
@@ -124,6 +141,7 @@ export function createWebSocketConnection(
124
141
  },
125
142
  actorId: args.actorId,
126
143
  clientId: args.clientId,
144
+ ownerKey: args.ownerKey,
127
145
  transportPath: args.transportPath,
128
146
  sendSync(
129
147
  cursor: number,
@@ -158,6 +176,7 @@ export function createWebSocketConnection(
158
176
  sendPresence(data: {
159
177
  action: 'join' | 'leave' | 'update' | 'snapshot';
160
178
  scopeKey: string;
179
+ ownerKey?: string;
161
180
  clientId?: string;
162
181
  actorId?: string;
163
182
  metadata?: Record<string, unknown>;
@@ -219,7 +238,7 @@ export class WebSocketConnectionManager {
219
238
 
220
239
  /**
221
240
  * In-memory presence tracking by scope key.
222
- * Map<scopeKey, Map<clientId, PresenceEntry>>
241
+ * Map<scopeKey, Map<ownerKey, PresenceEntry>>
223
242
  */
224
243
  private presenceByScopeKey = new Map<string, Map<string, PresenceEntry>>();
225
244
 
@@ -229,6 +248,7 @@ export class WebSocketConnectionManager {
229
248
  onPresenceChange?: (event: {
230
249
  action: 'join' | 'leave' | 'update';
231
250
  scopeKey: string;
251
+ ownerKey: string;
232
252
  clientId: string;
233
253
  actorId: string;
234
254
  metadata?: Record<string, unknown>;
@@ -241,8 +261,8 @@ export class WebSocketConnectionManager {
241
261
  this.onPresenceChange = options?.onPresenceChange;
242
262
  this.registry = new RealtimeConnectionRegistry({
243
263
  heartbeatIntervalMs: options?.heartbeatIntervalMs,
244
- onClientDisconnected: (clientId) => {
245
- this.cleanupClientPresence(clientId);
264
+ onOwnerDisconnected: (ownerKey) => {
265
+ this.cleanupOwnerPresence(ownerKey);
246
266
  },
247
267
  });
248
268
  }
@@ -262,15 +282,18 @@ export class WebSocketConnectionManager {
262
282
  * Update the effective scopes for an already-connected client.
263
283
  * If the client has no active connections, this is a no-op.
264
284
  */
265
- updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
266
- this.registry.updateClientScopeKeys(clientId, scopeKeys);
285
+ updateConnectionScopeKeys(ownerKey: string, scopeKeys: string[]): void {
286
+ this.registry.updateOwnerScopeKeys(ownerKey, scopeKeys);
267
287
  }
268
288
 
269
289
  /**
270
290
  * Check whether a client is currently authorized/subscribed for a scope key.
271
291
  */
272
- isClientSubscribedToScopeKey(clientId: string, scopeKey: string): boolean {
273
- return this.registry.isClientSubscribedToScopeKey(clientId, scopeKey);
292
+ isConnectionSubscribedToScopeKey(
293
+ ownerKey: string,
294
+ scopeKey: string
295
+ ): boolean {
296
+ return this.registry.isOwnerSubscribedToScopeKey(ownerKey, scopeKey);
274
297
  }
275
298
 
276
299
  // =========================================================================
@@ -282,20 +305,19 @@ export class WebSocketConnectionManager {
282
305
  * Called when a client wants to be visible to others in a scope.
283
306
  */
284
307
  joinPresence(
285
- clientId: string,
308
+ ownerKey: string,
286
309
  scopeKey: string,
287
310
  metadata?: Record<string, unknown>
288
311
  ): boolean {
289
- const conns = this.registry.getConnectionsForClient(clientId);
312
+ const conns = this.registry.getConnectionsForOwner(ownerKey);
290
313
  if (!conns || conns.size === 0) return false;
291
- if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
314
+ if (!this.isConnectionSubscribedToScopeKey(ownerKey, scopeKey))
315
+ return false;
292
316
 
293
- // Get actorId from first connection
294
317
  const conn = conns.values().next().value;
295
318
  if (!conn) return false;
296
- const actorId = conn.actorId;
319
+ const { actorId, clientId } = conn;
297
320
 
298
- // Add to presence map
299
321
  let scopePresence = this.presenceByScopeKey.get(scopeKey);
300
322
  if (!scopePresence) {
301
323
  scopePresence = new Map();
@@ -308,21 +330,21 @@ export class WebSocketConnectionManager {
308
330
  joinedAt: Date.now(),
309
331
  metadata,
310
332
  };
311
- scopePresence.set(clientId, entry);
333
+ scopePresence.set(ownerKey, entry);
312
334
 
313
- // Notify other clients in this scope
314
335
  this.broadcastPresenceEvent(scopeKey, {
315
336
  action: 'join',
316
337
  scopeKey,
338
+ ownerKey,
317
339
  clientId,
318
340
  actorId,
319
341
  metadata,
320
342
  });
321
343
 
322
- // Callback for cross-instance broadcasting
323
344
  this.onPresenceChange?.({
324
345
  action: 'join',
325
346
  scopeKey,
347
+ ownerKey,
326
348
  clientId,
327
349
  actorId,
328
350
  metadata,
@@ -335,31 +357,31 @@ export class WebSocketConnectionManager {
335
357
  * Leave presence for a scope key.
336
358
  * Called when a client no longer wants to be visible in a scope.
337
359
  */
338
- leavePresence(clientId: string, scopeKey: string): boolean {
360
+ leavePresence(ownerKey: string, scopeKey: string): boolean {
339
361
  const scopePresence = this.presenceByScopeKey.get(scopeKey);
340
362
  if (!scopePresence) return false;
341
363
 
342
- const entry = scopePresence.get(clientId);
364
+ const entry = scopePresence.get(ownerKey);
343
365
  if (!entry) return false;
344
366
 
345
- scopePresence.delete(clientId);
367
+ scopePresence.delete(ownerKey);
346
368
  if (scopePresence.size === 0) {
347
369
  this.presenceByScopeKey.delete(scopeKey);
348
370
  }
349
371
 
350
- // Notify other clients in this scope
351
372
  this.broadcastPresenceEvent(scopeKey, {
352
373
  action: 'leave',
353
374
  scopeKey,
354
- clientId,
375
+ ownerKey,
376
+ clientId: entry.clientId,
355
377
  actorId: entry.actorId,
356
378
  });
357
379
 
358
- // Callback for cross-instance broadcasting
359
380
  this.onPresenceChange?.({
360
381
  action: 'leave',
361
382
  scopeKey,
362
- clientId,
383
+ ownerKey,
384
+ clientId: entry.clientId,
363
385
  actorId: entry.actorId,
364
386
  });
365
387
 
@@ -371,33 +393,34 @@ export class WebSocketConnectionManager {
371
393
  * Used to update what entity a user is viewing/editing.
372
394
  */
373
395
  updatePresenceMetadata(
374
- clientId: string,
396
+ ownerKey: string,
375
397
  scopeKey: string,
376
398
  metadata: Record<string, unknown>
377
399
  ): boolean {
378
- if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
400
+ if (!this.isConnectionSubscribedToScopeKey(ownerKey, scopeKey))
401
+ return false;
379
402
  const scopePresence = this.presenceByScopeKey.get(scopeKey);
380
403
  if (!scopePresence) return false;
381
404
 
382
- const entry = scopePresence.get(clientId);
405
+ const entry = scopePresence.get(ownerKey);
383
406
  if (!entry) return false;
384
407
 
385
408
  entry.metadata = metadata;
386
409
 
387
- // Notify other clients in this scope
388
410
  this.broadcastPresenceEvent(scopeKey, {
389
411
  action: 'update',
390
412
  scopeKey,
391
- clientId,
413
+ ownerKey,
414
+ clientId: entry.clientId,
392
415
  actorId: entry.actorId,
393
416
  metadata,
394
417
  });
395
418
 
396
- // Callback for cross-instance broadcasting
397
419
  this.onPresenceChange?.({
398
420
  action: 'update',
399
421
  scopeKey,
400
- clientId,
422
+ ownerKey,
423
+ clientId: entry.clientId,
401
424
  actorId: entry.actorId,
402
425
  metadata,
403
426
  });
@@ -448,13 +471,24 @@ export class WebSocketConnectionManager {
448
471
  handleRemotePresenceEvent(event: {
449
472
  action: 'join' | 'leave' | 'update';
450
473
  scopeKey: string;
474
+ ownerKey?: string;
451
475
  clientId: string;
452
476
  actorId: string;
453
477
  metadata?: Record<string, unknown>;
454
478
  }): void {
455
- const { action, scopeKey, clientId, actorId, metadata } = event;
479
+ const {
480
+ action,
481
+ scopeKey,
482
+ clientId,
483
+ actorId,
484
+ metadata,
485
+ ownerKey = createWebSocketConnectionOwnerKey({
486
+ partitionId: scopeKey.split(':', 1)[0] ?? 'default',
487
+ actorId,
488
+ clientId,
489
+ }),
490
+ } = event;
456
491
 
457
- // Update local presence state
458
492
  let scopePresence = this.presenceByScopeKey.get(scopeKey);
459
493
 
460
494
  switch (action) {
@@ -463,7 +497,7 @@ export class WebSocketConnectionManager {
463
497
  scopePresence = new Map();
464
498
  this.presenceByScopeKey.set(scopeKey, scopePresence);
465
499
  }
466
- scopePresence.set(clientId, {
500
+ scopePresence.set(ownerKey, {
467
501
  clientId,
468
502
  actorId,
469
503
  joinedAt: Date.now(),
@@ -473,7 +507,7 @@ export class WebSocketConnectionManager {
473
507
  }
474
508
  case 'leave': {
475
509
  if (scopePresence) {
476
- scopePresence.delete(clientId);
510
+ scopePresence.delete(ownerKey);
477
511
  if (scopePresence.size === 0) {
478
512
  this.presenceByScopeKey.delete(scopeKey);
479
513
  }
@@ -482,7 +516,7 @@ export class WebSocketConnectionManager {
482
516
  }
483
517
  case 'update': {
484
518
  if (scopePresence) {
485
- const entry = scopePresence.get(clientId);
519
+ const entry = scopePresence.get(ownerKey);
486
520
  if (entry) {
487
521
  entry.metadata = metadata;
488
522
  }
@@ -503,6 +537,7 @@ export class WebSocketConnectionManager {
503
537
  event: {
504
538
  action: 'join' | 'leave' | 'update';
505
539
  scopeKey: string;
540
+ ownerKey?: string;
506
541
  clientId?: string;
507
542
  actorId?: string;
508
543
  metadata?: Record<string, unknown>;
@@ -520,32 +555,31 @@ export class WebSocketConnectionManager {
520
555
  }
521
556
 
522
557
  /**
523
- * Clean up presence when a client fully disconnects (all connections closed).
558
+ * Clean up presence when an owner fully disconnects (all connections closed).
524
559
  */
525
- private cleanupClientPresence(clientId: string): void {
526
- // Find all scopes this client has presence in
560
+ private cleanupOwnerPresence(ownerKey: string): void {
527
561
  for (const [scopeKey, scopePresence] of this.presenceByScopeKey) {
528
- const entry = scopePresence.get(clientId);
562
+ const entry = scopePresence.get(ownerKey);
529
563
  if (!entry) continue;
530
564
 
531
- scopePresence.delete(clientId);
565
+ scopePresence.delete(ownerKey);
532
566
  if (scopePresence.size === 0) {
533
567
  this.presenceByScopeKey.delete(scopeKey);
534
568
  }
535
569
 
536
- // Notify other clients
537
570
  this.broadcastPresenceEvent(scopeKey, {
538
571
  action: 'leave',
539
572
  scopeKey,
540
- clientId,
573
+ ownerKey,
574
+ clientId: entry.clientId,
541
575
  actorId: entry.actorId,
542
576
  });
543
577
 
544
- // Callback for cross-instance broadcasting
545
578
  this.onPresenceChange?.({
546
579
  action: 'leave',
547
580
  scopeKey,
548
- clientId,
581
+ ownerKey,
582
+ clientId: entry.clientId,
549
583
  actorId: entry.actorId,
550
584
  });
551
585
  }
@@ -617,6 +651,13 @@ export class WebSocketConnectionManager {
617
651
  return this.registry.getConnectionCount(clientId);
618
652
  }
619
653
 
654
+ /**
655
+ * Get the number of active connections for one owner identity.
656
+ */
657
+ getScopedConnectionCount(ownerKey: string): number {
658
+ return this.registry.getScopedConnectionCount(ownerKey);
659
+ }
660
+
620
661
  /**
621
662
  * Get the current transport path for a client if connected.
622
663
  */