@codeoid/core 0.1.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/src/client.ts ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Codeoid daemon WebSocket client — framework-agnostic transport core.
3
+ *
4
+ * Owns the entire transport lifecycle — connect, auth handshake (token +
5
+ * protocol version + capability declaration), request/response correlation,
6
+ * broadcast subscription, liveness heartbeat, exponential-backoff reconnect
7
+ * with full jitter. Consumers subscribe to `onMessage`/`onStatus` and never
8
+ * touch the raw socket.
9
+ *
10
+ * Environment assumptions (deliberately minimal):
11
+ * - a WHATWG `WebSocket` global (browsers, React Native, Bun, Node ≥ 22);
12
+ * - `setTimeout`/`setInterval`.
13
+ * The browser resume listeners (focus / online / visibilitychange) are
14
+ * feature-detected and no-op elsewhere; native hosts (React Native AppState,
15
+ * a TUI regaining the foreground) call `reconnectNow()` on their own resume
16
+ * signal instead. All timing (heartbeat cadence, backoff bounds, request
17
+ * timeout) is injectable via `ConnectOptions` — mobile hosts typically slow
18
+ * the heartbeat while backgrounded.
19
+ */
20
+
21
+ import type {
22
+ AuthOkMsg,
23
+ ClientMessage,
24
+ DaemonMessage,
25
+ ResponseErrorMsg,
26
+ ResponseOkMsg,
27
+ } from "@codeoid/protocol";
28
+ import { PROTOCOL_VERSION } from "@codeoid/protocol";
29
+
30
+ export type ClientStatus =
31
+ | { kind: "idle" }
32
+ | { kind: "connecting"; attempt: number }
33
+ | { kind: "connected"; auth: AuthOkMsg }
34
+ | { kind: "reconnecting"; attempt: number; nextInMs: number; reason: string }
35
+ | { kind: "failed"; reason: string };
36
+
37
+ export interface ConnectOptions {
38
+ url: string; // ws://host:port — daemon's WS endpoint
39
+ /** Initial access token used for the first handshake (and the fallback
40
+ * if `getToken` is absent or fails). */
41
+ token: string;
42
+ /** Optional fresh-token supplier, called on every (re)connect's open so a
43
+ * reconnect after the initial JWT expired re-exchanges the stored key for
44
+ * a new JWT instead of replaying the dead token forever. */
45
+ getToken?: () => Promise<string>;
46
+ /** Bounded reconnect attempts; once exhausted we land in `failed`. */
47
+ maxAttempts?: number;
48
+ /** Logger for transport-level diagnostics. */
49
+ log?: (level: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown) => void;
50
+ /**
51
+ * Capability identifiers this client declares on the auth frame (see the
52
+ * protocol's `CAPABILITIES`). Omit to authenticate as a legacy client —
53
+ * the daemon then assumes nothing beyond the base protocol.
54
+ */
55
+ capabilities?: string[];
56
+ /** Client name/version sent on the auth frame for daemon-side diagnostics
57
+ * (e.g. "codeoid-web", "codeoid-mobile/0.1.0"). */
58
+ clientName?: string;
59
+ /** Liveness ping cadence while connected. Default 20s. */
60
+ heartbeatMs?: number;
61
+ /** How long a ping may go unanswered before the socket is declared dead. Default 8s. */
62
+ heartbeatTimeoutMs?: number;
63
+ /** Reconnect backoff floor. Default 500ms. */
64
+ minBackoffMs?: number;
65
+ /** Reconnect backoff ceiling. Default 30s. */
66
+ maxBackoffMs?: number;
67
+ /** Default `request()` timeout. Default 30s. */
68
+ requestTimeoutMs?: number;
69
+ }
70
+
71
+ type StatusHandler = (status: ClientStatus) => void;
72
+ type MessageHandler = (msg: DaemonMessage) => void;
73
+
74
+ interface PendingRequest {
75
+ resolve: (ok: ResponseOkMsg) => void;
76
+ reject: (err: ResponseErrorMsg | Error) => void;
77
+ /** When true, ResponseOk/Error doesn't resolve — we wait for a typed result message. */
78
+ waitForResult?: (msg: DaemonMessage) => DaemonMessage | undefined;
79
+ /** Aborts auto-reject after timeoutMs. */
80
+ timer?: ReturnType<typeof setTimeout>;
81
+ }
82
+
83
+ /**
84
+ * Minimal typed view of the browser host, feature-detected at call time.
85
+ * Keeps the package compilable without the DOM lib while preserving the
86
+ * exact runtime behaviour in browsers.
87
+ */
88
+ interface HostEventTarget {
89
+ addEventListener(type: string, listener: () => void): void;
90
+ removeEventListener(type: string, listener: () => void): void;
91
+ }
92
+ function browserHost(): {
93
+ window?: HostEventTarget;
94
+ document?: HostEventTarget & { visibilityState?: string };
95
+ } {
96
+ const g = globalThis as Record<string, unknown>;
97
+ return {
98
+ window: g.window as HostEventTarget | undefined,
99
+ document: g.document as (HostEventTarget & { visibilityState?: string }) | undefined,
100
+ };
101
+ }
102
+
103
+ const DEFAULT_TIMEOUT_MS = 30_000;
104
+ const DEFAULT_MIN_BACKOFF_MS = 500;
105
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
106
+ /** Decorrelated jitter envelope: pick uniformly in [min, computed * factor]. */
107
+ const JITTER_FACTOR = 1.5;
108
+ /** Liveness heartbeat — detects a half-open socket the host never closed. */
109
+ const DEFAULT_HEARTBEAT_MS = 20_000;
110
+ const DEFAULT_HEARTBEAT_TIMEOUT_MS = 8_000;
111
+
112
+ export class CodeoidClient {
113
+ #opts: ConnectOptions;
114
+ /** Current access token; refreshed via opts.getToken on each connect. */
115
+ #token: string;
116
+ // Resolved timing knobs (see ConnectOptions) — read once at construction.
117
+ #heartbeatMs: number;
118
+ #heartbeatTimeoutMs: number;
119
+ #minBackoffMs: number;
120
+ #maxBackoffMs: number;
121
+ #requestTimeoutMs: number;
122
+ #ws: WebSocket | null = null;
123
+ #pending = new Map<string, PendingRequest>();
124
+ #statusHandlers = new Set<StatusHandler>();
125
+ #messageHandlers = new Set<MessageHandler>();
126
+ #status: ClientStatus = { kind: "idle" };
127
+ #reqCounter = 0;
128
+ #shutdown = false;
129
+ #heartbeatTimer: ReturnType<typeof setInterval> | null = null;
130
+ /** Resolver that interrupts the current backoff sleep (reconnect-now). */
131
+ #wake: (() => void) | null = null;
132
+ /** True while a connect loop is running (prevents concurrent loops). */
133
+ #connecting = false;
134
+ /**
135
+ * The in-flight connect loop, shared by every entry point (connect(),
136
+ * reconnectNow(), close-triggered and heartbeat-triggered reconnects) so a
137
+ * caller landing mid-reconnect JOINS the attempt instead of racing it —
138
+ * previously a connect() during a background reconnect threw
139
+ * "connect already in progress".
140
+ */
141
+ #loopPromise: Promise<AuthOkMsg> | null = null;
142
+ #resumeWired = false;
143
+ #onResume: (() => void) | null = null;
144
+
145
+ constructor(opts: ConnectOptions) {
146
+ this.#opts = opts;
147
+ this.#token = opts.token;
148
+ this.#heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
149
+ this.#heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
150
+ this.#minBackoffMs = opts.minBackoffMs ?? DEFAULT_MIN_BACKOFF_MS;
151
+ this.#maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
152
+ this.#requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
153
+ }
154
+
155
+ /** Current connection status, synchronously. */
156
+ get status(): ClientStatus {
157
+ return this.#status;
158
+ }
159
+
160
+ /** Subscribe to status changes. Returns an unsubscribe fn. */
161
+ onStatus(handler: StatusHandler): () => void {
162
+ this.#statusHandlers.add(handler);
163
+ try {
164
+ handler(this.#status);
165
+ } catch (err) {
166
+ this.#log("error", "status handler threw", { err });
167
+ }
168
+ return () => this.#statusHandlers.delete(handler);
169
+ }
170
+
171
+ /** Subscribe to incoming daemon messages. Returns an unsubscribe fn. */
172
+ onMessage(handler: MessageHandler): () => void {
173
+ this.#messageHandlers.add(handler);
174
+ return () => this.#messageHandlers.delete(handler);
175
+ }
176
+
177
+ /**
178
+ * Connect and complete the auth handshake. Resolves on `auth.ok`. Safe to
179
+ * call at any time: already connected → resolves immediately; a reconnect
180
+ * loop already running → joins it. Throws only after `shutdown()` — a
181
+ * client instance is terminal once shut down; construct a fresh one.
182
+ */
183
+ async connect(): Promise<AuthOkMsg> {
184
+ if (this.#shutdown) throw new Error("client is shut down — construct a new CodeoidClient");
185
+ this.#wireResumeListeners();
186
+ if (this.#status.kind === "connected") return this.#status.auth;
187
+ return this.#joinLoop();
188
+ }
189
+
190
+ /** Start the connect loop, or join the one already in flight. */
191
+ #joinLoop(): Promise<AuthOkMsg> {
192
+ if (!this.#loopPromise) {
193
+ this.#loopPromise = this.#connectWithBackoff(1).finally(() => {
194
+ this.#loopPromise = null;
195
+ });
196
+ }
197
+ return this.#loopPromise;
198
+ }
199
+
200
+ /**
201
+ * Reconnect immediately — used on resume (tab focus / network online /
202
+ * webview unsuspend / React Native AppState). If a backoff sleep is in
203
+ * progress, wake it so we retry now instead of waiting out the timer; if
204
+ * no loop is running and we're not connected, start one.
205
+ */
206
+ reconnectNow(): void {
207
+ if (this.#shutdown || this.#status.kind === "connected") return;
208
+ if (this.#wake) {
209
+ this.#wake();
210
+ } else if (!this.#connecting) {
211
+ void this.#joinLoop().catch((e) => this.#log("error", "reconnect failed", { e }));
212
+ }
213
+ }
214
+
215
+ /** Send a fire-and-forget client message. */
216
+ send(msg: ClientMessage): void {
217
+ if (this.#status.kind !== "connected") {
218
+ throw new Error(`cannot send while ${this.#status.kind}`);
219
+ }
220
+ this.#ws!.send(JSON.stringify(msg));
221
+ }
222
+
223
+ /**
224
+ * Send a request and resolve with the daemon's response. By default the
225
+ * `response.ok`/`response.error` for the request id resolves the promise;
226
+ * pass `waitForResult` to instead resolve on a typed broadcast (e.g.
227
+ * `session.list.result`) that carries `requestId`.
228
+ */
229
+ request<T extends DaemonMessage = ResponseOkMsg>(
230
+ msg: ClientMessage,
231
+ options: { timeoutMs?: number; waitForResult?: (m: DaemonMessage) => T | undefined } = {},
232
+ ): Promise<T> {
233
+ return new Promise<T>((resolve, reject) => {
234
+ if (this.#status.kind !== "connected") {
235
+ reject(new Error(`cannot request while ${this.#status.kind}`));
236
+ return;
237
+ }
238
+ const id = msg.id;
239
+ // A duplicate id would silently clobber the earlier pending entry,
240
+ // leaving its caller to hang until the timeout with a misleading
241
+ // "timed out" — surface the programmer error immediately instead.
242
+ if (this.#pending.has(id)) {
243
+ reject(new Error(`duplicate request id: ${id}`));
244
+ return;
245
+ }
246
+ const timer = setTimeout(() => {
247
+ this.#pending.delete(id);
248
+ reject(new Error(`request ${id} timed out`));
249
+ }, options.timeoutMs ?? this.#requestTimeoutMs);
250
+
251
+ this.#pending.set(id, {
252
+ resolve: (ok) => resolve(ok as unknown as T),
253
+ reject: (e) => reject(e instanceof Error ? e : new Error(e.error)),
254
+ waitForResult: options.waitForResult,
255
+ timer,
256
+ });
257
+ this.#ws!.send(JSON.stringify(msg));
258
+ });
259
+ }
260
+
261
+ /** Generate a unique request id. Monotonic + random suffix for safety across reconnects. */
262
+ nextId(): string {
263
+ this.#reqCounter += 1;
264
+ return `${Date.now().toString(36)}-${this.#reqCounter}-${Math.random().toString(36).slice(2, 8)}`;
265
+ }
266
+
267
+ /** Close the connection. No further reconnects after this. */
268
+ shutdown(): void {
269
+ this.#shutdown = true;
270
+ this.#stopHeartbeat();
271
+ this.#unwireResumeListeners();
272
+ this.#wake?.();
273
+ if (this.#ws) {
274
+ try {
275
+ this.#ws.close(1000, "client shutdown");
276
+ } catch {
277
+ /* ignore */
278
+ }
279
+ this.#ws = null;
280
+ }
281
+ for (const [, p] of this.#pending) {
282
+ if (p.timer) clearTimeout(p.timer);
283
+ p.reject(new Error("client shutdown"));
284
+ }
285
+ this.#pending.clear();
286
+ this.#setStatus({ kind: "idle" });
287
+ }
288
+
289
+ // ---------- internals ----------
290
+
291
+ #setStatus(next: ClientStatus): void {
292
+ this.#status = next;
293
+ // Isolate subscriber faults: one throwing handler must not break the
294
+ // fan-out for later handlers (or unwind transport internals).
295
+ for (const h of this.#statusHandlers) {
296
+ try {
297
+ h(next);
298
+ } catch (err) {
299
+ this.#log("error", "status handler threw", { err });
300
+ }
301
+ }
302
+ }
303
+
304
+ #log(level: "debug" | "info" | "warn" | "error", msg: string, ctx?: unknown): void {
305
+ this.#opts.log?.(level, msg, ctx);
306
+ }
307
+
308
+ async #connectWithBackoff(startAttempt: number): Promise<AuthOkMsg> {
309
+ let attempt = startAttempt;
310
+ // `maxAttempts: 0` (or undefined) means "never give up" — the
311
+ // common case is a laptop lid closing for an hour, the daemon
312
+ // bouncing during dev, or a flaky home wifi. Capping at 5
313
+ // attempts used to leave the user staring at "failed" until
314
+ // they refreshed; that's worse than waiting longer between
315
+ // attempts. Callers that explicitly want a bound can still set
316
+ // `maxAttempts` to a positive number.
317
+ const max = this.#opts.maxAttempts ?? 0;
318
+ const isBounded = max > 0;
319
+ if (this.#connecting) {
320
+ // A loop is already running — don't start a second.
321
+ throw new Error("connect already in progress");
322
+ }
323
+ this.#connecting = true;
324
+ try {
325
+ while (!this.#shutdown && (!isBounded || attempt <= max)) {
326
+ try {
327
+ this.#setStatus({ kind: "connecting", attempt });
328
+ const auth = await this.#connectOnce();
329
+ this.#setStatus({ kind: "connected", auth });
330
+ this.#startHeartbeat();
331
+ return auth;
332
+ } catch (err) {
333
+ const reason = err instanceof Error ? err.message : String(err);
334
+ if (isBounded && attempt >= max) {
335
+ this.#setStatus({ kind: "failed", reason });
336
+ throw err;
337
+ }
338
+ // Exponential backoff with full jitter — picks uniformly in
339
+ // [minBackoff, computed * JITTER_FACTOR] to break thundering
340
+ // herds when many clients reconnect at once (post-restart) and
341
+ // to avoid the "wakes up exactly on the second" pattern.
342
+ const ceiling = Math.min(this.#maxBackoffMs, this.#minBackoffMs * 2 ** (attempt - 1));
343
+ const upper = Math.min(this.#maxBackoffMs, Math.floor(ceiling * JITTER_FACTOR));
344
+ const backoff = this.#minBackoffMs + Math.floor(Math.random() * Math.max(0, upper - this.#minBackoffMs));
345
+ this.#log("warn", `connect attempt ${attempt} failed: ${reason}; retry in ${backoff}ms`);
346
+ this.#setStatus({ kind: "reconnecting", attempt, nextInMs: backoff, reason });
347
+ await this.#sleep(backoff);
348
+ attempt += 1;
349
+ }
350
+ }
351
+ } finally {
352
+ this.#connecting = false;
353
+ }
354
+ throw new Error("shutdown during reconnect");
355
+ }
356
+
357
+ /** Backoff sleep that `reconnectNow()` can interrupt via `#wake`. */
358
+ #sleep(ms: number): Promise<void> {
359
+ return new Promise<void>((resolve) => {
360
+ const t = setTimeout(() => {
361
+ this.#wake = null;
362
+ resolve();
363
+ }, ms);
364
+ this.#wake = () => {
365
+ clearTimeout(t);
366
+ this.#wake = null;
367
+ resolve();
368
+ };
369
+ });
370
+ }
371
+
372
+ #startHeartbeat(): void {
373
+ this.#stopHeartbeat();
374
+ this.#heartbeatTimer = setInterval(() => {
375
+ void this.#heartbeat();
376
+ }, this.#heartbeatMs);
377
+ }
378
+
379
+ #stopHeartbeat(): void {
380
+ if (this.#heartbeatTimer) {
381
+ clearInterval(this.#heartbeatTimer);
382
+ this.#heartbeatTimer = null;
383
+ }
384
+ }
385
+
386
+ /** Ping the daemon; a missed pong means the socket is dead → reconnect. */
387
+ async #heartbeat(): Promise<void> {
388
+ if (this.#status.kind !== "connected") return;
389
+ try {
390
+ await this.request(
391
+ { type: "ping", id: this.nextId() },
392
+ { timeoutMs: this.#heartbeatTimeoutMs },
393
+ );
394
+ } catch {
395
+ this.#log("warn", "heartbeat failed — socket is dead, forcing reconnect");
396
+ this.#forceReconnect();
397
+ }
398
+ }
399
+
400
+ /** Tear down a presumed-dead socket and reconnect from attempt 1. */
401
+ #forceReconnect(): void {
402
+ if (this.#shutdown) return;
403
+ this.#stopHeartbeat();
404
+ const ws = this.#ws;
405
+ this.#ws = null; // so onClose for this socket short-circuits (no double kick)
406
+ try {
407
+ ws?.close();
408
+ } catch {
409
+ /* ignore */
410
+ }
411
+ for (const [, p] of this.#pending) {
412
+ if (p.timer) clearTimeout(p.timer);
413
+ p.reject(new Error("connection reset"));
414
+ }
415
+ this.#pending.clear();
416
+ if (!this.#connecting) {
417
+ void this.#joinLoop().catch((e) => this.#log("error", "reconnect failed", { e }));
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Reconnect/revalidate on resume — tab focus, network online, or a
423
+ * suspended webview waking. Mobile webviews (Telegram Mini App) freeze the
424
+ * socket without a close event, so on resume we either reconnect (if down)
425
+ * or fire an immediate heartbeat to detect a zombie socket.
426
+ *
427
+ * Browser-only: feature-detected via globalThis so this module stays
428
+ * environment-agnostic (typed without the DOM lib). Non-browser hosts get
429
+ * a no-op and call `reconnectNow()` from their own resume signal instead
430
+ * (React Native `AppState`, a TUI regaining the foreground).
431
+ */
432
+ #wireResumeListeners(): void {
433
+ const { window: win, document: doc } = browserHost();
434
+ if (this.#resumeWired || !win) return;
435
+ this.#resumeWired = true;
436
+ const onResume = () => {
437
+ if (this.#shutdown) return;
438
+ if (doc?.visibilityState === "hidden") return;
439
+ if (this.#status.kind === "connected") {
440
+ void this.#heartbeat();
441
+ } else {
442
+ this.reconnectNow();
443
+ }
444
+ };
445
+ this.#onResume = onResume;
446
+ win.addEventListener("online", onResume);
447
+ win.addEventListener("focus", onResume);
448
+ doc?.addEventListener("visibilitychange", onResume);
449
+ }
450
+
451
+ #unwireResumeListeners(): void {
452
+ const { window: win, document: doc } = browserHost();
453
+ if (!this.#onResume || !win) return;
454
+ win.removeEventListener("online", this.#onResume);
455
+ win.removeEventListener("focus", this.#onResume);
456
+ doc?.removeEventListener("visibilitychange", this.#onResume);
457
+ this.#onResume = null;
458
+ this.#resumeWired = false;
459
+ }
460
+
461
+ #connectOnce(): Promise<AuthOkMsg> {
462
+ return new Promise<AuthOkMsg>((resolve, reject) => {
463
+ const ws = new WebSocket(this.#opts.url);
464
+ this.#ws = ws;
465
+
466
+ let authResolved = false;
467
+ // Handshake deadline: a peer (or middlebox) that accepts the socket but
468
+ // never answers the auth frame would otherwise hang connect() forever —
469
+ // the heartbeat only starts after `connected`, and request timeouts
470
+ // don't cover this pre-auth frame. Bounded by requestTimeoutMs; the
471
+ // rejection feeds the normal backoff loop.
472
+ const authTimer = setTimeout(() => {
473
+ if (authResolved) return;
474
+ authResolved = true;
475
+ this.#log("warn", "auth handshake timed out — closing socket");
476
+ try {
477
+ ws.close();
478
+ } catch {
479
+ /* ignore */
480
+ }
481
+ reject(new Error("auth handshake timed out"));
482
+ }, this.#requestTimeoutMs);
483
+
484
+ const onOpen = async () => {
485
+ // Refresh the token before the handshake so a reconnect after the
486
+ // prior JWT expired mints a fresh one (the daemon now closes 4003 on
487
+ // an expired token; without this we'd replay the dead token forever).
488
+ if (this.#opts.getToken) {
489
+ try {
490
+ this.#token = await this.#opts.getToken();
491
+ } catch (err) {
492
+ this.#log("warn", "getToken failed; using last token", { err });
493
+ }
494
+ }
495
+ // We may have awaited above — bail if a newer connect superseded
496
+ // this socket meanwhile.
497
+ if (this.#ws !== ws) return;
498
+ // Daemon requires the first frame to be the auth handshake. Beyond
499
+ // the token we declare our protocol version + whatever capabilities
500
+ // the host configured (additive — older daemons ignore the extra
501
+ // fields; omitting capabilities authenticates as a legacy client).
502
+ try {
503
+ ws.send(JSON.stringify({
504
+ type: "auth",
505
+ token: this.#token,
506
+ protocolVersion: PROTOCOL_VERSION,
507
+ ...(this.#opts.capabilities ? { capabilities: this.#opts.capabilities } : {}),
508
+ ...(this.#opts.clientName ? { client: this.#opts.clientName } : {}),
509
+ }));
510
+ } catch (err) {
511
+ this.#log("warn", "auth send failed (socket closed during refresh)", { err });
512
+ }
513
+ };
514
+ const onMessage = (ev: MessageEvent<string>) => {
515
+ let msg: DaemonMessage;
516
+ try {
517
+ msg = JSON.parse(ev.data) as DaemonMessage;
518
+ } catch (err) {
519
+ this.#log("error", "non-JSON frame from daemon", { err });
520
+ return;
521
+ }
522
+ if (!authResolved && msg.type === "auth.ok") {
523
+ authResolved = true;
524
+ clearTimeout(authTimer);
525
+ resolve(msg);
526
+ // From here on, dispatch normally.
527
+ this.#routeMessage(msg);
528
+ return;
529
+ }
530
+ if (!authResolved && msg.type === "response.error") {
531
+ authResolved = true;
532
+ clearTimeout(authTimer);
533
+ reject(new Error(`auth rejected: ${msg.error}`));
534
+ return;
535
+ }
536
+ this.#routeMessage(msg);
537
+ };
538
+ const onClose = (ev: CloseEvent) => {
539
+ const reason = ev.reason || `socket closed (code ${ev.code})`;
540
+ if (!authResolved) {
541
+ authResolved = true;
542
+ clearTimeout(authTimer);
543
+ reject(new Error(reason));
544
+ return;
545
+ }
546
+ clearTimeout(authTimer);
547
+ if (this.#shutdown) return;
548
+ // If this isn't the current socket, a forceReconnect/newer connect
549
+ // already took over — don't kick a second reconnect loop.
550
+ if (this.#ws !== ws) return;
551
+ this.#stopHeartbeat();
552
+ // Reject any in-flight requests so the UI doesn't hang on a
553
+ // dead socket waiting for the 30s default timeout — drawers
554
+ // and modals freeze otherwise. Reconnect kicks off below; the
555
+ // caller can simply retry once `connected` returns.
556
+ for (const [, p] of this.#pending) {
557
+ if (p.timer) clearTimeout(p.timer);
558
+ p.reject(new Error(`connection lost (${reason})`));
559
+ }
560
+ this.#pending.clear();
561
+ this.#log("warn", "connection dropped, attempting reconnect", { code: ev.code });
562
+ // Asynchronously kick off reconnect — caller already moved on.
563
+ if (!this.#connecting) {
564
+ void this.#joinLoop().catch((e) => {
565
+ this.#log("error", "reconnect exhausted", { e });
566
+ });
567
+ }
568
+ };
569
+ const onError = (ev: Event) => {
570
+ if (!authResolved) {
571
+ authResolved = true;
572
+ clearTimeout(authTimer);
573
+ reject(new Error("websocket error during connect"));
574
+ }
575
+ this.#log("error", "websocket error", { ev });
576
+ };
577
+
578
+ ws.addEventListener("open", onOpen);
579
+ ws.addEventListener("message", onMessage);
580
+ ws.addEventListener("close", onClose);
581
+ ws.addEventListener("error", onError);
582
+ });
583
+ }
584
+
585
+ #routeMessage(msg: DaemonMessage): void {
586
+ // Request/response correlation first.
587
+ if (msg.type === "response.ok" || msg.type === "response.error") {
588
+ const pending = this.#pending.get(msg.requestId);
589
+ if (pending) {
590
+ if (msg.type === "response.ok" && !pending.waitForResult) {
591
+ if (pending.timer) clearTimeout(pending.timer);
592
+ this.#pending.delete(msg.requestId);
593
+ pending.resolve(msg);
594
+ return;
595
+ }
596
+ if (msg.type === "response.error") {
597
+ if (pending.timer) clearTimeout(pending.timer);
598
+ this.#pending.delete(msg.requestId);
599
+ pending.reject(msg);
600
+ return;
601
+ }
602
+ }
603
+ }
604
+ // Typed result correlation: list/search/etc carry their own requestId.
605
+ for (const [id, pending] of this.#pending) {
606
+ if (pending.waitForResult) {
607
+ const matched = pending.waitForResult(msg);
608
+ if (matched) {
609
+ if (pending.timer) clearTimeout(pending.timer);
610
+ this.#pending.delete(id);
611
+ pending.resolve(matched as ResponseOkMsg);
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ // Isolate subscriber faults: a throwing UI handler must not break the
617
+ // fan-out for later handlers or propagate into the WS event callback.
618
+ for (const h of this.#messageHandlers) {
619
+ try {
620
+ h(msg);
621
+ } catch (err) {
622
+ this.#log("error", "message handler threw", { err });
623
+ }
624
+ }
625
+ }
626
+ }