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