@glassly/cloud-client 0.1.0-dev.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,826 @@
1
+ /**
2
+ * @fileoverview The live WebSocket: handshake, reconnect, and liveness ping.
3
+ *
4
+ * `Connection` owns the one runtime socket and hides every raw-socket detail
5
+ * from the rest of `cloud.runtime`. It opens the socket, runs the
6
+ * `connection.init` / `connection.ack` handshake, reconnects with exponential
7
+ * backoff plus jitter when the socket drops, and runs a client-driven liveness
8
+ * ping so a half-open socket (one that looks alive but is not) is detected and
9
+ * reconnected. Everything above it sees only validated `CloudToClientMessage`
10
+ * values, never a string off the wire.
11
+ *
12
+ * It never imports a real socket: the platform supplies a `WebSocketLike`
13
+ * factory, so the same code runs on the phone and in a Node/Bun test harness.
14
+ *
15
+ * Reconnect robustness (why the loop is self-sustaining + watchdogged):
16
+ * The reconnect loop used to be driven ONLY by the socket's `onClose` event
17
+ * (`handleClose` -> `scheduleReconnect`), and a scheduled attempt's
18
+ * `connectOnce().catch()` just swallowed the rejection, trusting that a close
19
+ * event would always fire and schedule the next try. That assumption is the
20
+ * bug: a connect attempt can fail WITHOUT ever firing a clean `onClose` -- a
21
+ * transient network/DNS blip mid-handshake, or a `WebSocketLike` transport that
22
+ * emits only `onError` and no `onClose`. When that happens the chain breaks:
23
+ * nothing schedules the next retry, so the client sits SILENTLY disconnected
24
+ * forever until a full app relaunch. We hit this in dev (ADB/Metro flapping
25
+ * wedged the v2 socket; only a cold relaunch recovered it).
26
+ *
27
+ * Three changes close that gap, belt-and-suspenders:
28
+ * 0. A failed initial `open()` now also enters the reconnect loop. Before
29
+ * this, reconnect was robust only AFTER the first successful session had
30
+ * dropped; if the app booted while cloud was down, `open()` rejected and no
31
+ * retry was queued.
32
+ * 1. The scheduled attempt's `.catch()` now reschedules itself, so a failure
33
+ * that did NOT fire `onClose` still queues the next try. `scheduleReconnect`
34
+ * is idempotent (guarded by `reconnectTimer`), so the double call from
35
+ * `onClose` + this catch never stacks two timers.
36
+ * 2. A lightweight watchdog interval revives the loop if some unforeseen path
37
+ * ever leaves us `closed`, not host-closed, with no reconnect pending. Even
38
+ * if reasoning (1) misses a case, the watchdog guarantees we never sit dead.
39
+ *
40
+ * See docs/issues/004-cloud-client/design.md ("src/modules/runtime/connection.ts")
41
+ * and docs/issues/002-cloud-runtime/protocol.md (envelope, handshake, control).
42
+ */
43
+ import {
44
+ PROTOCOL_MAJOR,
45
+ cloudToClientMessage,
46
+ type ConnectionInit,
47
+ type ConnectionAck,
48
+ type ClientToCloudMessage,
49
+ type CloudToClientMessage,
50
+ } from "@glassly/cloud-protocol";
51
+ import type { WebSocketLike } from "../../transports";
52
+ import type { Logger } from "../../logger";
53
+ import { systemTimers, type CloudClientTimers } from "../../timers";
54
+
55
+ /** Connection lifecycle, surfaced to the rest of runtime via `onState`. */
56
+ export type ConnectionState = "connecting" | "open" | "closed";
57
+
58
+ /**
59
+ * The error `open()` rejects with when the cloud answers the handshake with a
60
+ * fatal protocol error instead of an ack.
61
+ *
62
+ * It carries the protocol `code` (for example `AUTH_EXPIRED`) so the caller can
63
+ * branch on the cause without string-matching the message. `cloud.runtime` uses
64
+ * this to recognize `AUTH_EXPIRED` and refresh-then-reopen once.
65
+ */
66
+ export class HandshakeRejectedError extends Error {
67
+ readonly code: string;
68
+
69
+ constructor(code: string) {
70
+ super(`Handshake rejected: ${code}`);
71
+ this.name = "HandshakeRejectedError";
72
+ this.code = code;
73
+ Object.setPrototypeOf(this, new.target.prototype);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * How often the client sends `control.ping`, and how long it waits for the
79
+ * matching `control.pong` before declaring the socket dead.
80
+ *
81
+ * The cloud is passive on liveness (the client owns reconnect), so these are
82
+ * client-side constants. The timeout is shorter than the interval so a missed
83
+ * pong is caught before the next ping goes out, rather than letting two pings
84
+ * stack up against one dead socket.
85
+ */
86
+ const PING_INTERVAL_MS = 15_000;
87
+ const PONG_TIMEOUT_MS = 10_000;
88
+
89
+ /**
90
+ * How long to wait for `connection.ack` after sending `connection.init` before
91
+ * giving up on a handshake. Without this, a socket that opens but never acks
92
+ * (a stalled or misbehaving peer) would leave `open()` pending forever.
93
+ */
94
+ const HANDSHAKE_TIMEOUT_MS = 15_000;
95
+
96
+ /**
97
+ * How often the reconnect watchdog checks that the loop is still alive.
98
+ *
99
+ * This is the belt-and-suspenders backstop: if any path ever leaves the
100
+ * connection `closed` (not host-closed) with no reconnect timer pending, the
101
+ * watchdog notices within one interval and restarts the loop. It is deliberately
102
+ * coarse (slower than a single backoff step) because it is a safety net, not the
103
+ * primary driver -- the catch-reschedule in `scheduleReconnect` handles the
104
+ * common failed-attempt case directly; the watchdog only catches the cases that
105
+ * one somehow misses.
106
+ */
107
+ const RECONNECT_WATCHDOG_MS = 20_000;
108
+
109
+ export interface ConnectionDeps {
110
+ // Factory, not an instance: a fresh socket is opened on every (re)connect.
111
+ ws: (url: string) => WebSocketLike;
112
+ url: string;
113
+ // Resolves the current access token just before each (re)connect, so a token
114
+ // refreshed mid-session is picked up on the next open without re-wiring.
115
+ getToken: () => Promise<string>;
116
+ // Builds the `connection.init` payload (protocol version, platform, audio
117
+ // config, initial subscriptions). Injected so the connection stays unaware of
118
+ // what rides in the handshake beyond the token it stamps in.
119
+ initPayload: () => ConnectionInit;
120
+ reconnect: { baseMs: number; maxMs: number; jitter: boolean };
121
+ timers?: CloudClientTimers;
122
+ // Called when the WebSocket upgrade itself is rejected as unauthorized. That
123
+ // happens before a protocol `error` frame can exist, so runtime's normal
124
+ // AUTH_EXPIRED handshake retry cannot see it. The host uses this to invalidate
125
+ // a cached access token before the next reconnect attempt asks for one.
126
+ onAuthRejected?: () => Promise<void> | void;
127
+ logger: Logger;
128
+ }
129
+
130
+ /**
131
+ * Build an envelope around a payload.
132
+ *
133
+ * Every WebSocket message carries `v: 2` and a millisecond `timestamp`, so this
134
+ * one helper stamps both rather than each call site repeating (and risking
135
+ * drifting on) the envelope shape.
136
+ */
137
+ function envelope<T>(type: string, payload: T): {
138
+ v: typeof PROTOCOL_MAJOR;
139
+ type: string;
140
+ timestamp: number;
141
+ payload: T;
142
+ } {
143
+ return { v: PROTOCOL_MAJOR, type, timestamp: Date.now(), payload };
144
+ }
145
+
146
+ /**
147
+ * Compute the next reconnect delay: exponential backoff capped at `maxMs`, with
148
+ * optional jitter.
149
+ *
150
+ * Jitter (a random fraction of the computed delay) keeps a fleet of phones from
151
+ * reconnecting in lockstep after a shared cloud blip, which would otherwise hit
152
+ * the cloud with a synchronized thundering herd.
153
+ */
154
+ function backoffDelay(
155
+ attempt: number,
156
+ cfg: { baseMs: number; maxMs: number; jitter: boolean },
157
+ ): number {
158
+ const exponential = Math.min(cfg.maxMs, cfg.baseMs * 2 ** attempt);
159
+ if (!cfg.jitter) return exponential;
160
+ // Full jitter: a uniform random point in [0, exponential]. Spreads retries
161
+ // across the whole window instead of clustering at its edge.
162
+ return Math.random() * exponential;
163
+ }
164
+
165
+ function isUnauthorizedUpgrade(reason: string): boolean {
166
+ return /\b401\b|unauthorized/i.test(reason);
167
+ }
168
+
169
+ function isSupersededByNewerSession(reason: string): boolean {
170
+ return /superseded by newer session/i.test(reason);
171
+ }
172
+
173
+ export class Connection {
174
+ private readonly deps: ConnectionDeps;
175
+ private readonly timers: CloudClientTimers;
176
+
177
+ // The current socket. Null between connect attempts and after close, so every
178
+ // access guards on it rather than assuming a live socket.
179
+ private socket: WebSocketLike | null = null;
180
+
181
+ // The last successful handshake result (sessionId, audio coordinates). Held so
182
+ // `cloud.runtime` can read `sessionId` for REST calls and the audio path can
183
+ // read its coordinates, without re-doing the handshake.
184
+ private currentAck: ConnectionAck | null = null;
185
+
186
+ // Handler the rest of runtime registers for validated inbound messages.
187
+ private messageHandler: ((msg: CloudToClientMessage) => void) | null = null;
188
+
189
+ // State subscribers (connecting / open / closed).
190
+ private readonly stateHandlers = new Set<(s: ConnectionState) => void>();
191
+
192
+ // True once `close()` is called by the host, so an incidental socket-close
193
+ // event does NOT trigger a reconnect. Distinguishes "the host asked us to
194
+ // stop" from "the network dropped".
195
+ private closedByHost = false;
196
+
197
+ // True when the cloud closed us with 1012 "superseded by newer session".
198
+ // Another live socket for this user won; reconnecting would just kill it
199
+ // and start a two-client fight. Stays down until the host calls open().
200
+ private replacedByNewerSession = false;
201
+
202
+ // How many consecutive failed (re)connect attempts, used to grow the backoff.
203
+ // Reset to zero on a successful handshake.
204
+ private reconnectAttempt = 0;
205
+
206
+ // The pending reconnect timer, or null when no retry is queued. This single
207
+ // field makes `scheduleReconnect` idempotent: a second call while a timer is
208
+ // already armed is a no-op, so the (intentional) double call from `onClose`
209
+ // and from a failed attempt's catch-reschedule can never stack two timers and
210
+ // double the reconnect rate.
211
+ private reconnectTimer: unknown | null = null;
212
+
213
+ // The watchdog interval (started in open(), cleared in close()). It is the
214
+ // backstop that revives the reconnect loop if it ever stalls: see
215
+ // `tickWatchdog` and the file header for why a stall is possible at all.
216
+ private watchdogTimer: unknown | null = null;
217
+
218
+ // Mirror of the last state pushed through `setState`, so the watchdog can ask
219
+ // "are we currently closed?" without a separate subscription. The state
220
+ // handlers are fire-and-forget notifications; this is the authoritative copy
221
+ // the watchdog reads.
222
+ private currentState: ConnectionState = "closed";
223
+
224
+ // Liveness timers. The interval drives the periodic ping; the pong-wait timer
225
+ // is armed when a ping goes out and disarmed when its pong arrives.
226
+ private pingTimer: unknown | null = null;
227
+ private pongTimer: unknown | null = null;
228
+
229
+ // While `open()` is awaiting `connection.ack`, these settle that promise. They
230
+ // are cleared the moment the handshake resolves, rejects, or times out, so a
231
+ // late ack or error cannot settle an already-finished promise.
232
+ private pendingAck: {
233
+ resolve: (ack: ConnectionAck) => void;
234
+ reject: (err: Error) => void;
235
+ timer: unknown;
236
+ } | null = null;
237
+
238
+ constructor(deps: ConnectionDeps) {
239
+ this.deps = deps;
240
+ this.timers = deps.timers ?? systemTimers;
241
+ }
242
+
243
+ /**
244
+ * The last handshake result, or null before the first successful connect.
245
+ *
246
+ * Read by `cloud.runtime` for the `sessionId` it echoes on REST calls and by
247
+ * the audio path for the UDP coordinates and key.
248
+ */
249
+ get ack(): ConnectionAck | null {
250
+ return this.currentAck;
251
+ }
252
+
253
+ /** True after the cloud closed us as the losing socket for this user. */
254
+ get isReplacedByNewerSession(): boolean {
255
+ return this.replacedByNewerSession;
256
+ }
257
+
258
+ /**
259
+ * Open the socket, run the handshake, and resolve with `connection.ack`.
260
+ *
261
+ * The token goes in BOTH the first-frame `connection.init.payload.token` AND
262
+ * the `?token=` URL parameter: the in-frame token is the real auth path, and
263
+ * the query parameter is a fallback for environments where header or in-frame
264
+ * auth is awkward (the Chrome JS debugger), per the protocol. Rejects if the
265
+ * cloud answers with a fatal error or the handshake times out.
266
+ */
267
+ async open(): Promise<ConnectionAck> {
268
+ // A fresh open() means the host wants the socket up; clear any prior
269
+ // host-close / superseded intent so a later drop reconnects normally.
270
+ this.closedByHost = false;
271
+ this.replacedByNewerSession = false;
272
+
273
+ // A fresh open() is a clean slate: cancel any stale reconnect left over from
274
+ // a previous session, and reset the backoff so we do not start this connect
275
+ // from a long delay inherited from an earlier outage.
276
+ this.clearReconnectTimer();
277
+ this.reconnectAttempt = 0;
278
+
279
+ // Arm the watchdog for the lifetime of this open() session. It is cleared in
280
+ // close(); restarting it here (after stopping any prior one) keeps exactly
281
+ // one interval running even if open() is called more than once.
282
+ this.startWatchdog();
283
+
284
+ return this.connectOnce().catch((err) => {
285
+ if (!this.closedByHost) {
286
+ this.setState("closed");
287
+ this.scheduleReconnect("initial open failed");
288
+ }
289
+ throw err;
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Close for good: stop liveness, mark host-closed so the close event does not
295
+ * reconnect, and tear down the socket. Idempotent.
296
+ */
297
+ close(): void {
298
+ this.closedByHost = true;
299
+ this.replacedByNewerSession = false;
300
+ this.stopLiveness();
301
+ // Cancel any queued reconnect and stop the watchdog: the host wants us down,
302
+ // so neither the loop nor its backstop should bring the socket back up.
303
+ this.clearReconnectTimer();
304
+ this.stopWatchdog();
305
+ this.failPendingAck(new Error("Connection closed by host"));
306
+ this.teardownSocket();
307
+ this.setState("closed");
308
+ }
309
+
310
+ /**
311
+ * Send an enveloped client-to-cloud message.
312
+ *
313
+ * The caller passes the message payload already typed; this stamps the
314
+ * envelope and serializes. A send on a missing socket is dropped with a log
315
+ * rather than throwing, because callers (for example a queued subscription
316
+ * resend) should not have to guard every send against a mid-reconnect gap.
317
+ */
318
+ send(msg: ClientToCloudMessage): void {
319
+ if (!this.socket) {
320
+ this.deps.logger.warn("ws send dropped: socket not open", {
321
+ type: msg.type,
322
+ });
323
+ return;
324
+ }
325
+ this.socket.send(JSON.stringify(msg));
326
+ }
327
+
328
+ /**
329
+ * Send one binary frame on the live WebSocket.
330
+ *
331
+ * Used only for the audio fallback path. A missing socket is a soft drop for
332
+ * the same reason text sends are: audio is continuous and reconnect is owned
333
+ * by the connection state machine.
334
+ */
335
+ sendBinary(bytes: Uint8Array): void {
336
+ if (!this.socket || this.currentState !== "open") {
337
+ this.deps.logger.warn("ws binary send dropped: socket not open");
338
+ return;
339
+ }
340
+ this.socket.sendBinary(bytes);
341
+ }
342
+
343
+ get isOpen(): boolean {
344
+ return this.currentState === "open" && this.socket !== null;
345
+ }
346
+
347
+ /** Register the single handler for validated inbound messages. */
348
+ onMessage(cb: (msg: CloudToClientMessage) => void): void {
349
+ this.messageHandler = cb;
350
+ }
351
+
352
+ /** Subscribe to lifecycle changes. */
353
+ onState(cb: (s: ConnectionState) => void): void {
354
+ this.stateHandlers.add(cb);
355
+ }
356
+
357
+ // --- internals ------------------------------------------------------------
358
+
359
+ /**
360
+ * One full connect attempt: resolve the token, open the socket, wire its
361
+ * callbacks, send `connection.init`, and await `connection.ack`. On a clean
362
+ * handshake it starts liveness and resolves; on a transport error or a fatal
363
+ * protocol error it rejects (the caller, or the reconnect loop, decides what
364
+ * happens next).
365
+ */
366
+ private async connectOnce(): Promise<ConnectionAck> {
367
+ this.setState("connecting");
368
+
369
+ // Close any previous socket FIRST. Replacing `this.socket` without closing
370
+ // leaves the old TCP connection alive: its callbacks are ignored (the
371
+ // `socket !== this.socket` guards below) but the SERVER still sees a live
372
+ // session — a ghost that holds the user's subscription record and rejects
373
+ // the real session's writes as stale-session. One client must never hold
374
+ // two server sessions.
375
+ const previous = this.socket;
376
+ if (previous) {
377
+ this.socket = null;
378
+ try {
379
+ previous.close();
380
+ } catch {
381
+ /* best-effort */
382
+ }
383
+ }
384
+
385
+ const token = await this.deps.getToken();
386
+ const url = this.appendTokenParam(this.deps.url, token);
387
+ const socket = this.deps.ws(url);
388
+ this.socket = socket;
389
+
390
+ // The promise the handshake settles. Wired before the socket can fire any
391
+ // callback so an immediate open/message cannot race ahead of the listener.
392
+ const acked = new Promise<ConnectionAck>((resolve, reject) => {
393
+ const timer = this.timers.setTimeout(() => {
394
+ this.pendingAck = null;
395
+ // Close the half-done socket too: a handshake that times out client-
396
+ // side may still COMPLETE server-side moments later, leaving a ghost
397
+ // session nobody owns.
398
+ try {
399
+ socket.close();
400
+ } catch {
401
+ /* best-effort */
402
+ }
403
+ if (this.socket === socket) {
404
+ this.socket = null;
405
+ }
406
+ reject(new Error("Handshake timed out waiting for connection.ack"));
407
+ }, HANDSHAKE_TIMEOUT_MS);
408
+ this.pendingAck = { resolve, reject, timer };
409
+ });
410
+
411
+ socket.onOpen(() => {
412
+ if (socket !== this.socket) return;
413
+ // The socket is up; send the handshake. The token rides in the payload as
414
+ // the primary auth path (the ?token= parameter above is only a fallback).
415
+ const init = { ...this.deps.initPayload(), token };
416
+ socket.send(JSON.stringify(envelope("connection.init", init)));
417
+ });
418
+
419
+ socket.onMessage((data) => {
420
+ if (socket !== this.socket) return;
421
+ this.handleRawMessage(data);
422
+ });
423
+
424
+ socket.onError((err) => {
425
+ if (socket !== this.socket) return;
426
+ // A transport error is logged but not acted on directly: the socket's own
427
+ // close event (which follows) drives reconnect, so we have one path for
428
+ // "the connection ended" rather than two competing ones. We never log the
429
+ // token or any credential, only that an error occurred.
430
+ this.deps.logger.warn("ws transport error", { url: this.deps.url });
431
+ void err;
432
+ });
433
+
434
+ socket.onClose((info) => this.handleClose(socket, info));
435
+
436
+ return acked;
437
+ }
438
+
439
+ /**
440
+ * Parse, validate, and route one raw inbound frame.
441
+ *
442
+ * Every frame is checked against `cloudToClientMessage` from the shared
443
+ * protocol package before anything acts on it: a frame that does not match
444
+ * (bad JSON, wrong shape, an unknown `type`) is dropped with a log, never
445
+ * crashed on, so a malformed or future message cannot take the session down.
446
+ * A valid frame is dispatched: handshake acks and fatal errors settle a
447
+ * pending `open()`; control pings get an automatic pong; everything else,
448
+ * including pong (which disarms the liveness timer), goes to the message
449
+ * handler.
450
+ */
451
+ private handleRawMessage(data: string): void {
452
+ let parsed: unknown;
453
+ try {
454
+ parsed = JSON.parse(data);
455
+ } catch {
456
+ this.deps.logger.warn("ws dropped: invalid JSON frame");
457
+ return;
458
+ }
459
+
460
+ const result = cloudToClientMessage.safeParse(parsed);
461
+ if (!result.success) {
462
+ // Unknown or malformed type. Non-fatal by the protocol: log and ignore so
463
+ // adding message types stays backward compatible within this major.
464
+ const issue = result.error.issues[0];
465
+ this.deps.logger.warn("ws dropped: frame failed validation", {
466
+ type:
467
+ parsed && typeof parsed === "object" && "type" in parsed
468
+ ? String((parsed as { type?: unknown }).type)
469
+ : "unknown",
470
+ path: issue?.path.join(".") ?? "",
471
+ issue: issue?.message ?? "unknown validation error",
472
+ });
473
+ return;
474
+ }
475
+
476
+ const msg = result.data;
477
+
478
+ // The handshake ack resolves a pending open(). Checked first so the ack is
479
+ // not also forwarded as an ordinary message before the session is ready.
480
+ if (msg.type === "connection.ack") {
481
+ this.handleAck(msg.payload);
482
+ return;
483
+ }
484
+
485
+ // A fatal protocol error during the handshake rejects open(); any error is
486
+ // also forwarded so the rest of runtime can surface it (non-fatal ones keep
487
+ // the connection up).
488
+ if (msg.type === "error") {
489
+ if (msg.payload.fatal && this.pendingAck) {
490
+ this.failPendingAck(new HandshakeRejectedError(msg.payload.code));
491
+ }
492
+ this.messageHandler?.(msg);
493
+ return;
494
+ }
495
+
496
+ // The cloud may ping us; answer immediately so the peer's own liveness check
497
+ // (if any) stays satisfied. This is separate from our client-driven ping.
498
+ if (msg.type === "control.ping") {
499
+ this.sendPong();
500
+ return;
501
+ }
502
+
503
+ // A pong answers our liveness ping: the socket is proven alive, so disarm
504
+ // the pong-wait timer that would otherwise reconnect us.
505
+ if (msg.type === "control.pong") {
506
+ this.clearPongTimer();
507
+ return;
508
+ }
509
+
510
+ // Everything else (transcript, translation, ...) goes up to runtime.
511
+ this.messageHandler?.(msg);
512
+ }
513
+
514
+ /**
515
+ * Record a successful handshake, settle the pending `open()`, flip to the
516
+ * open state, reset the backoff, and start liveness. This is the one place a
517
+ * connection becomes "ready".
518
+ */
519
+ private handleAck(ack: ConnectionAck): void {
520
+ this.currentAck = ack;
521
+ this.reconnectAttempt = 0;
522
+
523
+ if (this.pendingAck) {
524
+ this.timers.clearTimeout(this.pendingAck.timer);
525
+ const { resolve } = this.pendingAck;
526
+ this.pendingAck = null;
527
+ resolve(ack);
528
+ }
529
+
530
+ this.setState("open");
531
+ this.startLiveness();
532
+ this.deps.logger.debug("ws-session-debug handshake ok", {
533
+ sessionId: ack.sessionId,
534
+ });
535
+ }
536
+
537
+ /**
538
+ * Handle a socket close. If the host asked to close, stay closed. Otherwise
539
+ * the network dropped, so reconnect with backoff: this also covers a liveness
540
+ * timeout, which closes the socket on purpose to funnel through this one path.
541
+ */
542
+ private handleClose(
543
+ socket: WebSocketLike,
544
+ info: { code: number; reason: string },
545
+ ): void {
546
+ if (socket !== this.socket) return;
547
+
548
+ this.stopLiveness();
549
+ this.socket = null;
550
+
551
+ // A drop mid-handshake rejects the in-flight open() so its caller is not
552
+ // left hanging until the handshake timeout.
553
+ this.failPendingAck(
554
+ new Error(`Socket closed during handshake: ${info.code}`),
555
+ );
556
+
557
+ if (this.closedByHost) {
558
+ this.setState("closed");
559
+ return;
560
+ }
561
+
562
+ this.setState("closed");
563
+
564
+ const reason = info.reason || `code ${info.code}`;
565
+ if (isSupersededByNewerSession(reason)) {
566
+ // Newest-wins on the server. Coming back up here would supersede the
567
+ // winner, which then reconnects, forever. Stay down until open().
568
+ this.replacedByNewerSession = true;
569
+ this.clearReconnectTimer();
570
+ this.stopWatchdog();
571
+ this.deps.logger.info("ws-session-debug staying down after supersede", {
572
+ code: info.code,
573
+ reason,
574
+ sessionId: this.currentAck?.sessionId ?? null,
575
+ reconnectAttempt: this.reconnectAttempt,
576
+ });
577
+ return;
578
+ }
579
+ if (isUnauthorizedUpgrade(reason)) {
580
+ void Promise.resolve(this.deps.onAuthRejected?.())
581
+ .catch((err) => {
582
+ this.deps.logger.warn("ws auth refresh after unauthorized upgrade failed", {
583
+ error: err instanceof Error ? err.message : String(err),
584
+ });
585
+ })
586
+ .finally(() => {
587
+ if (!this.closedByHost) {
588
+ this.scheduleReconnect(reason);
589
+ }
590
+ });
591
+ return;
592
+ }
593
+
594
+ this.scheduleReconnect(reason);
595
+ }
596
+
597
+ /**
598
+ * Wait out the backoff, then try again. Each failed attempt grows the delay
599
+ * (capped, jittered). The loop is self-sustaining: a failed attempt schedules
600
+ * the NEXT one from its own catch, so it keeps trying until the socket comes
601
+ * back or the host calls `close()`.
602
+ *
603
+ * Idempotent by design. `handleClose` calls this when a socket drops, and a
604
+ * failed attempt's catch (below) calls it too; the `reconnectTimer` guard
605
+ * means whichever fires first wins and the other is a no-op, so we never stack
606
+ * two timers and double the reconnect rate.
607
+ */
608
+ private scheduleReconnect(reason: string): void {
609
+ // Already a retry queued -> nothing to do. This is the guard that makes the
610
+ // double call from onClose + catch-reschedule (and the watchdog) harmless.
611
+ if (this.reconnectTimer !== null) return;
612
+ if (this.replacedByNewerSession) {
613
+ this.deps.logger.debug("ws-session-debug skip reconnect; replaced by newer session", {
614
+ reason,
615
+ });
616
+ return;
617
+ }
618
+
619
+ const delay = backoffDelay(this.reconnectAttempt, this.deps.reconnect);
620
+ this.reconnectAttempt += 1;
621
+ this.deps.logger.info("ws scheduling reconnect", {
622
+ attempt: this.reconnectAttempt,
623
+ delayMs: Math.round(delay),
624
+ reason,
625
+ });
626
+
627
+ this.reconnectTimer = this.timers.setTimeout(() => {
628
+ // Null the timer first so this slot is free again: a connect that fails
629
+ // below must be able to schedule the next retry, and the watchdog must see
630
+ // "no reconnect pending" while the attempt is in flight.
631
+ this.reconnectTimer = null;
632
+
633
+ // The host may have called close() during the wait; honor it.
634
+ if (this.closedByHost) return;
635
+
636
+ this.connectOnce().catch(() => {
637
+ // The attempt failed. Historically we relied on the socket's close event
638
+ // to schedule the next try -- but a failure WITHOUT a clean onClose (a
639
+ // mid-handshake network blip, or a transport that emits only onError)
640
+ // never fires that event, which is exactly how the client used to wedge
641
+ // (see the file header). So reschedule from here unconditionally; the
642
+ // idempotency guard above means this is a no-op if onClose already
643
+ // queued the next retry.
644
+ if (!this.closedByHost) {
645
+ this.scheduleReconnect("retry after failed attempt");
646
+ }
647
+ });
648
+ }, delay);
649
+ }
650
+
651
+ /** Cancel a queued reconnect, if any. Safe to call when none is pending. */
652
+ private clearReconnectTimer(): void {
653
+ if (this.reconnectTimer !== null) {
654
+ this.timers.clearTimeout(this.reconnectTimer);
655
+ this.reconnectTimer = null;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Start the reconnect watchdog (one interval for the life of an open()
661
+ * session). Stops any prior interval first so open() can be called repeatedly
662
+ * without leaking intervals.
663
+ */
664
+ private startWatchdog(): void {
665
+ this.stopWatchdog();
666
+ this.watchdogTimer = this.timers.setInterval(
667
+ () => this.tickWatchdog(),
668
+ RECONNECT_WATCHDOG_MS,
669
+ );
670
+ }
671
+
672
+ /** Stop the reconnect watchdog (on host close). */
673
+ private stopWatchdog(): void {
674
+ if (this.watchdogTimer !== null) {
675
+ this.timers.clearInterval(this.watchdogTimer);
676
+ this.watchdogTimer = null;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Belt-and-suspenders: if we are sitting `closed`, the host did not close us,
682
+ * and yet no reconnect is queued, the loop has stalled -- revive it.
683
+ *
684
+ * In normal operation this never fires: either we are open/connecting, or a
685
+ * reconnect timer is already pending. It exists only to guarantee that no
686
+ * unforeseen path (a future refactor, an exotic transport) can leave the
687
+ * client silently and permanently disconnected, which is the exact failure the
688
+ * self-rescheduling catch was added to prevent. The watchdog is the last line
689
+ * of defense behind it.
690
+ */
691
+ private tickWatchdog(): void {
692
+ if (
693
+ this.currentState === "closed" &&
694
+ !this.closedByHost &&
695
+ !this.replacedByNewerSession &&
696
+ this.reconnectTimer === null
697
+ ) {
698
+ this.scheduleReconnect("watchdog: no reconnect pending");
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Start the client-driven liveness ping.
704
+ *
705
+ * The client owns reconnect, so it actively probes the socket: it sends
706
+ * `control.ping` on an interval and arms a pong-wait timer each time. A pong
707
+ * disarms that timer (see `handleRawMessage`); a missing pong means the socket
708
+ * is dead even if it still looks open, so we close it to trigger reconnect.
709
+ */
710
+ private startLiveness(): void {
711
+ this.stopLiveness();
712
+ this.pingTimer = this.timers.setInterval(() => this.sendPing(), PING_INTERVAL_MS);
713
+ }
714
+
715
+ /** Send one liveness ping and arm the pong-wait timer. */
716
+ private sendPing(): void {
717
+ if (!this.socket) return;
718
+ this.socket.send(JSON.stringify(envelope("control.ping", {})));
719
+
720
+ // If a pong-wait timer is already armed, leave it: a single outstanding
721
+ // timeout is enough to catch a dead socket, and re-arming would push the
722
+ // deadline back on every interval.
723
+ if (this.pongTimer !== null) return;
724
+ this.pongTimer = this.timers.setTimeout(() => {
725
+ // No pong in time. The socket is dead; close it so the close handler runs
726
+ // the reconnect path, rather than reconnecting from here and racing the
727
+ // existing socket's eventual close.
728
+ this.deps.logger.warn("ws liveness timeout: no pong, reconnecting");
729
+ this.pongTimer = null;
730
+ const socket = this.socket;
731
+ if (!socket) return;
732
+ try {
733
+ socket.close();
734
+ } catch {
735
+ // The close event below is the desired path; if close throws, run it
736
+ // ourselves so liveness still recovers the session.
737
+ }
738
+ this.handleClose(socket, { code: 4000, reason: "liveness timeout" });
739
+ }, PONG_TIMEOUT_MS);
740
+ }
741
+
742
+ /** Answer a cloud ping with a pong. */
743
+ private sendPong(): void {
744
+ if (!this.socket) return;
745
+ this.socket.send(JSON.stringify(envelope("control.pong", {})));
746
+ }
747
+
748
+ /** Stop both liveness timers (on close, reconnect, or teardown). */
749
+ private stopLiveness(): void {
750
+ if (this.pingTimer !== null) {
751
+ this.timers.clearInterval(this.pingTimer);
752
+ this.pingTimer = null;
753
+ }
754
+ this.clearPongTimer();
755
+ }
756
+
757
+ /** Disarm the pong-wait timer (a pong arrived, or liveness is stopping). */
758
+ private clearPongTimer(): void {
759
+ if (this.pongTimer !== null) {
760
+ this.timers.clearTimeout(this.pongTimer);
761
+ this.pongTimer = null;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Reject and clear the in-flight handshake promise, if any. Safe to call when
767
+ * none is pending (a no-op), so close paths can call it unconditionally.
768
+ */
769
+ private failPendingAck(err: Error): void {
770
+ if (!this.pendingAck) return;
771
+ this.timers.clearTimeout(this.pendingAck.timer);
772
+ const { reject } = this.pendingAck;
773
+ this.pendingAck = null;
774
+ reject(err);
775
+ }
776
+
777
+ /**
778
+ * Close and drop the underlying socket without changing host-close intent or
779
+ * scheduling a reconnect. Used by paths that have already decided what happens
780
+ * next (host close, liveness timeout).
781
+ */
782
+ private teardownSocket(): void {
783
+ if (!this.socket) return;
784
+ const socket = this.socket;
785
+ this.socket = null;
786
+ try {
787
+ socket.close();
788
+ } catch {
789
+ // The socket may already be closing; closing twice must not throw.
790
+ }
791
+ }
792
+
793
+ /** Notify every state subscriber, isolating a throwing handler. */
794
+ private setState(s: ConnectionState): void {
795
+ // Record the state before notifying so the watchdog reads the authoritative
796
+ // current value, independent of any (possibly throwing) subscriber.
797
+ this.currentState = s;
798
+ for (const cb of [...this.stateHandlers]) {
799
+ try {
800
+ cb(s);
801
+ } catch {
802
+ // A misbehaving state handler must not break the others or the socket.
803
+ }
804
+ }
805
+ }
806
+
807
+ /**
808
+ * Append `?token=` to the connect URL as the auth fallback.
809
+ *
810
+ * The token also rides in the first frame (the primary path); this query
811
+ * parameter exists for environments where in-frame or header auth is awkward,
812
+ * notably the Chrome JS debugger. We use `URL` so an existing query string is
813
+ * merged correctly, and fall back to manual concatenation if `url` is not an
814
+ * absolute URL the parser accepts.
815
+ */
816
+ private appendTokenParam(url: string, token: string): string {
817
+ try {
818
+ const u = new URL(url);
819
+ u.searchParams.set("token", token);
820
+ return u.toString();
821
+ } catch {
822
+ const sep = url.includes("?") ? "&" : "?";
823
+ return `${url}${sep}token=${encodeURIComponent(token)}`;
824
+ }
825
+ }
826
+ }