@mentra/cloud-client 0.1.0-beta.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.
- package/README.md +35 -0
- package/node/index.ts +54 -0
- package/node/transports.ts +133 -0
- package/package.json +58 -0
- package/react-native/index.ts +38 -0
- package/react-native/transports.ts +231 -0
- package/src/client.ts +256 -0
- package/src/config.ts +84 -0
- package/src/errors.ts +54 -0
- package/src/http.ts +259 -0
- package/src/index.ts +70 -0
- package/src/logger.ts +34 -0
- package/src/modules/auth/auth.ts +553 -0
- package/src/modules/auth/jwt.ts +82 -0
- package/src/modules/auth/token-store.ts +138 -0
- package/src/modules/core/core.ts +202 -0
- package/src/modules/core/reports.ts +153 -0
- package/src/modules/runtime/audio-udp.ts +192 -0
- package/src/modules/runtime/camera.ts +192 -0
- package/src/modules/runtime/connection.ts +782 -0
- package/src/modules/runtime/emitter.ts +129 -0
- package/src/modules/runtime/maps.ts +92 -0
- package/src/modules/runtime/runtime.ts +557 -0
- package/src/modules/runtime/status.ts +12 -0
- package/src/modules/runtime/subscriptions.ts +136 -0
- package/src/modules/runtime/tts.ts +81 -0
- package/src/transports.ts +77 -0
|
@@ -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
|
+
}
|