@steve31415/baselib 3.52.0 → 3.53.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.
@@ -3,6 +3,7 @@ import type { UpgradeWebSocket } from 'hono/ws';
3
3
  import type pg from 'pg';
4
4
  import { type RequireIdentityOptions } from '../auth.js';
5
5
  import { type Logger } from '../log.js';
6
+ import { type SocketAgeCapOptions } from '../socket-age.js';
6
7
  import type { Grants } from '../s2s.js';
7
8
  import { type DbBroadcastBridgeOptions } from '../sync/db-bridge.js';
8
9
  /** The hub database's one table (the hub repo's first migration carries the
@@ -28,7 +29,7 @@ export declare const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
28
29
  /** The largest frame a browser may send (its frames are `connect` and `ping`). */
29
30
  export declare const HUB_SOCKET_MAX_PAYLOAD = 4096;
30
31
  /** A socket that has sent nothing (clients ping every 30 s) for this long is
31
- * closed: a half-dead socket would otherwise hold a slot for an hour. */
32
+ * closed: a half-dead socket would otherwise hold a slot until the age cap. */
32
33
  export declare const HUB_SOCKET_IDLE_MS = 180000;
33
34
  export interface HubOptions {
34
35
  pool: pg.Pool;
@@ -68,6 +69,8 @@ export interface HubOptions {
68
69
  idleMs: number;
69
70
  sweepMs: number;
70
71
  };
72
+ /** Tests: the socket age cap (default 300 s, then 5 s to complete the close). */
73
+ socketAge?: Pick<SocketAgeCapOptions, 'maxAgeMs' | 'graceMs'>;
71
74
  }
72
75
  export interface Hub {
73
76
  /** Start the cross-copy bridge. Call before the server accepts
@@ -24,12 +24,14 @@
24
24
  // caller, for the dark-run curl); answers on a
25
25
  // `work` hint, else 204 after `wait` seconds
26
26
  // GET /api/ws?app=<app> browser WebSocket, fleet cookie, Origin
27
- // must be that app's page
27
+ // must be that app's page; closed (1012) at
28
+ // 300 s of age, clients roll at ~270 s
28
29
  import { createHash } from 'node:crypto';
29
30
  import { createMiddleware } from 'hono/factory';
30
31
  import { requireIdentity } from '../auth.js';
31
32
  import { forwardedClientIp } from '../http.js';
32
33
  import { serializeError } from '../log.js';
34
+ import { capSocketAge } from '../socket-age.js';
33
35
  import { DbBroadcastBridge } from '../sync/db-bridge.js';
34
36
  import { checkHint, isAppName } from './types.js';
35
37
  /** The hub database's one table (the hub repo's first migration carries the
@@ -65,7 +67,7 @@ export const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
65
67
  /** The largest frame a browser may send (its frames are `connect` and `ping`). */
66
68
  export const HUB_SOCKET_MAX_PAYLOAD = 4096;
67
69
  /** A socket that has sent nothing (clients ping every 30 s) for this long is
68
- * closed: a half-dead socket would otherwise hold a slot for an hour. */
70
+ * closed: a half-dead socket would otherwise hold a slot until the age cap. */
69
71
  export const HUB_SOCKET_IDLE_MS = 180_000;
70
72
  const HUB_SOCKET_SWEEP_MS = 30_000;
71
73
  const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
@@ -381,7 +383,12 @@ export function createHub(app, opts) {
381
383
  return c.body(null, 400); // no one reads it
382
384
  await next();
383
385
  });
384
- app.get('/api/ws', originCheck, socketIdentity, opts.upgradeWebSocket((c) => {
386
+ app.get('/api/ws', originCheck, socketIdentity,
387
+ // Every socket closes (1012) at 300 s of age, like the apps' own sockets:
388
+ // a socket on a retired hub copy holds that copy, and its slot under
389
+ // --max-instances, until it ends (PW docs/OPERATIONS.md, "Socket
390
+ // lifetime"). SIGTERM comes only after the last socket ends.
391
+ capSocketAge(opts.upgradeWebSocket, { logger, ...opts.socketAge })((c) => {
385
392
  const appName = c.req.query('app');
386
393
  const refusal = refusals.get(c);
387
394
  const id = c.var.identity;
@@ -0,0 +1,22 @@
1
+ import type { UpgradeWebSocket } from 'hono/ws';
2
+ import type { Logger } from './log-core.js';
3
+ /** Sockets older than this are closed by the server. */
4
+ export declare const SOCKET_MAX_AGE_MS = 300000;
5
+ /** After the close frame, how long the client gets to answer it before the
6
+ * connection is terminated. A frozen tab answers nothing, and the open TCP
7
+ * connection is what holds the instance. */
8
+ export declare const SOCKET_CLOSE_GRACE_MS = 5000;
9
+ /** "Service restart": clients treat it as a planned roll and reconnect at once. */
10
+ export declare const CLOSE_SERVICE_RESTART = 1012;
11
+ export interface SocketAgeCapOptions {
12
+ logger: Pick<Logger, 'info'>;
13
+ /** Tests. */
14
+ maxAgeMs?: number;
15
+ graceMs?: number;
16
+ }
17
+ /** `upgradeWebSocket`, with every socket it opens closed at `maxAgeMs` of age
18
+ * (1012), and terminated `graceMs` later if the client hasn't completed the
19
+ * close. One INFO line per cut: `socket age cap`, with the socket's path, its
20
+ * age and whether it had to be terminated. A socket the route's own onOpen
21
+ * closed (a refused identity) is left alone. */
22
+ export declare function capSocketAge(upgradeWebSocket: UpgradeWebSocket, options: SocketAgeCapOptions): UpgradeWebSocket;
@@ -0,0 +1,92 @@
1
+ // A cap on WebSocket age, applied by wrapping a Hono `upgradeWebSocket`.
2
+ //
3
+ // An open socket is an open request, and Cloud Run keeps a retired instance
4
+ // alive (and counted against --max-instances) until its last request ends.
5
+ // On 2026-11-02 two such instances held radar's only slot and every request
6
+ // got 429 for 14 minutes (PW docs/LESSONS.md). Clients replace their sockets
7
+ // at about 270 s; this cut closes whatever didn't (a frozen tab, a sleeping
8
+ // laptop, an old client) at 300 s of age, in code rather than through the
9
+ // service's --timeout, which must stay long enough for long background
10
+ // routes (PW docs/OPERATIONS.md, "Socket lifetime").
11
+ /** Sockets older than this are closed by the server. */
12
+ export const SOCKET_MAX_AGE_MS = 300_000;
13
+ /** After the close frame, how long the client gets to answer it before the
14
+ * connection is terminated. A frozen tab answers nothing, and the open TCP
15
+ * connection is what holds the instance. */
16
+ export const SOCKET_CLOSE_GRACE_MS = 5_000;
17
+ /** "Service restart": clients treat it as a planned roll and reconnect at once. */
18
+ export const CLOSE_SERVICE_RESTART = 1012;
19
+ const WS_OPEN = 1;
20
+ /** `upgradeWebSocket`, with every socket it opens closed at `maxAgeMs` of age
21
+ * (1012), and terminated `graceMs` later if the client hasn't completed the
22
+ * close. One INFO line per cut: `socket age cap`, with the socket's path, its
23
+ * age and whether it had to be terminated. A socket the route's own onOpen
24
+ * closed (a refused identity) is left alone. */
25
+ export function capSocketAge(upgradeWebSocket, options) {
26
+ const maxAgeMs = options.maxAgeMs ?? SOCKET_MAX_AGE_MS;
27
+ const graceMs = options.graceMs ?? SOCKET_CLOSE_GRACE_MS;
28
+ const cap = (events, c) => withAgeCap(events, c.req.path, maxAgeMs, graceMs, options.logger);
29
+ // Both of UpgradeWebSocket's call forms: (createEvents, options) returning
30
+ // middleware, and (c, events, options) upgrading at once.
31
+ function wrapped(first, second, third) {
32
+ if (typeof first === 'function') {
33
+ const createEvents = first;
34
+ return upgradeWebSocket(async (c) => cap(await createEvents(c), c), second);
35
+ }
36
+ return upgradeWebSocket(first, cap(second, first), third);
37
+ }
38
+ return wrapped;
39
+ }
40
+ function withAgeCap(events, path, maxAgeMs, graceMs, logger) {
41
+ let openedAt = 0;
42
+ let ageTimer = null;
43
+ let graceTimer = null;
44
+ let cutAt = null;
45
+ let logged = false;
46
+ const clearTimers = () => {
47
+ if (ageTimer !== null)
48
+ clearTimeout(ageTimer);
49
+ if (graceTimer !== null)
50
+ clearTimeout(graceTimer);
51
+ ageTimer = graceTimer = null;
52
+ };
53
+ const logCut = (terminated) => {
54
+ if (cutAt === null || logged)
55
+ return;
56
+ logged = true;
57
+ logger.info('socket age cap', { path, ageMs: cutAt - openedAt, terminated });
58
+ };
59
+ const cut = (ws) => {
60
+ ageTimer = null;
61
+ cutAt = Date.now();
62
+ try {
63
+ ws.close(CLOSE_SERVICE_RESTART, 'socket age limit');
64
+ }
65
+ catch {
66
+ // Already closing: its close handler logs the cut.
67
+ }
68
+ graceTimer = setTimeout(() => {
69
+ graceTimer = null;
70
+ const raw = ws.raw;
71
+ raw?.terminate?.();
72
+ logCut(true);
73
+ }, graceMs);
74
+ graceTimer.unref?.();
75
+ };
76
+ return {
77
+ ...events,
78
+ onOpen: (evt, ws) => {
79
+ events.onOpen?.(evt, ws);
80
+ if (ws.readyState !== WS_OPEN)
81
+ return;
82
+ openedAt = Date.now();
83
+ ageTimer = setTimeout(() => cut(ws), maxAgeMs);
84
+ ageTimer.unref?.();
85
+ },
86
+ onClose: (evt, ws) => {
87
+ clearTimers();
88
+ logCut(false);
89
+ events.onClose?.(evt, ws);
90
+ },
91
+ };
92
+ }
@@ -101,6 +101,10 @@ export interface SyncClientOptions<B, C extends EventBody, S extends EventBody,
101
101
  onBuildObserved?(buildId: string): void;
102
102
  onBuildChanged?(): void;
103
103
  markPerformance?(name: 'outbox-start' | 'outbox-ready' | 'bootstrap-start' | 'bootstrap-response' | 'store-ready'): void;
104
+ /** The socket's planned life: it is replaced (make-before-break) this long
105
+ * after it opens. Default 270 s ±10%, jittered per socket so tabs restored
106
+ * together don't roll together; an explicit value is used exactly. Servers
107
+ * cut sockets at 300 s (PW docs/OPERATIONS.md, "Socket lifetime"). */
104
108
  reconnectBeforeMs?: number;
105
109
  /** While hidden and disconnected, one HTTP catch-up per this interval
106
110
  * (default 5 min). */
@@ -173,7 +177,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
173
177
  private connectReplyTimer;
174
178
  private socketCreatedHidden;
175
179
  private socketOpenedAt;
176
- private plannedClose;
180
+ private candidate;
177
181
  private outageCloseCode;
178
182
  private outageInfo;
179
183
  private lastContactAt;
@@ -242,6 +246,23 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
242
246
  * connects at once. */
243
247
  private replaceSocket;
244
248
  private connect;
249
+ /** The live socket's message and close handling; a rollover's replacement
250
+ * gets it when it takes over. */
251
+ private attachLiveHandlers;
252
+ private startLiveTimers;
253
+ private armRollover;
254
+ private rolloverDelayMs;
255
+ /** Make-before-break, as notes' document sockets do: a replacement connects
256
+ * while the live socket keeps serving (sends, echoes, state), and takes over
257
+ * when the server answers its connect. Nothing the user sees changes, even
258
+ * with an edit unsent. The replacement's handshake re-runs the server's
259
+ * identity check; a 4403 halts as on any socket. If it fails, the live socket
260
+ * keeps serving until the server's age cap closes it (1012). */
261
+ private startRollover;
262
+ private promoteCandidate;
263
+ /** Abandons a rollover's replacement; `failure` logs why and tries again
264
+ * after another socket life (the server's age cap usually comes first). */
265
+ private dropCandidate;
245
266
  /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
246
267
  * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
247
268
  private enterDormant;
@@ -308,6 +329,7 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
308
329
  private scheduleResyncFailureRetry;
309
330
  private cancelResyncFailureRetry;
310
331
  private sendConnect;
332
+ private connectMessage;
311
333
  private sendRaw;
312
334
  private trySend;
313
335
  private flushViaBeacon;
@@ -5,7 +5,16 @@ const PING_INTERVAL_MS = 30_000;
5
5
  // times in 25 minutes on 10-19, but none of those handshakes ever reached
6
6
  // the load balancer, so a longer limit would only have delayed the retry.
7
7
  const CONNECT_WATCHDOG_MS = 10_000;
8
- const DEFAULT_RECONNECT_BEFORE_MS = 55 * 60 * 1000;
8
+ /** A socket that holds a retired Cloud Run instance keeps that instance
9
+ * alive and counted against --max-instances (radar, 2026-11-02: 14 minutes of
10
+ * 429s). Rolling at 270 s, under the servers' 300 s cut, bounds that hold. */
11
+ const DEFAULT_RECONNECT_BEFORE_MS = 270_000;
12
+ /** ± this fraction of the default rollover, per socket: every fleet app shares
13
+ * the load balancer's IP, and Chrome queues handshakes per IP. */
14
+ const ROLLOVER_JITTER = 0.1;
15
+ /** "Service restart": the server's planned close (its socket age cap, or a
16
+ * hub copy stopping). Handled like our own rollover. */
17
+ const CLOSE_SERVICE_RESTART = 1012;
9
18
  const FAILURE_TIMEOUT_MS = 60_000;
10
19
  // ERROR only after a sustained foreground outage. The fleet is single-user
11
20
  // and triage runs daily, so detection latency is cheap; short client-network
@@ -44,6 +53,14 @@ const SLOW_HANDSHAKE_MS = 3_000;
44
53
  const STALL_MIN_MS = 1_000;
45
54
  const SOCKET_CONNECTING = 0;
46
55
  const SOCKET_OPEN = 1;
56
+ function messageType(raw) {
57
+ try {
58
+ return JSON.parse(raw)?.type;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
47
64
  class LifecycleAbort {
48
65
  reason;
49
66
  constructor(reason) {
@@ -112,7 +129,7 @@ export class SyncClient {
112
129
  connectReplyTimer = null;
113
130
  socketCreatedHidden = false;
114
131
  socketOpenedAt = null;
115
- plannedClose = null;
132
+ candidate = null;
116
133
  outageCloseCode = 1006;
117
134
  outageInfo = null;
118
135
  lastContactAt = null;
@@ -280,6 +297,7 @@ export class SyncClient {
280
297
  for (const remove of this.removeListeners.splice(0))
281
298
  remove();
282
299
  this.clearAllTimers();
300
+ this.dropCandidate();
283
301
  const socket = this.socket;
284
302
  this.socket = null;
285
303
  if (socket) {
@@ -555,6 +573,7 @@ export class SyncClient {
555
573
  const previous = this.socket;
556
574
  this.socket = null;
557
575
  this.clearConnectionTimers();
576
+ this.dropCandidate();
558
577
  if (previous) {
559
578
  previous.close();
560
579
  this.detachSocket(previous);
@@ -614,13 +633,13 @@ export class SyncClient {
614
633
  }
615
634
  this.cancelBackgroundTick();
616
635
  this.sendConnect();
617
- this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
618
- this.rolloverTimer = this.setTimer(() => {
619
- this.options.logger.info('ws rollover', {});
620
- this.plannedClose = socket;
621
- socket.close();
622
- }, this.options.reconnectBeforeMs ?? DEFAULT_RECONNECT_BEFORE_MS);
636
+ this.startLiveTimers();
623
637
  };
638
+ this.attachLiveHandlers(socket, () => killedByWatchdog);
639
+ }
640
+ /** The live socket's message and close handling; a rollover's replacement
641
+ * gets it when it takes over. */
642
+ attachLiveHandlers(socket, killedByWatchdog) {
624
643
  socket.onmessage = (event) => {
625
644
  if (this.socket !== socket)
626
645
  return;
@@ -638,7 +657,7 @@ export class SyncClient {
638
657
  if (this.halted || this.disposed)
639
658
  return;
640
659
  const socketAgeMs = this.now() - this.socketCreatedAt;
641
- if (this.socketOpenedAt === null && !killedByWatchdog && socketAgeMs >= SLOW_HANDSHAKE_MS) {
660
+ if (this.socketOpenedAt === null && !killedByWatchdog() && socketAgeMs >= SLOW_HANDSHAKE_MS) {
642
661
  this.options.logger.info('sync connect failed', {
643
662
  socketAgeMs,
644
663
  closeCode: event.code,
@@ -651,20 +670,26 @@ export class SyncClient {
651
670
  this.halt();
652
671
  return;
653
672
  }
654
- const planned = this.plannedClose === socket;
655
- this.plannedClose = null;
656
673
  const openForMs = this.socketOpenedAt === null ? null : Math.max(0, this.now() - this.socketOpenedAt);
674
+ // Ended before its replacement took over: start afresh rather than wait
675
+ // on a handshake begun for a handover.
676
+ const rolling = this.candidate !== null;
677
+ this.dropCandidate();
678
+ if (event.code === CLOSE_SERVICE_RESTART) {
679
+ // The server's planned close (its age cap: a tab whose own rollover
680
+ // didn't run, such as a frozen one): reconnect at once, as after our
681
+ // own rollover, with no outage and no ladder step. A failure of that
682
+ // reconnect is an ordinary outage.
683
+ this.options.logger.info('ws closed by server', { closeCode: event.code, openForMs, rolling });
684
+ this.connectNow();
685
+ return;
686
+ }
657
687
  this.startOutage(event.code, event.wasClean === true, openForMs);
658
688
  if (!this.isHidden()) {
659
689
  this.noteReturnFailure(event.code);
660
690
  this.scheduleReconnect();
661
691
  return;
662
692
  }
663
- if (planned) {
664
- // Our own rollover: this spell's sockets have lasted; reconnect once.
665
- this.connectNow();
666
- return;
667
- }
668
693
  const openedAt = this.socketOpenedAt;
669
694
  if (this.socketCreatedHidden && (openedAt === null || this.now() - openedAt < HIDDEN_SOCKET_MIN_LIFE_MS)) {
670
695
  this.hiddenSocketFailed = true;
@@ -672,6 +697,145 @@ export class SyncClient {
672
697
  this.enterDormant();
673
698
  };
674
699
  }
700
+ startLiveTimers() {
701
+ this.pingTimer = this.setRepeating(() => this.sendRaw({ type: 'ping' }), PING_INTERVAL_MS);
702
+ this.armRollover();
703
+ }
704
+ armRollover() {
705
+ this.rolloverTimer = this.setTimer(() => {
706
+ this.rolloverTimer = null;
707
+ this.startRollover();
708
+ }, this.rolloverDelayMs());
709
+ }
710
+ rolloverDelayMs() {
711
+ if (this.options.reconnectBeforeMs !== undefined)
712
+ return this.options.reconnectBeforeMs;
713
+ const factor = 1 - ROLLOVER_JITTER + 2 * ROLLOVER_JITTER * Math.random();
714
+ return Math.round(DEFAULT_RECONNECT_BEFORE_MS * factor);
715
+ }
716
+ /** Make-before-break, as notes' document sockets do: a replacement connects
717
+ * while the live socket keeps serving (sends, echoes, state), and takes over
718
+ * when the server answers its connect. Nothing the user sees changes, even
719
+ * with an edit unsent. The replacement's handshake re-runs the server's
720
+ * identity check; a 4403 halts as on any socket. If it fails, the live socket
721
+ * keeps serving until the server's age cap closes it (1012). */
722
+ startRollover() {
723
+ if (this.halted || this.disposed || this.candidate !== null || this.socket?.readyState !== SOCKET_OPEN)
724
+ return;
725
+ let socket;
726
+ try {
727
+ socket = this.options.runtime.createSocket(this.options.socketUrl());
728
+ }
729
+ catch (error) {
730
+ this.options.logger.warn('socket creation failed', { error });
731
+ return;
732
+ }
733
+ const candidate = {
734
+ socket,
735
+ createdAt: this.now(),
736
+ createdHidden: this.isHidden(),
737
+ openedAt: null,
738
+ watchdog: null,
739
+ early: [],
740
+ };
741
+ this.candidate = candidate;
742
+ candidate.watchdog = this.setTimer(() => {
743
+ candidate.watchdog = null;
744
+ if (this.candidate === candidate)
745
+ this.dropCandidate({ reason: 'timeout' });
746
+ }, CONNECT_WATCHDOG_MS);
747
+ socket.onopen = () => {
748
+ if (this.candidate !== candidate) {
749
+ socket.close();
750
+ return;
751
+ }
752
+ if (candidate.watchdog !== null)
753
+ this.options.runtime.clearTimeout(candidate.watchdog);
754
+ candidate.watchdog = null;
755
+ candidate.openedAt = this.now();
756
+ try {
757
+ socket.send(JSON.stringify(this.connectMessage()));
758
+ }
759
+ catch {
760
+ // Its close handler abandons it.
761
+ }
762
+ };
763
+ socket.onmessage = (event) => {
764
+ if (this.candidate !== candidate)
765
+ return;
766
+ const raw = String(event.data);
767
+ const type = messageType(raw);
768
+ if (type !== 'catchup' && type !== 'reload') {
769
+ candidate.early.push(raw);
770
+ return;
771
+ }
772
+ this.promoteCandidate(candidate);
773
+ this.lastContactAt = this.now();
774
+ this.handleMessage(raw);
775
+ for (const early of candidate.early) {
776
+ if (this.socket !== socket)
777
+ return;
778
+ this.handleMessage(early);
779
+ }
780
+ };
781
+ socket.onerror = () => { };
782
+ socket.onclose = (event) => {
783
+ if (this.candidate !== candidate)
784
+ return;
785
+ if (event.code === CLOSE_NO_USER) {
786
+ this.dropCandidate();
787
+ this.options.authState.markBlocked();
788
+ this.halt();
789
+ return;
790
+ }
791
+ this.dropCandidate({ closeCode: event.code });
792
+ };
793
+ }
794
+ promoteCandidate(candidate) {
795
+ this.candidate = null;
796
+ if (candidate.watchdog !== null)
797
+ this.options.runtime.clearTimeout(candidate.watchdog);
798
+ const now = this.now();
799
+ const previous = this.socket;
800
+ const previousOpenedAt = this.socketOpenedAt;
801
+ this.clearConnectionTimers();
802
+ if (previous) {
803
+ this.detachSocket(previous);
804
+ previous.close();
805
+ }
806
+ this.socket = candidate.socket;
807
+ this.socketCreatedAt = candidate.createdAt;
808
+ this.socketCreatedHidden = candidate.createdHidden;
809
+ this.socketOpenedAt = candidate.openedAt;
810
+ candidate.socket.onopen = null;
811
+ this.attachLiveHandlers(candidate.socket, () => false);
812
+ this.startLiveTimers();
813
+ this.options.logger.info('ws rollover', {
814
+ openForMs: previousOpenedAt === null ? null : Math.max(0, now - previousOpenedAt),
815
+ handoverMs: Math.max(0, now - candidate.createdAt),
816
+ });
817
+ }
818
+ /** Abandons a rollover's replacement; `failure` logs why and tries again
819
+ * after another socket life (the server's age cap usually comes first). */
820
+ dropCandidate(failure) {
821
+ const candidate = this.candidate;
822
+ if (candidate === null)
823
+ return;
824
+ this.candidate = null;
825
+ if (candidate.watchdog !== null)
826
+ this.options.runtime.clearTimeout(candidate.watchdog);
827
+ this.detachSocket(candidate.socket);
828
+ candidate.socket.close();
829
+ if (failure) {
830
+ this.options.logger.info('ws rollover failed', {
831
+ ...failure,
832
+ opened: candidate.openedAt !== null,
833
+ socketAgeMs: Math.max(0, this.now() - candidate.createdAt),
834
+ });
835
+ if (this.socket?.readyState === SOCKET_OPEN && this.rolloverTimer === null)
836
+ this.armRollover();
837
+ }
838
+ }
675
839
  /** Hidden and disconnected: no WebSocket retries; HTTP catch-ups on a slow
676
840
  * cadence instead (docs/SYNC-BACKGROUND-PLAN.md). */
677
841
  enterDormant() {
@@ -1510,11 +1674,10 @@ export class SyncClient {
1510
1674
  this.resyncFailureTimer = null;
1511
1675
  }
1512
1676
  sendConnect() {
1513
- this.sendRaw({
1514
- type: 'connect',
1515
- clientGuid: this.options.clientGuid,
1516
- lastSeq: this.options.store.currentSeq(),
1517
- });
1677
+ this.sendRaw(this.connectMessage());
1678
+ }
1679
+ connectMessage() {
1680
+ return { type: 'connect', clientGuid: this.options.clientGuid, lastSeq: this.options.store.currentSeq() };
1518
1681
  }
1519
1682
  sendRaw(message) {
1520
1683
  if (this.socket?.readyState !== SOCKET_OPEN)
@@ -1605,6 +1768,7 @@ export class SyncClient {
1605
1768
  this.abortFetches();
1606
1769
  this.firstLoadRetry?.stop();
1607
1770
  this.clearAllTimers();
1771
+ this.dropCandidate();
1608
1772
  const socket = this.socket;
1609
1773
  this.socket = null;
1610
1774
  if (socket) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.52.0",
3
+ "version": "3.53.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -69,6 +69,10 @@
69
69
  "types": "./dist/hub/index.d.ts",
70
70
  "default": "./dist/hub/index.js"
71
71
  },
72
+ "./socket-age": {
73
+ "types": "./dist/socket-age.d.ts",
74
+ "default": "./dist/socket-age.js"
75
+ },
72
76
  "./app-update": {
73
77
  "types": "./dist/app-update/index.d.ts",
74
78
  "default": "./dist/app-update/index.js"