@celestia-island/plana-rpc-client 0.1.3 → 0.1.6
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 +5 -1
- package/src/client.ts +345 -21
- package/src/index.ts +5 -0
- package/src/relay.ts +153 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -39,11 +39,38 @@ export interface RpcNotification {
|
|
|
39
39
|
params: unknown;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Heartbeat wire protocol (P59-W4). The default preserves shittim-chest's
|
|
44
|
+
* shape; alternative backends (e.g. erp.celestia.world) speak a different
|
|
45
|
+
* one and can now adopt the shared client without forking it.
|
|
46
|
+
*/
|
|
47
|
+
export interface HeartbeatProtocol {
|
|
48
|
+
/**
|
|
49
|
+
* - "notify" (default): fire-and-forget notification `method`; the server
|
|
50
|
+
* answers with an `ackMethod` notification.
|
|
51
|
+
* - "request": JSON-RPC request `method` (default "ping"); the beat
|
|
52
|
+
* completes when the matching id-response arrives.
|
|
53
|
+
*/
|
|
54
|
+
mode?: "notify" | "request";
|
|
55
|
+
/** Notify mode: method to send (default "Base.Heartbeat"). */
|
|
56
|
+
method?: string;
|
|
57
|
+
/** Notify mode: ack method to listen for (default "Base.HeartbeatAck"). */
|
|
58
|
+
ackMethod?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Server-initiated pings to echo: when a notification named `on`
|
|
61
|
+
* arrives, reply with a notification named `reply` (e.g. erp's
|
|
62
|
+
* server "server.ping" → reply "client.pong" to satisfy its idle
|
|
63
|
+
* timer). Echoes are fire-and-forget.
|
|
64
|
+
*/
|
|
65
|
+
serverPings?: Array<{ on: string; reply: string }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
export interface RpcClientOpts {
|
|
43
69
|
baseUrl: string;
|
|
44
70
|
rpcPath?: string;
|
|
45
71
|
getToken: () => string | null;
|
|
46
72
|
onAuthLost?: () => void;
|
|
73
|
+
heartbeatProtocol?: HeartbeatProtocol;
|
|
47
74
|
/**
|
|
48
75
|
* Called when a request is rejected with 401: return a fresh access token
|
|
49
76
|
* to retry the request once (the callback owns persisting it), or null to
|
|
@@ -56,6 +83,14 @@ export interface RpcClientOpts {
|
|
|
56
83
|
sseMaxRetries?: number;
|
|
57
84
|
pollIntervalMs?: number;
|
|
58
85
|
local?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Probe-only mode: without a token the client performs a single anonymous
|
|
88
|
+
* `/api/health` handshake, reports `connected` once it succeeds, then
|
|
89
|
+
* immediately tears the connection down. Used by status bars to show
|
|
90
|
+
* backend health for anonymous visitors without holding a long-lived
|
|
91
|
+
* connection (abuse prevention).
|
|
92
|
+
*/
|
|
93
|
+
probeOnly?: boolean;
|
|
59
94
|
}
|
|
60
95
|
|
|
61
96
|
type NotificationHandler = (n: RpcNotification) => void;
|
|
@@ -116,6 +151,20 @@ export class RpcClient {
|
|
|
116
151
|
#hbAckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
117
152
|
#hbSentAt: number | null = null;
|
|
118
153
|
#latencyMs: number | null = null;
|
|
154
|
+
/** Resolved heartbeat protocol (defaults preserve the chest shape). */
|
|
155
|
+
readonly #hbProtocol: Required<Pick<HeartbeatProtocol, "mode" | "method" | "ackMethod">> & { serverPings: Map<string, string> };
|
|
156
|
+
/** Pending request-mode heartbeat ids → completion callbacks. */
|
|
157
|
+
#hbRequests = new Map<string, () => void>();
|
|
158
|
+
/** Established-connection loss recovery: delayed re-entry into the
|
|
159
|
+
* progressive rounds (exponential backoff, 1.5× → 30s cap + jitter).
|
|
160
|
+
* `connect()`/`forceReconnect()` reset it so manual retries are fresh. */
|
|
161
|
+
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
#reconnectDelay = 1_000;
|
|
163
|
+
#reconnectArmed = false;
|
|
164
|
+
/** True while a progressive session was entered by the recovery loop —
|
|
165
|
+
* exhausting its rounds re-arms recovery instead of parking on
|
|
166
|
+
* "failed" (only FIRST-EVER connection attempts park on failed). */
|
|
167
|
+
#fromRecovery = false;
|
|
119
168
|
|
|
120
169
|
#notifHandlers = new Set<NotificationHandler>();
|
|
121
170
|
#binaryHandlers = new Set<BinaryHandler>();
|
|
@@ -125,6 +174,7 @@ export class RpcClient {
|
|
|
125
174
|
|
|
126
175
|
#state: ConnectionState = "disconnected";
|
|
127
176
|
#retryCount = 0;
|
|
177
|
+
#probeOnly = false;
|
|
128
178
|
|
|
129
179
|
get state(): ConnectionState { return this.#state; }
|
|
130
180
|
get connected(): boolean { return this.#ws?.readyState === WebSocket.OPEN; }
|
|
@@ -140,11 +190,19 @@ export class RpcClient {
|
|
|
140
190
|
this.#onAuthLost = opts.onAuthLost;
|
|
141
191
|
this.#refreshToken = opts.refreshToken;
|
|
142
192
|
this.#heartbeatInterval = opts.heartbeatInterval ?? HB_INTERVAL;
|
|
193
|
+
const proto = opts.heartbeatProtocol ?? {};
|
|
194
|
+
this.#hbProtocol = {
|
|
195
|
+
mode: proto.mode ?? "notify",
|
|
196
|
+
method: proto.method ?? (proto.mode === "request" ? "ping" : "Base.Heartbeat"),
|
|
197
|
+
ackMethod: proto.ackMethod ?? "Base.HeartbeatAck",
|
|
198
|
+
serverPings: new Map((proto.serverPings ?? []).map(({ on, reply }) => [on, reply])),
|
|
199
|
+
};
|
|
143
200
|
this.#heartbeatTimeout = opts.heartbeatTimeout ?? HB_TIMEOUT;
|
|
144
201
|
this.#callTimeoutMs = opts.callTimeoutMs ?? CALL_TIMEOUT;
|
|
145
202
|
this.#pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL;
|
|
146
203
|
this.#sessionId = randomSessionId();
|
|
147
204
|
this.#local = opts.local ?? isLocalhost(this.#baseUrl);
|
|
205
|
+
this.#probeOnly = opts.probeOnly ?? false;
|
|
148
206
|
}
|
|
149
207
|
|
|
150
208
|
// ── main API ────────────────────────────────────────────
|
|
@@ -174,7 +232,12 @@ export class RpcClient {
|
|
|
174
232
|
connect(): void {
|
|
175
233
|
this.#disposed = false;
|
|
176
234
|
this.#retryCount = 0;
|
|
177
|
-
|
|
235
|
+
this.#cancelRecovery();
|
|
236
|
+
this.#fromRecovery = false;
|
|
237
|
+
this.#bindResumeTriggers();
|
|
238
|
+
if (this.#probeOnly) {
|
|
239
|
+
this.#probeHealthOnce();
|
|
240
|
+
} else if (this.#local) {
|
|
178
241
|
this.#tier = "local";
|
|
179
242
|
console.info("[RpcClient:local] detected localhost, using direct HTTP");
|
|
180
243
|
this.#setState("connected");
|
|
@@ -185,14 +248,26 @@ export class RpcClient {
|
|
|
185
248
|
|
|
186
249
|
async disconnect(): Promise<void> {
|
|
187
250
|
this.#disposed = true;
|
|
251
|
+
this.#cancelRecovery();
|
|
188
252
|
this.#teardownAll();
|
|
253
|
+
this.#unbindResumeTriggers();
|
|
189
254
|
this.#setState("disconnected");
|
|
190
255
|
}
|
|
191
256
|
|
|
192
257
|
forceReconnect(): void {
|
|
193
|
-
if (this.#
|
|
258
|
+
if (!this.#revivable) return;
|
|
194
259
|
this.#retryCount = 0;
|
|
260
|
+
this.#cancelRecovery();
|
|
261
|
+
this.#fromRecovery = false;
|
|
195
262
|
this.#teardownAll();
|
|
263
|
+
if (this.#probeOnly) {
|
|
264
|
+
// Anonymous clients park `disposed` right after their one-shot
|
|
265
|
+
// health probe succeeds; without this revive the status-bar tap
|
|
266
|
+
// on a red light was a permanent no-op (nothing ever re-probed).
|
|
267
|
+
this.#disposed = false;
|
|
268
|
+
this.#probeHealthOnce();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
196
271
|
if (this.#local) {
|
|
197
272
|
this.#tier = "local";
|
|
198
273
|
this.#setState("connected");
|
|
@@ -201,6 +276,62 @@ export class RpcClient {
|
|
|
201
276
|
}
|
|
202
277
|
}
|
|
203
278
|
|
|
279
|
+
// ═══════════════════════════════════════════════════════════
|
|
280
|
+
// Resume triggers: phones suspend timers in the background, so the
|
|
281
|
+
// recovery loop (exponential backoff up to 30s + jitter) can sit
|
|
282
|
+
// frozen while the page is hidden and the light stays red long after
|
|
283
|
+
// connectivity returned. Re-entering the foreground or the network
|
|
284
|
+
// coming back online forces a prompt reconnect instead of waiting
|
|
285
|
+
// out a stale countdown.
|
|
286
|
+
// ═══════════════════════════════════════════════════════════
|
|
287
|
+
|
|
288
|
+
#resumeBound = false;
|
|
289
|
+
#lastResumeAttemptAt = 0;
|
|
290
|
+
|
|
291
|
+
/** A host-killed client (logout disconnect()) must never revive;
|
|
292
|
+
* a disposed probeOnly client (its one-shot probe parked it) may. */
|
|
293
|
+
get #revivable(): boolean {
|
|
294
|
+
return !this.#disposed || this.#probeOnly;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
readonly #onVisibilityChange = (): void => {
|
|
298
|
+
if (typeof document === "undefined" || document.visibilityState !== "visible") return;
|
|
299
|
+
this.#onResumeTrigger();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
readonly #onOnline = (): void => {
|
|
303
|
+
// `online` can fire while the page is still hidden (network restored
|
|
304
|
+
// in the background); leave that resume to visibilitychange so a
|
|
305
|
+
// foreground return within the debounce window still gets its own
|
|
306
|
+
// fresh attempt.
|
|
307
|
+
if (typeof document === "undefined" || document.visibilityState !== "visible") return;
|
|
308
|
+
this.#onResumeTrigger();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
#onResumeTrigger(): void {
|
|
312
|
+
if (!this.#revivable) return;
|
|
313
|
+
if (this.#state === "connected" || this.#state === "connecting") return;
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
// Debounce: visibility flips and online events can burst together.
|
|
316
|
+
if (now - this.#lastResumeAttemptAt < 5_000) return;
|
|
317
|
+
this.#lastResumeAttemptAt = now;
|
|
318
|
+
this.forceReconnect();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#bindResumeTriggers(): void {
|
|
322
|
+
if (this.#resumeBound || typeof window === "undefined" || typeof document === "undefined") return;
|
|
323
|
+
this.#resumeBound = true;
|
|
324
|
+
document.addEventListener("visibilitychange", this.#onVisibilityChange);
|
|
325
|
+
window.addEventListener("online", this.#onOnline);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#unbindResumeTriggers(): void {
|
|
329
|
+
if (!this.#resumeBound) return;
|
|
330
|
+
this.#resumeBound = false;
|
|
331
|
+
document.removeEventListener("visibilitychange", this.#onVisibilityChange);
|
|
332
|
+
window.removeEventListener("online", this.#onOnline);
|
|
333
|
+
}
|
|
334
|
+
|
|
204
335
|
on(event: "notification", handler: NotificationHandler): () => void;
|
|
205
336
|
on(event: "binary", handler: BinaryHandler): () => void;
|
|
206
337
|
on(event: "state", handler: StateHandler): () => void;
|
|
@@ -258,12 +389,21 @@ export class RpcClient {
|
|
|
258
389
|
this.#tier = tier;
|
|
259
390
|
if (tier === "ws") this.#startHeartbeat();
|
|
260
391
|
else { this.#eventSource?.close(); this.#eventSource = null; if (this.#ws) { this.#cleanupWs(this.#ws, this.#wsGen); } }
|
|
261
|
-
|
|
392
|
+
// A fresh success also ends any recovery cycle with a clean
|
|
393
|
+
// backoff for the next drop.
|
|
394
|
+
if (this.#reconnectArmed || this.#reconnectDelay !== 1_000) this.#cancelRecovery();
|
|
395
|
+
this.#setState("connected", undefined, this.#tier);
|
|
262
396
|
return;
|
|
263
397
|
}
|
|
264
398
|
}
|
|
265
399
|
|
|
266
400
|
this.#tier = "poll";
|
|
401
|
+
if (this.#fromRecovery && !this.#disposed) {
|
|
402
|
+
// Recovery session exhausted its rounds — keep the loop alive.
|
|
403
|
+
this.#fromRecovery = false;
|
|
404
|
+
this.#scheduleRecovery();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
267
407
|
this.#setState("failed", undefined, "poll", undefined, undefined);
|
|
268
408
|
}
|
|
269
409
|
|
|
@@ -307,10 +447,17 @@ export class RpcClient {
|
|
|
307
447
|
const token = this.#getToken();
|
|
308
448
|
if (!token) { resolve(false); return; }
|
|
309
449
|
|
|
450
|
+
// Join the query correctly: rpcPath may already carry a query
|
|
451
|
+
// string (e.g. "?workspace=<id>" — every webui consumer passes
|
|
452
|
+
// one). Appending "?token=" to such a path produced
|
|
453
|
+
// "/api/rpc?workspace=X?token=Y": the server read the token as
|
|
454
|
+
// part of the workspace value, the upgrade failed with 401, and
|
|
455
|
+
// the WS tier never connected at all.
|
|
456
|
+
const tokenJoin = this.#rpcPath.includes("?") ? "&token=" : "?token=";
|
|
310
457
|
const wsUrl =
|
|
311
458
|
this.#baseUrl.replace(/^http/, "ws") +
|
|
312
459
|
this.#rpcPath +
|
|
313
|
-
|
|
460
|
+
tokenJoin +
|
|
314
461
|
encodeURIComponent(token);
|
|
315
462
|
const gen = ++this.#wsGen;
|
|
316
463
|
const ws = new WebSocket(wsUrl);
|
|
@@ -341,14 +488,21 @@ export class RpcClient {
|
|
|
341
488
|
};
|
|
342
489
|
|
|
343
490
|
ws.onclose = () => {
|
|
344
|
-
if (settled)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
491
|
+
if (!settled) {
|
|
492
|
+
// Attempt-phase close (never opened / failed mid-handshake).
|
|
493
|
+
settled = true;
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
this.#cleanupWs(ws, gen);
|
|
496
|
+
resolve(false);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Established connection lost (server idle-close, heartbeat
|
|
500
|
+
// timeout's close(4000), network drop): the progressive rounds
|
|
501
|
+
// only retry before the FIRST success, so without this a single
|
|
502
|
+
// dropped socket left consumers on a stale green light forever.
|
|
503
|
+
if (this.#ws === ws && this.#tier === "ws" && !this.#disposed) {
|
|
504
|
+
this.#scheduleRecovery();
|
|
350
505
|
}
|
|
351
|
-
resolve(false);
|
|
352
506
|
};
|
|
353
507
|
|
|
354
508
|
ws.onmessage = (event) => {
|
|
@@ -367,22 +521,42 @@ export class RpcClient {
|
|
|
367
521
|
let data: any;
|
|
368
522
|
try { data = JSON.parse(event.data); } catch { return; }
|
|
369
523
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
524
|
+
// Upstreams may answer unknown methods with a literal `null`
|
|
525
|
+
// body (JSON.parse("null") → null). Reading `.method` off it
|
|
526
|
+
// throws, and the uncaught TypeError is treated as a boot
|
|
527
|
+
// failure by the shell's fatal-fallback, freezing the whole
|
|
528
|
+
// page behind a full-screen overlay. Only JSON objects carry
|
|
529
|
+
// JSON-RPC semantics — drop everything else (null, scalars,
|
|
530
|
+
// arrays) without touching `.method`/`.id`.
|
|
531
|
+
if (typeof data !== "object" || data === null) return;
|
|
532
|
+
|
|
533
|
+
if (data.method === this.#hbProtocol.ackMethod) {
|
|
534
|
+
this.#completeHeartbeat();
|
|
378
535
|
return;
|
|
379
536
|
}
|
|
380
537
|
|
|
381
538
|
if (data.method && data.id === undefined) {
|
|
539
|
+
// Server-initiated ping → echo the configured reply (if any)
|
|
540
|
+
// BEFORE fanning the notification out, so idle timers on the
|
|
541
|
+
// server side are satisfied even if a consumer throws.
|
|
542
|
+
const echo = this.#hbProtocol.serverPings.get(data.method);
|
|
543
|
+
if (echo !== undefined) {
|
|
544
|
+
try { ws.send(JSON.stringify({ jsonrpc: "2.0", method: echo })); } catch { /* closing */ }
|
|
545
|
+
}
|
|
382
546
|
this.#notifHandlers.forEach((h) => h({ method: data.method, params: data.params }));
|
|
383
547
|
return;
|
|
384
548
|
}
|
|
385
549
|
|
|
550
|
+
if (data.id !== undefined) {
|
|
551
|
+
// Request-mode heartbeat completion (id-correlated response).
|
|
552
|
+
const hbDone = this.#hbRequests.get(String(data.id));
|
|
553
|
+
if (hbDone) {
|
|
554
|
+
this.#hbRequests.delete(String(data.id));
|
|
555
|
+
hbDone();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
386
560
|
if (data.id !== undefined) {
|
|
387
561
|
const id = String(data.id);
|
|
388
562
|
const entry = this.#pending.get(id);
|
|
@@ -484,7 +658,7 @@ export class RpcClient {
|
|
|
484
658
|
if (data.method && data.params !== undefined) {
|
|
485
659
|
this.#notifHandlers.forEach((h) => h({ method: data.method, params: data.params }));
|
|
486
660
|
}
|
|
487
|
-
if (data.method ===
|
|
661
|
+
if (data.method === this.#hbProtocol.ackMethod) {
|
|
488
662
|
this.#heartbeatHandlers.forEach((h) => h());
|
|
489
663
|
}
|
|
490
664
|
} catch { /* ignore */ }
|
|
@@ -528,6 +702,94 @@ export class RpcClient {
|
|
|
528
702
|
this.#progressiveConnect();
|
|
529
703
|
}
|
|
530
704
|
|
|
705
|
+
// ═══════════════════════════════════════════════════════════
|
|
706
|
+
// Anonymous health probe (probeOnly)
|
|
707
|
+
// ═══════════════════════════════════════════════════════════
|
|
708
|
+
|
|
709
|
+
async #probeHealthOnce(): Promise<void> {
|
|
710
|
+
// Preferred: one long-connection attempt over the real transport path.
|
|
711
|
+
// The server replies with a success ack and actively closes the socket,
|
|
712
|
+
// so the probe validates the WS upgrade + roundtrip without holding an
|
|
713
|
+
// anonymous connection open (no connection storm on login pages).
|
|
714
|
+
if (await this.#probeWsOnce(5000)) {
|
|
715
|
+
this.#tier = "ws";
|
|
716
|
+
this.#setState("connected");
|
|
717
|
+
this.#disposed = true;
|
|
718
|
+
this.#teardownAll();
|
|
719
|
+
this.#setState("disconnected");
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// Fallback: plain HTTP GET on the health endpoint.
|
|
723
|
+
try {
|
|
724
|
+
const url = this.#baseUrl + "/api/health";
|
|
725
|
+
const resp = await fetch(url, {
|
|
726
|
+
method: "GET",
|
|
727
|
+
credentials: "include",
|
|
728
|
+
signal: AbortSignal.timeout(5000),
|
|
729
|
+
});
|
|
730
|
+
if (!resp.ok) {
|
|
731
|
+
this.#setState("failed");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.#tier = "poll";
|
|
735
|
+
this.#setState("connected");
|
|
736
|
+
// Handshake succeeded — tear down immediately. The status bar only
|
|
737
|
+
// needs to know the backend is reachable; keeping a connection open
|
|
738
|
+
// for anonymous visitors would invite abuse.
|
|
739
|
+
this.#disposed = true;
|
|
740
|
+
this.#teardownAll();
|
|
741
|
+
this.#setState("disconnected");
|
|
742
|
+
} catch {
|
|
743
|
+
this.#setState("failed");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ═══════════════════════════════════════════════════════════
|
|
748
|
+
// Anonymous WS probe (single long-connection attempt)
|
|
749
|
+
// ═══════════════════════════════════════════════════════════
|
|
750
|
+
|
|
751
|
+
async #probeWsOnce(timeoutMs: number): Promise<boolean> {
|
|
752
|
+
return new Promise((resolve) => {
|
|
753
|
+
let ws: WebSocket | null = null;
|
|
754
|
+
try {
|
|
755
|
+
ws = new WebSocket(this.#baseUrl.replace(/^http/, "ws") + this.#rpcPath);
|
|
756
|
+
} catch {
|
|
757
|
+
resolve(false);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
let settled = false;
|
|
761
|
+
const finish = (ok: boolean): void => {
|
|
762
|
+
if (settled) return;
|
|
763
|
+
settled = true;
|
|
764
|
+
clearTimeout(timer);
|
|
765
|
+
try { ws?.close(); } catch { /* ignore */ }
|
|
766
|
+
resolve(ok);
|
|
767
|
+
};
|
|
768
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
769
|
+
|
|
770
|
+
ws.onopen = () => {
|
|
771
|
+
try {
|
|
772
|
+
ws?.send(
|
|
773
|
+
JSON.stringify({
|
|
774
|
+
jsonrpc: "2.0",
|
|
775
|
+
id: "probe",
|
|
776
|
+
method: "system.probe",
|
|
777
|
+
params: {},
|
|
778
|
+
}),
|
|
779
|
+
);
|
|
780
|
+
} catch {
|
|
781
|
+
finish(false);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
ws.onmessage = () => finish(true);
|
|
785
|
+
ws.onerror = () => finish(false);
|
|
786
|
+
ws.onclose = () => {
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
if (!settled) resolve(false);
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
531
793
|
// ═══════════════════════════════════════════════════════════
|
|
532
794
|
// HTTP POST (used by all tiers)
|
|
533
795
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -589,6 +851,17 @@ export class RpcClient {
|
|
|
589
851
|
// Heartbeat (WS only)
|
|
590
852
|
// ═══════════════════════════════════════════════════════════
|
|
591
853
|
|
|
854
|
+
/** Shared beat completion: latency bookkeeping + fan-out. */
|
|
855
|
+
#completeHeartbeat(): void {
|
|
856
|
+
this.#resetHeartbeatTimeout();
|
|
857
|
+
if (this.#hbSentAt !== null) {
|
|
858
|
+
this.#latencyMs = Math.max(0, Math.round(performance.now() - this.#hbSentAt));
|
|
859
|
+
this.#hbSentAt = null;
|
|
860
|
+
this.#emitLatency();
|
|
861
|
+
}
|
|
862
|
+
this.#heartbeatHandlers.forEach((h) => h());
|
|
863
|
+
}
|
|
864
|
+
|
|
592
865
|
#startHeartbeat(): void {
|
|
593
866
|
this.#clearHeartbeat();
|
|
594
867
|
this.#hbTimer = setInterval(() => {
|
|
@@ -596,10 +869,19 @@ export class RpcClient {
|
|
|
596
869
|
if (this.#hbAckTimer) return;
|
|
597
870
|
try {
|
|
598
871
|
this.#hbSentAt = performance.now();
|
|
599
|
-
this.#
|
|
872
|
+
if (this.#hbProtocol.mode === "request") {
|
|
873
|
+
// Request mode: a JSON-RPC request whose id-correlated response
|
|
874
|
+
// completes the beat (matched in the WS onmessage handler).
|
|
875
|
+
const id = `hb-${(++this.#idCounter).toString(36)}`;
|
|
876
|
+
this.#hbRequests.set(id, () => this.#completeHeartbeat());
|
|
877
|
+
this.#ws.send(JSON.stringify({ jsonrpc: "2.0", method: this.#hbProtocol.method, id }));
|
|
878
|
+
} else {
|
|
879
|
+
this.#ws.send(JSON.stringify({ jsonrpc: "2.0", method: this.#hbProtocol.method }));
|
|
880
|
+
}
|
|
600
881
|
this.#hbAckTimer = setTimeout(() => {
|
|
601
882
|
this.#hbAckTimer = null;
|
|
602
883
|
this.#hbSentAt = null;
|
|
884
|
+
this.#hbRequests.clear();
|
|
603
885
|
if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
|
|
604
886
|
this.#ws.close(4000, "heartbeat timeout");
|
|
605
887
|
}
|
|
@@ -626,6 +908,7 @@ export class RpcClient {
|
|
|
626
908
|
#clearHeartbeat(): void {
|
|
627
909
|
if (this.#hbTimer) { clearInterval(this.#hbTimer); this.#hbTimer = null; }
|
|
628
910
|
this.#resetHeartbeatTimeout();
|
|
911
|
+
this.#hbRequests.clear();
|
|
629
912
|
}
|
|
630
913
|
|
|
631
914
|
// ═══════════════════════════════════════════════════════════
|
|
@@ -633,6 +916,7 @@ export class RpcClient {
|
|
|
633
916
|
// ═══════════════════════════════════════════════════════════
|
|
634
917
|
|
|
635
918
|
#teardownAll(): void {
|
|
919
|
+
this.#cancelRecovery();
|
|
636
920
|
this.#eventSource?.close();
|
|
637
921
|
this.#eventSource = null;
|
|
638
922
|
if (this.#pollTimer) { clearInterval(this.#pollTimer); this.#pollTimer = null; }
|
|
@@ -652,6 +936,46 @@ export class RpcClient {
|
|
|
652
936
|
this.#rejectAllPending("disconnected");
|
|
653
937
|
}
|
|
654
938
|
|
|
939
|
+
/** Recovery loop for an established connection that dropped: surface
|
|
940
|
+
* `reconnecting` with a countdown, then re-enter the progressive
|
|
941
|
+
* rounds after an exponential backoff. Backoff resets on a successful
|
|
942
|
+
* reconnect (see the connected branch of #progressiveConnect). */
|
|
943
|
+
#scheduleRecovery(): void {
|
|
944
|
+
if (this.#disposed || this.#reconnectArmed) return;
|
|
945
|
+
this.#reconnectArmed = true;
|
|
946
|
+
this.#clearHeartbeat();
|
|
947
|
+
this.#retryCount = MAX_RETRIES; // exhaust the "rounds" so a failure re-arms recovery, not the loop
|
|
948
|
+
const jitter = Math.random() * this.#reconnectDelay * 0.5;
|
|
949
|
+
const delay = this.#reconnectDelay + jitter;
|
|
950
|
+
this.#reconnectDelay = Math.min(this.#reconnectDelay * 1.5, 30_000);
|
|
951
|
+
let remaining = Math.ceil(delay / 1000);
|
|
952
|
+
this.#setState("reconnecting", remaining);
|
|
953
|
+
const tick = setInterval(() => {
|
|
954
|
+
remaining -= 1;
|
|
955
|
+
if (remaining >= 0 && !this.#disposed) {
|
|
956
|
+
this.#setState("reconnecting", remaining);
|
|
957
|
+
}
|
|
958
|
+
}, 1_000);
|
|
959
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
960
|
+
clearInterval(tick);
|
|
961
|
+
this.#reconnectTimer = null;
|
|
962
|
+
this.#reconnectArmed = false;
|
|
963
|
+
if (!this.#disposed) {
|
|
964
|
+
this.#fromRecovery = true;
|
|
965
|
+
this.#progressiveConnect();
|
|
966
|
+
}
|
|
967
|
+
}, delay);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
#cancelRecovery(): void {
|
|
971
|
+
this.#reconnectArmed = false;
|
|
972
|
+
this.#reconnectDelay = 1_000;
|
|
973
|
+
if (this.#reconnectTimer !== null) {
|
|
974
|
+
clearTimeout(this.#reconnectTimer);
|
|
975
|
+
this.#reconnectTimer = null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
655
979
|
#setState(state: ConnectionState, retryIn?: number, transportTier?: string, attemptNumber?: number, countdown?: number): void {
|
|
656
980
|
this.#state = state;
|
|
657
981
|
this.#stateHandlers.forEach((h) => h({
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,13 @@ export { RpcClient } from "./client.js";
|
|
|
2
2
|
export { RpcError } from "./client.js";
|
|
3
3
|
export type {
|
|
4
4
|
RpcClientOpts,
|
|
5
|
+
HeartbeatProtocol,
|
|
5
6
|
ConnectionState,
|
|
6
7
|
ConnectionStateEvent,
|
|
7
8
|
RpcErrorKind,
|
|
8
9
|
RpcNotification,
|
|
9
10
|
} from "./client.js";
|
|
11
|
+
|
|
12
|
+
// Relay extension (edge side) — see docs/en/rpc/relay-profile.md.
|
|
13
|
+
export { RELAY, relayMethods, RelayClient, createTauriTransport, createMemoryTransport } from "./relay.js";
|
|
14
|
+
export type { RelayContext, EdgeFrame, EdgeTransport } from "./relay.js";
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Relay extension — the edge (webview/UI) side.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors `plana-rpc-client`'s Rust `relay` module and `plana-tauri`'s
|
|
4
|
+
// bridge: one request/response transport function plus one notification
|
|
5
|
+
// lane, canonical JSON-RPC 2.0 frames, the reserved `relay.*`
|
|
6
|
+
// system-control method names, and the hop context for chains. A webview
|
|
7
|
+
// picks a transport (Tauri v2 global bridge or in-memory for tests) and
|
|
8
|
+
// speaks the same surface in every app.
|
|
9
|
+
|
|
10
|
+
/** Reserved system-control namespace (never forwarded). */
|
|
11
|
+
export const RELAY = "relay";
|
|
12
|
+
|
|
13
|
+
/** Pre-standardized `relay.*` method names (see docs/en/rpc/relay-profile.md). */
|
|
14
|
+
export const relayMethods = {
|
|
15
|
+
netSetProxy: "relay.net.set_proxy",
|
|
16
|
+
netConfigureEntrypoint: "relay.net.configure_entrypoint",
|
|
17
|
+
connOpen: "relay.conn.open",
|
|
18
|
+
fwdMap: "relay.fwd.map",
|
|
19
|
+
fwdList: "relay.fwd.list",
|
|
20
|
+
windowMinimize: "relay.window.minimize",
|
|
21
|
+
windowMaximizeToggle: "relay.window.maximize_toggle",
|
|
22
|
+
windowClose: "relay.window.close",
|
|
23
|
+
windowState: "relay.window.state",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** Per-frame relay context: hop count for the loop guard + trace. */
|
|
27
|
+
export interface RelayContext {
|
|
28
|
+
hops: number;
|
|
29
|
+
via?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One edge frame: canonical JSON-RPC request members + relay extension. */
|
|
33
|
+
export interface EdgeFrame {
|
|
34
|
+
method: string;
|
|
35
|
+
params?: unknown;
|
|
36
|
+
relay?: RelayContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The transport contract any framework adapter implements. */
|
|
40
|
+
export interface EdgeTransport {
|
|
41
|
+
call(frame: EdgeFrame): Promise<unknown>;
|
|
42
|
+
/** Subscribe to the notification lane; returns an unsubscribe. */
|
|
43
|
+
listen(handler: (method: string, params?: unknown) => void): () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tauri v2 transport: `bridge_call` invoke + the `bridge_event` lane.
|
|
48
|
+
* Reads the strict v2 global shape (`__TAURI__.core.invoke`).
|
|
49
|
+
*/
|
|
50
|
+
export function createTauriTransport(): EdgeTransport {
|
|
51
|
+
const raw = (window as unknown as {
|
|
52
|
+
__TAURI__?: {
|
|
53
|
+
core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
|
54
|
+
event?: {
|
|
55
|
+
listen?: (
|
|
56
|
+
event: string,
|
|
57
|
+
handler: (e: { payload: unknown }) => void,
|
|
58
|
+
) => Promise<() => void>;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}).__TAURI__;
|
|
62
|
+
const invoke = raw?.core?.invoke;
|
|
63
|
+
const listen = raw?.event?.listen;
|
|
64
|
+
if (!invoke || !listen) {
|
|
65
|
+
throw new Error("tauri v2 global API unavailable (no withGlobalTauri shell)");
|
|
66
|
+
}
|
|
67
|
+
const subscribers = new Set<(method: string, params?: unknown) => void>();
|
|
68
|
+
let wired = false;
|
|
69
|
+
return {
|
|
70
|
+
async call(frame) {
|
|
71
|
+
return invoke("bridge_call", {
|
|
72
|
+
method: frame.method,
|
|
73
|
+
params: frame.params ?? null,
|
|
74
|
+
relay: frame.relay ?? null,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
listen(handler) {
|
|
78
|
+
if (!wired) {
|
|
79
|
+
wired = true;
|
|
80
|
+
void listen("bridge_event", (e) => {
|
|
81
|
+
const payload = e.payload as { method?: string; params?: unknown };
|
|
82
|
+
if (payload?.method) {
|
|
83
|
+
for (const fn of subscribers) fn(payload.method, payload.params);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
subscribers.add(handler);
|
|
88
|
+
return () => subscribers.delete(handler);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** In-memory transport for tests and non-framework embedding. */
|
|
94
|
+
export function createMemoryTransport(
|
|
95
|
+
far: (frame: EdgeFrame) => Promise<unknown>,
|
|
96
|
+
): EdgeTransport {
|
|
97
|
+
return {
|
|
98
|
+
call: (frame) => far(frame),
|
|
99
|
+
listen: () => () => {},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The edge client every UI surface shares. */
|
|
104
|
+
export class RelayClient {
|
|
105
|
+
constructor(private transport: EdgeTransport) {}
|
|
106
|
+
|
|
107
|
+
/** One request/response call; the transport owns correlation. */
|
|
108
|
+
call(method: string, params?: unknown, relay?: RelayContext): Promise<unknown> {
|
|
109
|
+
return this.transport.call({ method, params, relay });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Typed helpers over the standard system-control battery. */
|
|
113
|
+
setProxy(config: {
|
|
114
|
+
scheme: "http" | "https" | "socks5";
|
|
115
|
+
host: string;
|
|
116
|
+
username?: string;
|
|
117
|
+
password?: string;
|
|
118
|
+
}): Promise<unknown> {
|
|
119
|
+
return this.call(relayMethods.netSetProxy, config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
configureEntrypoint(url: string): Promise<unknown> {
|
|
123
|
+
return this.call(relayMethods.netConfigureEntrypoint, { url });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fwdMap(namespacePrefix: string, endpoint: string): Promise<unknown> {
|
|
127
|
+
return this.call(relayMethods.fwdMap, { namespacePrefix, endpoint });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
fwdList(): Promise<unknown> {
|
|
131
|
+
return this.call(relayMethods.fwdList);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
windowMinimize(): Promise<unknown> {
|
|
135
|
+
return this.call(relayMethods.windowMinimize);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
windowMaximizeToggle(): Promise<unknown> {
|
|
139
|
+
return this.call(relayMethods.windowMaximizeToggle);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
windowClose(): Promise<unknown> {
|
|
143
|
+
return this.call(relayMethods.windowClose);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
windowState(): Promise<unknown> {
|
|
147
|
+
return this.call(relayMethods.windowState);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
onNotification(handler: (method: string, params?: unknown) => void): () => void {
|
|
151
|
+
return this.transport.listen(handler);
|
|
152
|
+
}
|
|
153
|
+
}
|