@nativedesktop/rpc 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,700 @@
1
+ // Resilient JSON-RPC 2.0 client over a pluggable Transport. When a handshake
2
+ // is configured it must be the first frame on every socket; everything else
3
+ // is an id-correlated request/response, and the server pushes JSON-RPC
4
+ // notifications (no id) as events.
5
+ //
6
+ // The reconnect machinery is a port of CanaryOrchestrator's control-plane
7
+ // client; its invariants are behavioral, not stylistic, and each carries the
8
+ // comment explaining the failure it prevents.
9
+
10
+ import { ConnectionLadder } from "./backoff.ts";
11
+ import type { Transport, TransportFactory, TransportHandlers } from "./transport.ts";
12
+
13
+ export type RpcState = "connecting" | "ready" | "reconnecting" | "offline" | "closed";
14
+
15
+ export interface RpcContract {
16
+ methods: Record<string, { params: unknown; result: unknown }>;
17
+ events: Record<string, unknown>;
18
+ }
19
+
20
+ export class RpcError extends Error {
21
+ readonly rpcCode?: number;
22
+ readonly data?: unknown;
23
+ constructor(message: string, rpcCode?: number, data?: unknown) {
24
+ super(message);
25
+ this.name = "RpcError";
26
+ this.rpcCode = rpcCode;
27
+ this.data = data;
28
+ }
29
+ }
30
+
31
+ interface PendingCall {
32
+ resolve: (result: unknown) => void;
33
+ reject: (err: unknown) => void;
34
+ }
35
+
36
+ interface QueuedCall {
37
+ id: number;
38
+ method: string;
39
+ params: unknown;
40
+ }
41
+
42
+ interface JsonRpcIncoming {
43
+ id?: number | string | null;
44
+ method?: string;
45
+ params?: unknown;
46
+ result?: unknown;
47
+ error?: { code: number; message: string; data?: unknown };
48
+ }
49
+
50
+ /** One dial's transport plus its detach guard. Detaching (setting `detached`
51
+ * before `close()`) is the port of "null the ws handlers before close": a
52
+ * dying socket's late events must never drive recovery logic against the
53
+ * fresh connection dialed after it. */
54
+ interface Session {
55
+ transport: Transport;
56
+ detached: boolean;
57
+ }
58
+
59
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
60
+ const DEFAULT_CALL_TIMEOUT_MS = 15_000;
61
+ const DEFAULT_WATCHDOG_INTERVAL_MS = 5_000;
62
+ const DEFAULT_IDLE_PROBE_MS = 20_000;
63
+ const DEFAULT_IDLE_CLOSE_MS = 40_000;
64
+ const DEFAULT_JITTER = 0.5;
65
+
66
+ export interface RpcClientOptions<C extends RpcContract> {
67
+ transport: TransportFactory;
68
+ /** First frame on every socket; nothing else is sent until it answers.
69
+ * `fatal` inspects a server-sent handshake error: returning true marks the
70
+ * rejection terminal (wrong protocol, bad credentials), which suppresses
71
+ * the reconnect ladder and leaves the descriptive offline state. */
72
+ handshake?: { method: string; params: unknown | (() => unknown); fatal?: (err: RpcError) => boolean };
73
+ /** Runs after EVERY successful handshake, first dial included (a
74
+ * conditional latch here has a hole). Its failure never stomps a newer
75
+ * state. */
76
+ resume?: (client: RpcClient<C>) => Promise<void>;
77
+ /** Liveness probe sent at idleProbeMs of rx silence. Omit for the silence
78
+ * budget alone. */
79
+ probe?: { method: string; params?: unknown };
80
+ /** Bounds a single connect()/handshake attempt (default 10s) so a socket
81
+ * that never opens (or a handshake that never answers) rejects the caller
82
+ * instead of hanging it forever. */
83
+ connectTimeoutMs?: number;
84
+ /** Bounds every non-handshake call (default 15s), whether in flight or
85
+ * queued during a reconnect window. A half-open socket accepts writes into
86
+ * the kernel buffer and never answers, so without this a call (and the UI
87
+ * action behind it) hangs forever. */
88
+ callTimeoutMs?: number;
89
+ /** Liveness watchdog cadence while ready (default 5s). A wifi/VPN drop
90
+ * with no RST produces no close event; the watchdog is what turns "silent
91
+ * dead socket" into a close so the reconnect ladder can run at all. */
92
+ watchdogIntervalMs?: number;
93
+ /** Rx silence after which the watchdog sends the probe (default 20s). */
94
+ idleProbeMs?: number;
95
+ /** Rx silence after which the socket is declared dead and force-closed into
96
+ * the normal reconnect path (default 40s). */
97
+ idleCloseMs?: number;
98
+ backoff?: { baseMs?: number; maxMs?: number; jitter?: number; stabilityWindowMs?: number };
99
+ /** Injectable clock for tests. */
100
+ now?: () => number;
101
+ }
102
+
103
+ export class RpcClient<C extends RpcContract> {
104
+ #session: Session | undefined;
105
+ #connectCalled = false;
106
+ #nextId = 1;
107
+ #pending = new Map<number, PendingCall>();
108
+ #sendQueue: QueuedCall[] = [];
109
+ #authed = false;
110
+ #everAuthed = false;
111
+ #explicitClose = false;
112
+ #retryForever = false;
113
+ #lastHandshake: unknown | undefined;
114
+ // The one reconnect ladder (backoff.ts) for this client: tracks the attempt count and
115
+ // the stability window, and is the single source #scheduleReconnect computes the real
116
+ // setTimeout delay from. `attempt`/`nextRetryInMs` below expose the same numbers to
117
+ // subscribers so their reported state never drifts from what's actually scheduled.
118
+ #ladder: ConnectionLadder;
119
+ #pendingRetryInMs: number | undefined;
120
+ #reconnectTimer: ReturnType<typeof setTimeout> | undefined;
121
+ // Settles the connect()/handshake promise from outside the open handler: the socket can
122
+ // die (error/close) or never answer (timeout) before the pending handshake call
123
+ // is ever registered, and #rejectAllPending can't reach it there.
124
+ #pendingHandshakeReject: ((err: unknown) => void) | undefined;
125
+ // The #pending id of an in-flight handshake, once registered. Its own reject handler
126
+ // carries side effects (the fatal() check, #setState("offline", ...)) that must fire
127
+ // only for an actual server-sent error response, never for the generic "connection
128
+ // closed" #rejectAllPending sweeps on a plain socket drop, which would otherwise flash
129
+ // an incorrect offline state in the middle of a forever-reconnect run (see
130
+ // #discardPendingHandshake).
131
+ #pendingHandshakeId: number | undefined;
132
+ #connectTimer: ReturnType<typeof setTimeout> | undefined;
133
+ // Set just before tearing the socket down on a fatal handshake rejection, so the close
134
+ // cascade below (#handleClose) treats it as terminal (a reconnect backoff loop against
135
+ // a server we know will refuse us can never succeed) instead of clobbering the
136
+ // descriptive offline state we just set or scheduling another attempt.
137
+ #suppressReconnect = false;
138
+ #eventHandlers = new Map<string, Set<(params: unknown) => void>>();
139
+ #stateHandlers = new Set<(state: RpcState, detail?: string) => void>();
140
+ #reconnectHandlers = new Set<() => void>();
141
+ #state: RpcState = "offline";
142
+ #lastRxAt = 0;
143
+ #lastTickAt = 0;
144
+ #watchdogTimer: ReturnType<typeof setInterval> | undefined;
145
+ readonly #options: RpcClientOptions<C>;
146
+ readonly #connectTimeoutMs: number;
147
+ readonly #callTimeoutMs: number;
148
+ readonly #watchdogIntervalMs: number;
149
+ readonly #idleProbeMs: number;
150
+ readonly #idleCloseMs: number;
151
+ readonly #jitter: number;
152
+ readonly #now: () => number;
153
+
154
+ constructor(options: RpcClientOptions<C>) {
155
+ this.#options = options;
156
+ this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
157
+ this.#callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
158
+ this.#watchdogIntervalMs = options.watchdogIntervalMs ?? DEFAULT_WATCHDOG_INTERVAL_MS;
159
+ this.#idleProbeMs = options.idleProbeMs ?? DEFAULT_IDLE_PROBE_MS;
160
+ this.#idleCloseMs = options.idleCloseMs ?? DEFAULT_IDLE_CLOSE_MS;
161
+ this.#jitter = options.backoff?.jitter ?? DEFAULT_JITTER;
162
+ this.#now = options.now ?? Date.now;
163
+ this.#ladder = new ConnectionLadder({
164
+ now: this.#now,
165
+ baseMs: options.backoff?.baseMs,
166
+ maxMs: options.backoff?.maxMs,
167
+ stabilityWindowMs: options.backoff?.stabilityWindowMs,
168
+ });
169
+ }
170
+
171
+ get state(): RpcState {
172
+ return this.#state;
173
+ }
174
+
175
+ /** The result of the most recent successful handshake, so a subscriber can
176
+ * pick it up when the FIRST success arrives via the reconnect ladder (a
177
+ * retryForever dial whose initial connect() promise already rejected). */
178
+ get handshakeResult(): unknown | undefined {
179
+ return this.#lastHandshake;
180
+ }
181
+
182
+ /** The current rung of the reconnect ladder (0 while connected/idle), readable
183
+ * synchronously from inside an onStateChange callback. */
184
+ get attempt(): number {
185
+ return this.#ladder.attempt;
186
+ }
187
+
188
+ /** The delay of the currently scheduled reconnect attempt, undefined once that attempt
189
+ * starts dialing or none is pending. */
190
+ get nextRetryInMs(): number | undefined {
191
+ return this.#pendingRetryInMs;
192
+ }
193
+
194
+ connect(opts: { retryForever?: boolean } = {}): Promise<unknown> {
195
+ // Idempotent: a connect() during a reconnect window (or over a live socket)
196
+ // must not leak the pending backoff timer or the old socket. Detach the old
197
+ // socket's handlers before closing it so its imminent close event can't drive
198
+ // recovery logic against the fresh connection opened below.
199
+ if (this.#reconnectTimer) {
200
+ clearTimeout(this.#reconnectTimer);
201
+ this.#reconnectTimer = undefined;
202
+ }
203
+ this.#clearConnectAttempt(new RpcError("connect superseded"));
204
+ // The superseded attempt may have an in-flight handshake registered in
205
+ // #pending; with its socket detached below, no close event will ever
206
+ // sweep it, so the entry would sit there until a LATER #rejectAllPending
207
+ // invokes its reject handler, which sets a spurious offline state and
208
+ // closes whatever session is current by then. Discard it now.
209
+ this.#discardPendingHandshake();
210
+ this.#detachSession();
211
+ this.#connectCalled = true;
212
+ this.#explicitClose = false;
213
+ this.#retryForever = opts.retryForever ?? false;
214
+ // #everAuthed deliberately survives connect(): manual retry paths re-enter
215
+ // through connect() (not reconnectNow()), and a once-ready client must keep
216
+ // the forever-reconnect promise there too; resetting it made every manual
217
+ // retry on those paths a one-shot. It resets only in close().
218
+ this.#suppressReconnect = false;
219
+ this.#pendingRetryInMs = undefined;
220
+ this.#ladder.reset();
221
+ return this.#openAndHandshake(false);
222
+ }
223
+
224
+ /** Manual retry: re-dials immediately, bypassing any pending backoff wait.
225
+ * No-op if connect() was never called. Unlike connect(), this does NOT reset
226
+ * #everAuthed: a manual retry that itself fails must still rejoin the
227
+ * forever-reconnect ladder, not fall back to the never-ready fail-fast path. */
228
+ reconnectNow(): void {
229
+ if (!this.#connectCalled) return;
230
+ if (this.#reconnectTimer) {
231
+ clearTimeout(this.#reconnectTimer);
232
+ this.#reconnectTimer = undefined;
233
+ }
234
+ this.#pendingRetryInMs = undefined;
235
+ this.#ladder.reset();
236
+ this.#clearConnectAttempt(new RpcError("retry superseded"));
237
+ this.#discardPendingHandshake(); // same stranded-handshake hazard as connect() above
238
+ this.#detachSession();
239
+ void this.#openAndHandshake(true).catch(() => {
240
+ // #handleClose already rescheduled the next attempt on failure.
241
+ });
242
+ }
243
+
244
+ call<M extends keyof C["methods"] & string>(
245
+ method: M,
246
+ params: C["methods"][M]["params"],
247
+ ): Promise<C["methods"][M]["result"]> {
248
+ return this.#call(method, params) as Promise<C["methods"][M]["result"]>;
249
+ }
250
+
251
+ #call(method: string, params: unknown): Promise<unknown> {
252
+ return new Promise((resolve, reject) => {
253
+ // No socket: fail fast only when there is no reconnect run to wait for.
254
+ // During a ladder run the call queues instead: a UI action fired in a
255
+ // reconnect window rides out the blip (bounded by the call deadline
256
+ // below) rather than failing on millisecond timing.
257
+ if (!this.#session && (this.#explicitClose || !(this.#everAuthed || this.#retryForever))) {
258
+ reject(new RpcError("not connected"));
259
+ return;
260
+ }
261
+ const id = this.#nextId++;
262
+ // Bounds the call whether in flight, queued during a dial, or queued
263
+ // across a reconnect window: a half-open socket answers nothing, and a
264
+ // queued call on a link that stays down must not strand its caller.
265
+ const deadline = setTimeout(() => {
266
+ if (!this.#pending.has(id)) return;
267
+ this.#pending.delete(id);
268
+ this.#sendQueue = this.#sendQueue.filter((q) => q.id !== id);
269
+ reject(new RpcError(`${method} timed out after ${this.#callTimeoutMs}ms`));
270
+ }, this.#callTimeoutMs);
271
+ this.#pending.set(id, {
272
+ resolve: (result) => {
273
+ clearTimeout(deadline);
274
+ resolve(result);
275
+ },
276
+ reject: (err) => {
277
+ clearTimeout(deadline);
278
+ reject(err);
279
+ },
280
+ });
281
+ const queued: QueuedCall = { id, method, params };
282
+ if (this.#authed && this.#session) this.#sendRaw(queued);
283
+ else this.#sendQueue.push(queued);
284
+ });
285
+ }
286
+
287
+ on<E extends keyof C["events"] & string>(event: E, handler: (params: C["events"][E]) => void): () => void {
288
+ let handlers = this.#eventHandlers.get(event);
289
+ if (!handlers) {
290
+ handlers = new Set();
291
+ this.#eventHandlers.set(event, handlers);
292
+ }
293
+ const wrapped = handler as (params: unknown) => void;
294
+ handlers.add(wrapped);
295
+ return () => handlers.delete(wrapped);
296
+ }
297
+
298
+ onStateChange(cb: (state: RpcState, detail?: string) => void): () => void {
299
+ this.#stateHandlers.add(cb);
300
+ return () => this.#stateHandlers.delete(cb);
301
+ }
302
+
303
+ /** Fires after a successful post-drop reconnect (handshake + resume done), so the app
304
+ * can re-hydrate whatever it may have missed while disconnected. */
305
+ onReconnected(cb: () => void): () => void {
306
+ this.#reconnectHandlers.add(cb);
307
+ return () => this.#reconnectHandlers.delete(cb);
308
+ }
309
+
310
+ close(): void {
311
+ this.#explicitClose = true;
312
+ this.#stopWatchdog();
313
+ if (this.#reconnectTimer) {
314
+ clearTimeout(this.#reconnectTimer);
315
+ this.#reconnectTimer = undefined;
316
+ }
317
+ this.#pendingRetryInMs = undefined;
318
+ this.#clearConnectAttempt(new RpcError("client closed"));
319
+ this.#discardPendingHandshake();
320
+ this.#rejectAllPending(new RpcError("client closed"));
321
+ this.#detachSession();
322
+ this.#authed = false;
323
+ this.#everAuthed = false;
324
+ this.#setState("closed");
325
+ }
326
+
327
+ /** Marks the current session dead and closes its transport. After this, no
328
+ * event from that transport reaches the client (the Transport contract
329
+ * plus the `detached` guard), the port of nulling ws handlers pre-close. */
330
+ #detachSession(): void {
331
+ const session = this.#session;
332
+ if (!session) return;
333
+ this.#session = undefined;
334
+ session.detached = true;
335
+ session.transport.close();
336
+ }
337
+
338
+ #openAndHandshake(isReconnect: boolean): Promise<unknown> {
339
+ return new Promise((resolve, reject) => {
340
+ this.#setState(isReconnect ? "reconnecting" : "connecting");
341
+ const session: Session = { transport: undefined as unknown as Transport, detached: false };
342
+ // An already-open transport (a child process's stdio, a test mock) may
343
+ // fire onOpen from inside the factory call, before `session.transport`
344
+ // or the connect timer exist — #handleOpen would then send the
345
+ // handshake through an undefined transport, and the resulting throw
346
+ // would ride the factory-failure branch below as a permanent dial
347
+ // failure. Events fired during the factory call are buffered and
348
+ // replayed once the session is fully wired.
349
+ let preWireBuffer: Array<() => void> | undefined = [];
350
+ const deliver = (event: () => void): void => {
351
+ if (preWireBuffer) preWireBuffer.push(event);
352
+ else event();
353
+ };
354
+ const handlers: TransportHandlers = {
355
+ onOpen: () =>
356
+ deliver(() => {
357
+ if (!session.detached) this.#handleOpen(isReconnect, resolve, reject);
358
+ }),
359
+ onMessage: (frame) =>
360
+ deliver(() => {
361
+ if (!session.detached) this.#handleMessage(frame);
362
+ }),
363
+ onClose: () =>
364
+ deliver(() => {
365
+ if (!session.detached) this.#handleClose();
366
+ }),
367
+ };
368
+ let transport: Transport;
369
+ try {
370
+ transport = this.#options.transport(handlers);
371
+ } catch (err) {
372
+ // A synchronous factory throw (malformed URL, exhausted fds) skips
373
+ // every socket event, so nothing downstream would ever re-arm the
374
+ // timer; without this branch the ladder dies silently and the state
375
+ // sticks at "reconnecting" forever.
376
+ if (this.#everAuthed || this.#retryForever) {
377
+ this.#scheduleReconnect();
378
+ this.#setState("reconnecting");
379
+ } else {
380
+ this.#setState("offline", err instanceof Error ? err.message : String(err));
381
+ }
382
+ reject(err instanceof Error ? err : new RpcError(String(err)));
383
+ return;
384
+ }
385
+ session.transport = transport;
386
+ this.#session = session;
387
+ this.#authed = false;
388
+
389
+ // Settle-once wiring for THIS attempt's handshake. If the socket dies
390
+ // before it opens, #handleClose calls this; if it opens but the
391
+ // handshake never answers, the timer below does. Either way connect()
392
+ // rejects instead of hanging.
393
+ this.#pendingHandshakeReject = (err) => {
394
+ this.#pendingHandshakeReject = undefined;
395
+ this.#clearConnectTimer();
396
+ reject(err);
397
+ };
398
+ this.#connectTimer = setTimeout(() => {
399
+ this.#connectTimer = undefined;
400
+ const rejectHandshake = this.#pendingHandshakeReject;
401
+ if (!rejectHandshake) return;
402
+ this.#pendingHandshakeReject = undefined;
403
+ // Detach + close the wedged socket so its late events can't drive
404
+ // recovery against a connection nobody is waiting for. With close
405
+ // detached, #handleClose never runs for this socket, so its duties
406
+ // move here: sweep the in-flight handshake and any calls queued
407
+ // during the dial (a queued call would otherwise neither reject nor
408
+ // clear, and then be REPLAYED by #flushQueue on a much later
409
+ // reconnect), and keep the "retry forever once ready" promise for a
410
+ // dial that hangs rather than being refused (a sleeping host
411
+ // black-holing the SYN must not end the ladder any more than an RST
412
+ // does).
413
+ session.detached = true;
414
+ session.transport.close();
415
+ if (this.#session === session) this.#session = undefined;
416
+ this.#discardPendingHandshake();
417
+ if (this.#everAuthed || this.#retryForever) {
418
+ // Unsent calls queued during the dial survive to the next attempt;
419
+ // their own deadlines bound the total wait.
420
+ this.#rejectAllPending(new RpcError("connect timed out"), { keepQueued: true });
421
+ this.#scheduleReconnect();
422
+ this.#setState("reconnecting");
423
+ } else {
424
+ this.#rejectAllPending(new RpcError("connect timed out"));
425
+ this.#setState("offline", "connect timed out");
426
+ }
427
+ rejectHandshake(new RpcError(`rpc connect timed out after ${this.#connectTimeoutMs}ms`));
428
+ }, this.#connectTimeoutMs);
429
+
430
+ // Replay AFTER the reject/timer wiring above: a replayed onOpen with
431
+ // no handshake configured clears both on its way to ready, and wiring
432
+ // them afterwards would arm a connect timer against an already-ready
433
+ // session.
434
+ const replay = preWireBuffer;
435
+ preWireBuffer = undefined;
436
+ for (const event of replay) event();
437
+ });
438
+ }
439
+
440
+ #handleOpen(isReconnect: boolean, resolve: (result: unknown) => void, reject: (err: unknown) => void): void {
441
+ const handshake = this.#options.handshake;
442
+ if (!handshake) {
443
+ // No handshake configured: the transport opening IS readiness.
444
+ this.#pendingHandshakeReject = undefined;
445
+ this.#clearConnectTimer();
446
+ this.#becomeReady(undefined, isReconnect, resolve);
447
+ return;
448
+ }
449
+ const id = this.#nextId++;
450
+ this.#pendingHandshakeId = id;
451
+ this.#pending.set(id, {
452
+ resolve: (result) => {
453
+ this.#pendingHandshakeId = undefined;
454
+ this.#pendingHandshakeReject = undefined;
455
+ this.#clearConnectTimer();
456
+ this.#becomeReady(result, isReconnect, resolve);
457
+ },
458
+ reject: (err) => {
459
+ this.#pendingHandshakeId = undefined;
460
+ this.#pendingHandshakeReject = undefined;
461
+ this.#clearConnectTimer();
462
+ if (err instanceof RpcError && handshake.fatal?.(err)) this.#suppressReconnect = true;
463
+ this.#setState("offline", err instanceof Error ? err.message : String(err));
464
+ // The source closed the raw socket and let its close event run
465
+ // #handleClose; the Transport contract suppresses onClose after
466
+ // close(), so run the cascade directly.
467
+ this.#detachSession();
468
+ this.#handleClose();
469
+ reject(err);
470
+ },
471
+ });
472
+ const params = typeof handshake.params === "function" ? (handshake.params as () => unknown)() : handshake.params;
473
+ this.#sendRaw({ id, method: handshake.method, params });
474
+ }
475
+
476
+ #becomeReady(result: unknown, isReconnect: boolean, resolve: (result: unknown) => void): void {
477
+ this.#authed = true;
478
+ this.#everAuthed = true;
479
+ this.#lastHandshake = result;
480
+ this.#ladder.noteConnected();
481
+ this.#startWatchdog();
482
+ this.#setState("ready");
483
+ this.#flushQueue();
484
+ resolve(result);
485
+ void this.#afterConnected(isReconnect);
486
+ }
487
+
488
+ #clearConnectTimer(): void {
489
+ if (this.#connectTimer) {
490
+ clearTimeout(this.#connectTimer);
491
+ this.#connectTimer = undefined;
492
+ }
493
+ }
494
+
495
+ /** Cancels an in-flight connect()/handshake attempt, rejecting its pending promise
496
+ * so it never strands; used when a fresh connect() supersedes it or close()
497
+ * tears the client down. */
498
+ #clearConnectAttempt(err: Error): void {
499
+ this.#clearConnectTimer();
500
+ const rejectHandshake = this.#pendingHandshakeReject;
501
+ this.#pendingHandshakeReject = undefined;
502
+ rejectHandshake?.(err);
503
+ }
504
+
505
+ /** Drops a still-in-flight handshake's #pending entry WITHOUT invoking its reject
506
+ * handler: that handler's side effects (the fatal() check, #setState("offline", ...))
507
+ * belong only to an explicit error response from the server (via #handleMessage),
508
+ * never to the socket merely closing. Called before the generic #rejectAllPending
509
+ * sweep in both close() and #handleClose() so a plain drop mid-handshake can't
510
+ * masquerade as a protocol-level rejection and short-circuit the forever-reconnect
511
+ * decision that follows. */
512
+ #discardPendingHandshake(): void {
513
+ if (this.#pendingHandshakeId !== undefined) {
514
+ this.#pending.delete(this.#pendingHandshakeId);
515
+ this.#pendingHandshakeId = undefined;
516
+ }
517
+ }
518
+
519
+ async #afterConnected(isReconnect: boolean): Promise<void> {
520
+ // resume runs after EVERY successful handshake, first dial included: a
521
+ // conditional first-connect latch has a hole (a first-connect resume
522
+ // swept by a drop would leave the latch unset, and every future
523
+ // reconnect would then skip it). Idempotency is the resume callback's
524
+ // contract with its server.
525
+ const resume = this.#options.resume;
526
+ if (resume) {
527
+ try {
528
+ await resume(this);
529
+ } catch (err) {
530
+ // If the socket dropped while this was in flight, #handleClose
531
+ // already flipped #authed false and moved to 'reconnecting'; don't
532
+ // stomp that back to 'ready'. The next reconnect's resume retries.
533
+ if (!this.#authed) return;
534
+ this.#setState("ready", err instanceof Error ? err.message : "resume failed");
535
+ }
536
+ }
537
+ if (isReconnect) for (const handler of this.#reconnectHandlers) handler();
538
+ }
539
+
540
+ #handleClose(): void {
541
+ const wasExplicit = this.#explicitClose;
542
+ const suppressReconnect = this.#suppressReconnect;
543
+ this.#suppressReconnect = false;
544
+ this.#authed = false;
545
+ this.#detachSession();
546
+ this.#stopWatchdog();
547
+ this.#discardPendingHandshake();
548
+ const ridesLadder = !wasExplicit && !suppressReconnect && (this.#everAuthed || this.#retryForever);
549
+ // In-flight calls reject (their fate on the wire is unknown); calls still
550
+ // queued (never sent) survive a ladder-bound drop and flush on the next
551
+ // successful handshake, bounded by their own deadlines.
552
+ this.#rejectAllPending(new RpcError("connection closed"), { keepQueued: ridesLadder });
553
+ // A socket that dies before or during the handshake never registered a
554
+ // pending call, so #rejectAllPending can't reach the connect() promise;
555
+ // settle it here or it hangs forever (#openAndHandshake only wires the
556
+ // handshake call inside the open handler).
557
+ const rejectHandshake = this.#pendingHandshakeReject;
558
+ if (rejectHandshake) {
559
+ this.#pendingHandshakeReject = undefined;
560
+ this.#clearConnectTimer();
561
+ rejectHandshake(new RpcError("connection closed before authentication"));
562
+ }
563
+ if (wasExplicit) {
564
+ this.#setState("closed");
565
+ return;
566
+ }
567
+ // The handshake reject handler already set a descriptive 'offline' state
568
+ // for a fatal rejection: a reconnect backoff loop against a server we
569
+ // know will refuse us can never succeed, so leave that state alone
570
+ // rather than retrying.
571
+ if (suppressReconnect) return;
572
+ // Fail fast only on a client that has NEVER been ready (and isn't marked
573
+ // retryForever): an interactive first connect() that never succeeds rolls
574
+ // back instead of leaving a permanent reconnect loop against a
575
+ // dead/unreachable endpoint. Once ready at least once, or dialed with
576
+ // retryForever, retry forever (no attempt cap): a link that drops
577
+ // mid-session should always come back on its own.
578
+ if (!this.#everAuthed && !this.#retryForever) {
579
+ this.#setState("offline");
580
+ return;
581
+ }
582
+ // Advance the ladder + arm the timer BEFORE notifying listeners, so a
583
+ // subscriber reading attempt/nextRetryInMs from inside its state-change
584
+ // callback sees the rung this very 'reconnecting' transition represents.
585
+ this.#scheduleReconnect();
586
+ this.#setState("reconnecting");
587
+ }
588
+
589
+ #scheduleReconnect(): void {
590
+ if (this.#reconnectTimer) return;
591
+ this.#ladder.noteDisconnected();
592
+ // Jitter is folded in BEFORE publishing: nextRetryInMs must report the
593
+ // delay actually armed (a countdown built on it would otherwise hit zero
594
+ // and sit there a large fraction of the rung while the timer still runs).
595
+ const base = this.#ladder.nextDelayMs();
596
+ const delay = base + Math.random() * base * this.#jitter;
597
+ this.#pendingRetryInMs = delay;
598
+ this.#reconnectTimer = setTimeout(() => {
599
+ this.#reconnectTimer = undefined;
600
+ this.#pendingRetryInMs = undefined;
601
+ void this.#openAndHandshake(true).catch(() => {
602
+ // #handleClose already rescheduled the next attempt on failure.
603
+ });
604
+ }, delay);
605
+ }
606
+
607
+ #flushQueue(): void {
608
+ const queue = this.#sendQueue;
609
+ this.#sendQueue = [];
610
+ for (const item of queue) this.#sendRaw(item);
611
+ }
612
+
613
+ #sendRaw(msg: QueuedCall): void {
614
+ this.#session!.transport.send(JSON.stringify({ jsonrpc: "2.0", ...msg }));
615
+ }
616
+
617
+ /** Liveness watchdog (runs only while ready). Any received message
618
+ * refreshes #lastRxAt; at `idleProbeMs` of silence the configured probe
619
+ * goes out (its reply, even an error, counts as rx); at `idleCloseMs` the
620
+ * socket is declared dead and force-closed into the normal #handleClose
621
+ * recovery path. A wall-clock jump between ticks (laptop suspend) forces
622
+ * an immediate probe or close instead of waiting out the silence budget on
623
+ * a socket that almost certainly died while asleep. */
624
+ #startWatchdog(): void {
625
+ this.#stopWatchdog();
626
+ this.#lastRxAt = this.#now();
627
+ this.#lastTickAt = this.#now();
628
+ this.#watchdogTimer = setInterval(() => this.#watchdogTick(), this.#watchdogIntervalMs);
629
+ }
630
+
631
+ #stopWatchdog(): void {
632
+ if (this.#watchdogTimer) {
633
+ clearInterval(this.#watchdogTimer);
634
+ this.#watchdogTimer = undefined;
635
+ }
636
+ }
637
+
638
+ #watchdogTick(): void {
639
+ if (!this.#authed || !this.#session) return;
640
+ const now = this.#now();
641
+ const resumed = now - this.#lastTickAt > this.#watchdogIntervalMs * 3;
642
+ this.#lastTickAt = now;
643
+ const silence = now - this.#lastRxAt;
644
+ if (silence >= this.#idleCloseMs) {
645
+ // Detach first so the real close event (if the stack ever delivers
646
+ // one) can't run recovery twice against the fresh connection.
647
+ this.#detachSession();
648
+ this.#handleClose();
649
+ return;
650
+ }
651
+ const probe = this.#options.probe;
652
+ if (probe && (silence >= this.#idleProbeMs || resumed)) {
653
+ void this.#call(probe.method, probe.params ?? {}).catch(() => {
654
+ // Probe failure is not a verdict; the silence budget is.
655
+ });
656
+ }
657
+ }
658
+
659
+ #handleMessage(raw: string): void {
660
+ // Every rx, including an error reply, refreshes the silence budget.
661
+ this.#lastRxAt = this.#now();
662
+ let msg: JsonRpcIncoming;
663
+ try {
664
+ msg = JSON.parse(raw);
665
+ } catch {
666
+ return;
667
+ }
668
+ if (msg.method && (msg.id === undefined || msg.id === null)) {
669
+ const handlers = this.#eventHandlers.get(msg.method);
670
+ if (handlers) for (const handler of handlers) handler(msg.params);
671
+ return;
672
+ }
673
+ if (typeof msg.id !== "number") return;
674
+ const pending = this.#pending.get(msg.id);
675
+ if (!pending) return;
676
+ this.#pending.delete(msg.id);
677
+ if (msg.error) pending.reject(new RpcError(msg.error.message, msg.error.code, msg.error.data));
678
+ else pending.resolve(msg.result);
679
+ }
680
+
681
+ #rejectAllPending(err: unknown, opts?: { keepQueued?: boolean }): void {
682
+ if (opts?.keepQueued) {
683
+ const queuedIds = new Set(this.#sendQueue.map((q) => q.id));
684
+ for (const [id, pending] of [...this.#pending]) {
685
+ if (queuedIds.has(id)) continue;
686
+ this.#pending.delete(id);
687
+ pending.reject(err);
688
+ }
689
+ return;
690
+ }
691
+ for (const pending of this.#pending.values()) pending.reject(err);
692
+ this.#pending.clear();
693
+ this.#sendQueue = [];
694
+ }
695
+
696
+ #setState(state: RpcState, detail?: string): void {
697
+ this.#state = state;
698
+ for (const handler of this.#stateHandlers) handler(state, detail);
699
+ }
700
+ }