@syncular/server-hono 0.0.6-125 → 0.0.6-135
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/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +799 -1200
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/live-auth.d.ts +7 -0
- package/dist/console/live-auth.d.ts.map +1 -0
- package/dist/console/live-auth.js +35 -0
- package/dist/console/live-auth.js.map +1 -0
- package/dist/console/routes.d.ts +5 -1
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +39 -191
- package/dist/console/routes.js.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +86 -4
- package/dist/routes.js.map +1 -1
- package/dist/ws.d.ts +5 -12
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +30 -202
- package/dist/ws.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/create-server.test.ts +26 -0
- package/src/__tests__/sync-maintenance.test.ts +257 -0
- package/src/console/gateway.ts +985 -1591
- package/src/console/live-auth.ts +39 -0
- package/src/console/routes.ts +59 -211
- package/src/routes.ts +102 -4
- package/src/ws.ts +44 -213
package/src/routes.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
type CompactOptions,
|
|
34
34
|
createServerHandlerCollection,
|
|
35
35
|
InvalidSubscriptionScopeError,
|
|
36
|
+
maybeCompactChanges,
|
|
37
|
+
maybePruneSync,
|
|
36
38
|
type PruneOptions,
|
|
37
39
|
type PullResult,
|
|
38
40
|
pull,
|
|
@@ -127,7 +129,7 @@ export interface SyncRoutesConfigWithRateLimit {
|
|
|
127
129
|
requestPayloadSnapshots?: {
|
|
128
130
|
/**
|
|
129
131
|
* Enable payload snapshot storage in `sync_request_payloads`.
|
|
130
|
-
* Default:
|
|
132
|
+
* Default: false (opt-in).
|
|
131
133
|
*/
|
|
132
134
|
enabled?: boolean;
|
|
133
135
|
/**
|
|
@@ -496,14 +498,28 @@ export function createSyncRoutes<
|
|
|
496
498
|
const maxPullLimitSnapshotRows = config.maxPullLimitSnapshotRows ?? 5000;
|
|
497
499
|
const maxPullMaxSnapshotPages = config.maxPullMaxSnapshotPages ?? 10;
|
|
498
500
|
const maxOperationsPerPush = config.maxOperationsPerPush ?? 200;
|
|
501
|
+
const requestPayloadSnapshots = config.requestPayloadSnapshots;
|
|
502
|
+
const requestPayloadSnapshotsEnabled =
|
|
503
|
+
requestPayloadSnapshots?.enabled ??
|
|
504
|
+
requestPayloadSnapshots?.maxBytes !== undefined;
|
|
505
|
+
const pruneConfig = config.prune;
|
|
506
|
+
const compactConfig = config.compact;
|
|
507
|
+
const pruneMinIntervalMs = readPositiveInteger(
|
|
508
|
+
pruneConfig?.minIntervalMs,
|
|
509
|
+
5 * 60 * 1000
|
|
510
|
+
);
|
|
511
|
+
const compactMinIntervalMs = readPositiveInteger(
|
|
512
|
+
compactConfig?.minIntervalMs,
|
|
513
|
+
30 * 60 * 1000
|
|
514
|
+
);
|
|
515
|
+
const compactOptions = compactConfig?.options;
|
|
499
516
|
const consoleLiveEmitter = options.consoleLiveEmitter;
|
|
500
517
|
const shouldEmitConsoleLiveEvents = consoleLiveEmitter !== undefined;
|
|
501
518
|
const shouldRecordRequestEvents = shouldEmitConsoleLiveEvents;
|
|
502
519
|
const shouldCaptureRequestPayloadSnapshots =
|
|
503
|
-
shouldRecordRequestEvents &&
|
|
504
|
-
config.requestPayloadSnapshots?.enabled !== false;
|
|
520
|
+
shouldRecordRequestEvents && requestPayloadSnapshotsEnabled;
|
|
505
521
|
const requestPayloadSnapshotMaxBytes = readPositiveInteger(
|
|
506
|
-
|
|
522
|
+
requestPayloadSnapshots?.maxBytes,
|
|
507
523
|
DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES
|
|
508
524
|
);
|
|
509
525
|
const consoleSchemaReadyBase = shouldRecordRequestEvents
|
|
@@ -565,6 +581,76 @@ export function createSyncRoutes<
|
|
|
565
581
|
logSyncEvent(event);
|
|
566
582
|
};
|
|
567
583
|
|
|
584
|
+
if (compactConfig && !compactOptions) {
|
|
585
|
+
logSyncEvent({
|
|
586
|
+
event: 'sync.compact_auto_disabled',
|
|
587
|
+
reason: 'missing_options',
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const triggerAutoMaintenance = (ctx: {
|
|
592
|
+
actorId: string;
|
|
593
|
+
clientId: string;
|
|
594
|
+
partitionId: string;
|
|
595
|
+
}): void => {
|
|
596
|
+
if (!pruneConfig && !compactConfig) return;
|
|
597
|
+
|
|
598
|
+
void (async () => {
|
|
599
|
+
if (pruneConfig) {
|
|
600
|
+
try {
|
|
601
|
+
const deleted = await maybePruneSync(options.db, {
|
|
602
|
+
minIntervalMs: pruneMinIntervalMs,
|
|
603
|
+
options: pruneConfig.options,
|
|
604
|
+
});
|
|
605
|
+
if (deleted > 0) {
|
|
606
|
+
logSyncEvent({
|
|
607
|
+
event: 'sync.prune_auto',
|
|
608
|
+
userId: ctx.actorId,
|
|
609
|
+
clientId: ctx.clientId,
|
|
610
|
+
partitionId: ctx.partitionId,
|
|
611
|
+
deletedCount: deleted,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
logAsyncFailureOnce('sync.prune_auto_failed', {
|
|
616
|
+
event: 'sync.prune_auto_failed',
|
|
617
|
+
userId: ctx.actorId,
|
|
618
|
+
clientId: ctx.clientId,
|
|
619
|
+
partitionId: ctx.partitionId,
|
|
620
|
+
error: error instanceof Error ? error.message : String(error),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (compactConfig && compactOptions) {
|
|
626
|
+
try {
|
|
627
|
+
const deleted = await maybeCompactChanges(options.db, {
|
|
628
|
+
dialect: options.dialect,
|
|
629
|
+
minIntervalMs: compactMinIntervalMs,
|
|
630
|
+
options: compactOptions,
|
|
631
|
+
});
|
|
632
|
+
if (deleted > 0) {
|
|
633
|
+
logSyncEvent({
|
|
634
|
+
event: 'sync.compact_auto',
|
|
635
|
+
userId: ctx.actorId,
|
|
636
|
+
clientId: ctx.clientId,
|
|
637
|
+
partitionId: ctx.partitionId,
|
|
638
|
+
deletedCount: deleted,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
} catch (error) {
|
|
642
|
+
logAsyncFailureOnce('sync.compact_auto_failed', {
|
|
643
|
+
event: 'sync.compact_auto_failed',
|
|
644
|
+
userId: ctx.actorId,
|
|
645
|
+
clientId: ctx.clientId,
|
|
646
|
+
partitionId: ctx.partitionId,
|
|
647
|
+
error: error instanceof Error ? error.message : String(error),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
})();
|
|
652
|
+
};
|
|
653
|
+
|
|
568
654
|
if (wsConnectionManager && realtimeBroadcaster) {
|
|
569
655
|
const unsubscribe = realtimeBroadcaster.subscribe(
|
|
570
656
|
(event: SyncRealtimeEvent) => {
|
|
@@ -1182,6 +1268,12 @@ export function createSyncRoutes<
|
|
|
1182
1268
|
pullResponse = pullResult.response;
|
|
1183
1269
|
}
|
|
1184
1270
|
|
|
1271
|
+
triggerAutoMaintenance({
|
|
1272
|
+
actorId: auth.actorId,
|
|
1273
|
+
clientId,
|
|
1274
|
+
partitionId,
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1185
1277
|
return c.json(
|
|
1186
1278
|
{
|
|
1187
1279
|
ok: true as const,
|
|
@@ -1864,6 +1956,12 @@ export function createSyncRoutes<
|
|
|
1864
1956
|
}));
|
|
1865
1957
|
}
|
|
1866
1958
|
|
|
1959
|
+
triggerAutoMaintenance({
|
|
1960
|
+
actorId,
|
|
1961
|
+
clientId,
|
|
1962
|
+
partitionId,
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1867
1965
|
conn.sendPushResponse({
|
|
1868
1966
|
requestId,
|
|
1869
1967
|
ok: pushed.response.ok,
|
package/src/ws.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Also supports presence tracking for collaborative features.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { RealtimeConnectionRegistry } from '@syncular/core';
|
|
8
9
|
import type { WSContext } from 'hono/ws';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -196,9 +197,7 @@ export function createWebSocketConnection(
|
|
|
196
197
|
* Scope-key based notifications and presence tracking.
|
|
197
198
|
*/
|
|
198
199
|
export class WebSocketConnectionManager {
|
|
199
|
-
private
|
|
200
|
-
private scopeKeysByClientId = new Map<string, Set<string>>();
|
|
201
|
-
private connectionsByScopeKey = new Map<string, Set<WebSocketConnection>>();
|
|
200
|
+
private readonly registry: RealtimeConnectionRegistry<WebSocketConnection>;
|
|
202
201
|
|
|
203
202
|
/**
|
|
204
203
|
* In-memory presence tracking by scope key.
|
|
@@ -217,15 +216,17 @@ export class WebSocketConnectionManager {
|
|
|
217
216
|
metadata?: Record<string, unknown>;
|
|
218
217
|
}) => void;
|
|
219
218
|
|
|
220
|
-
private heartbeatIntervalMs: number;
|
|
221
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
222
|
-
|
|
223
219
|
constructor(options?: {
|
|
224
220
|
heartbeatIntervalMs?: number;
|
|
225
221
|
onPresenceChange?: WebSocketConnectionManager['onPresenceChange'];
|
|
226
222
|
}) {
|
|
227
|
-
this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
|
|
228
223
|
this.onPresenceChange = options?.onPresenceChange;
|
|
224
|
+
this.registry = new RealtimeConnectionRegistry({
|
|
225
|
+
heartbeatIntervalMs: options?.heartbeatIntervalMs,
|
|
226
|
+
onClientDisconnected: (clientId) => {
|
|
227
|
+
this.cleanupClientPresence(clientId);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
/**
|
|
@@ -236,35 +237,7 @@ export class WebSocketConnectionManager {
|
|
|
236
237
|
connection: WebSocketConnection,
|
|
237
238
|
initialScopeKeys: string[] = []
|
|
238
239
|
): () => void {
|
|
239
|
-
|
|
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
|
-
};
|
|
240
|
+
return this.registry.register(connection, initialScopeKeys);
|
|
268
241
|
}
|
|
269
242
|
|
|
270
243
|
/**
|
|
@@ -272,52 +245,14 @@ export class WebSocketConnectionManager {
|
|
|
272
245
|
* If the client has no active connections, this is a no-op.
|
|
273
246
|
*/
|
|
274
247
|
updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
|
|
275
|
-
|
|
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
|
-
}
|
|
248
|
+
this.registry.updateClientScopeKeys(clientId, scopeKeys);
|
|
312
249
|
}
|
|
313
250
|
|
|
314
251
|
/**
|
|
315
252
|
* Check whether a client is currently authorized/subscribed for a scope key.
|
|
316
253
|
*/
|
|
317
254
|
isClientSubscribedToScopeKey(clientId: string, scopeKey: string): boolean {
|
|
318
|
-
|
|
319
|
-
if (!scopeKeys || scopeKeys.size === 0) return false;
|
|
320
|
-
return scopeKeys.has(scopeKey);
|
|
255
|
+
return this.registry.isClientSubscribedToScopeKey(clientId, scopeKey);
|
|
321
256
|
}
|
|
322
257
|
|
|
323
258
|
// =========================================================================
|
|
@@ -333,7 +268,7 @@ export class WebSocketConnectionManager {
|
|
|
333
268
|
scopeKey: string,
|
|
334
269
|
metadata?: Record<string, unknown>
|
|
335
270
|
): boolean {
|
|
336
|
-
const conns = this.
|
|
271
|
+
const conns = this.registry.getConnectionsForClient(clientId);
|
|
337
272
|
if (!conns || conns.size === 0) return false;
|
|
338
273
|
if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
|
|
339
274
|
|
|
@@ -555,15 +490,15 @@ export class WebSocketConnectionManager {
|
|
|
555
490
|
metadata?: Record<string, unknown>;
|
|
556
491
|
}
|
|
557
492
|
): void {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
493
|
+
this.registry.forEachConnectionInScopeKeys(
|
|
494
|
+
[scopeKey],
|
|
495
|
+
(conn) => {
|
|
496
|
+
conn.sendPresence(event);
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
excludeClientIds: event.clientId ? [event.clientId] : undefined,
|
|
500
|
+
}
|
|
501
|
+
);
|
|
567
502
|
}
|
|
568
503
|
|
|
569
504
|
/**
|
|
@@ -602,30 +537,21 @@ export class WebSocketConnectionManager {
|
|
|
602
537
|
// Sync Notifications
|
|
603
538
|
// =========================================================================
|
|
604
539
|
|
|
605
|
-
/**
|
|
606
|
-
* Notify clients that new data is available for the given scopes.
|
|
607
|
-
* Dedupes connections that match multiple scopes.
|
|
608
|
-
*/
|
|
609
540
|
/**
|
|
610
541
|
* Maximum serialized size (bytes) for inline WS change delivery.
|
|
611
542
|
* Larger payloads fall back to cursor-only notification.
|
|
612
543
|
*/
|
|
613
544
|
private static readonly WS_INLINE_MAX_BYTES = 64 * 1024;
|
|
614
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Notify clients that new data is available for the given scopes.
|
|
548
|
+
* Dedupes connections that match multiple scopes.
|
|
549
|
+
*/
|
|
615
550
|
notifyScopeKeys(
|
|
616
551
|
scopeKeys: string[],
|
|
617
552
|
cursor: number,
|
|
618
553
|
opts?: { excludeClientIds?: string[]; changes?: unknown[] }
|
|
619
554
|
): 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
555
|
// Size guard: only deliver inline changes if under threshold
|
|
630
556
|
let inlineChanges: unknown[] | undefined;
|
|
631
557
|
if (opts?.changes && opts.changes.length > 0) {
|
|
@@ -635,15 +561,17 @@ export class WebSocketConnectionManager {
|
|
|
635
561
|
}
|
|
636
562
|
}
|
|
637
563
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
564
|
+
this.registry.forEachConnectionInScopeKeys(
|
|
565
|
+
scopeKeys,
|
|
566
|
+
(conn) => {
|
|
567
|
+
if (inlineChanges) {
|
|
568
|
+
conn.sendSync(cursor, inlineChanges);
|
|
569
|
+
} else {
|
|
570
|
+
conn.sendSync(cursor);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
{ excludeClientIds: opts?.excludeClientIds }
|
|
574
|
+
);
|
|
647
575
|
}
|
|
648
576
|
|
|
649
577
|
/**
|
|
@@ -651,26 +579,23 @@ export class WebSocketConnectionManager {
|
|
|
651
579
|
* Used for external data changes that affect all clients regardless of scope.
|
|
652
580
|
*/
|
|
653
581
|
notifyAllClients(cursor: number): void {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
conn.sendSync(cursor);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
582
|
+
this.registry.forEachConnection((conn) => {
|
|
583
|
+
conn.sendSync(cursor);
|
|
584
|
+
});
|
|
660
585
|
}
|
|
661
586
|
|
|
662
587
|
/**
|
|
663
588
|
* Get the number of active connections for a client.
|
|
664
589
|
*/
|
|
665
590
|
getConnectionCount(clientId: string): number {
|
|
666
|
-
return this.
|
|
591
|
+
return this.registry.getConnectionCount(clientId);
|
|
667
592
|
}
|
|
668
593
|
|
|
669
594
|
/**
|
|
670
595
|
* Get the current transport path for a client if connected.
|
|
671
596
|
*/
|
|
672
597
|
getClientTransportPath(clientId: string): 'direct' | 'relay' | null {
|
|
673
|
-
const conns = this.
|
|
598
|
+
const conns = this.registry.getConnectionsForClient(clientId);
|
|
674
599
|
if (!conns || conns.size === 0) {
|
|
675
600
|
return null;
|
|
676
601
|
}
|
|
@@ -688,115 +613,21 @@ export class WebSocketConnectionManager {
|
|
|
688
613
|
* Get total number of active connections.
|
|
689
614
|
*/
|
|
690
615
|
getTotalConnections(): number {
|
|
691
|
-
|
|
692
|
-
for (const conns of this.connectionsByClientId.values()) {
|
|
693
|
-
total += conns.size;
|
|
694
|
-
}
|
|
695
|
-
return total;
|
|
616
|
+
return this.registry.getTotalConnections();
|
|
696
617
|
}
|
|
697
618
|
|
|
698
619
|
/**
|
|
699
620
|
* Close all connections for a client.
|
|
700
621
|
*/
|
|
701
622
|
closeClientConnections(clientId: string): void {
|
|
702
|
-
|
|
703
|
-
if (!conns) return;
|
|
704
|
-
|
|
705
|
-
const scopeKeys =
|
|
706
|
-
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
707
|
-
for (const k of scopeKeys) {
|
|
708
|
-
const set = this.connectionsByScopeKey.get(k);
|
|
709
|
-
if (!set) continue;
|
|
710
|
-
for (const conn of conns) set.delete(conn);
|
|
711
|
-
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
for (const conn of conns) {
|
|
715
|
-
conn.close(1000, 'client closed');
|
|
716
|
-
}
|
|
717
|
-
this.connectionsByClientId.delete(clientId);
|
|
718
|
-
this.scopeKeysByClientId.delete(clientId);
|
|
719
|
-
this.ensureHeartbeat();
|
|
623
|
+
this.registry.closeClientConnections(clientId, 1000, 'client closed');
|
|
720
624
|
}
|
|
721
625
|
|
|
722
626
|
/**
|
|
723
627
|
* Close all connections.
|
|
724
628
|
*/
|
|
725
629
|
closeAll(): void {
|
|
726
|
-
|
|
727
|
-
for (const conn of conns) {
|
|
728
|
-
conn.close(1000, 'server shutdown');
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
this.connectionsByClientId.clear();
|
|
732
|
-
this.scopeKeysByClientId.clear();
|
|
733
|
-
this.connectionsByScopeKey.clear();
|
|
630
|
+
this.registry.closeAll(1000, 'server shutdown');
|
|
734
631
|
this.presenceByScopeKey.clear();
|
|
735
|
-
this.ensureHeartbeat();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
private ensureHeartbeat(): void {
|
|
739
|
-
if (this.heartbeatIntervalMs <= 0) return;
|
|
740
|
-
|
|
741
|
-
const total = this.getTotalConnections();
|
|
742
|
-
|
|
743
|
-
if (total === 0) {
|
|
744
|
-
if (this.heartbeatTimer) {
|
|
745
|
-
clearInterval(this.heartbeatTimer);
|
|
746
|
-
this.heartbeatTimer = null;
|
|
747
|
-
}
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
if (this.heartbeatTimer) return;
|
|
752
|
-
|
|
753
|
-
this.heartbeatTimer = setInterval(() => {
|
|
754
|
-
this.sendHeartbeats();
|
|
755
|
-
}, this.heartbeatIntervalMs);
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
private sendHeartbeats(): void {
|
|
759
|
-
const closed: WebSocketConnection[] = [];
|
|
760
|
-
|
|
761
|
-
for (const conns of this.connectionsByClientId.values()) {
|
|
762
|
-
for (const conn of conns) {
|
|
763
|
-
if (!conn.isOpen) {
|
|
764
|
-
closed.push(conn);
|
|
765
|
-
continue;
|
|
766
|
-
}
|
|
767
|
-
conn.sendHeartbeat();
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
for (const conn of closed) {
|
|
772
|
-
this.unregister(conn);
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Might have removed last connection.
|
|
776
|
-
this.ensureHeartbeat();
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
private unregister(connection: WebSocketConnection): void {
|
|
780
|
-
const clientId = connection.clientId;
|
|
781
|
-
|
|
782
|
-
const scopeKeys =
|
|
783
|
-
this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
|
|
784
|
-
for (const k of scopeKeys) {
|
|
785
|
-
const set = this.connectionsByScopeKey.get(k);
|
|
786
|
-
if (!set) continue;
|
|
787
|
-
set.delete(connection);
|
|
788
|
-
if (set.size === 0) this.connectionsByScopeKey.delete(k);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const conns = this.connectionsByClientId.get(clientId);
|
|
792
|
-
if (!conns) return;
|
|
793
|
-
conns.delete(connection);
|
|
794
|
-
if (conns.size > 0) return;
|
|
795
|
-
|
|
796
|
-
// Client fully disconnected - clean up presence
|
|
797
|
-
this.cleanupClientPresence(clientId);
|
|
798
|
-
|
|
799
|
-
this.connectionsByClientId.delete(clientId);
|
|
800
|
-
this.scopeKeysByClientId.delete(clientId);
|
|
801
632
|
}
|
|
802
633
|
}
|