@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/package.json +31 -0
- package/src/backoff.test.ts +87 -0
- package/src/backoff.ts +72 -0
- package/src/client.test.ts +313 -0
- package/src/client.ts +700 -0
- package/src/fake-transport.ts +101 -0
- package/src/index.ts +6 -0
- package/src/react.ts +17 -0
- package/src/status-store.test.ts +46 -0
- package/src/status-store.ts +56 -0
- package/src/transport.test.ts +48 -0
- package/src/transport.ts +142 -0
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
|
+
}
|