@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 CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@celestia-island/plana-rpc-client",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/celestia-island/plana"
7
+ },
4
8
  "type": "module",
5
9
  "main": "src/index.ts",
6
10
  "types": "src/index.ts",
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
- if (this.#local) {
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.#disposed) return;
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
- this.#setState("connected");
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
- "?token=" +
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) return;
345
- settled = true;
346
- clearTimeout(timer);
347
- this.#cleanupWs(ws, gen);
348
- if (this.#tier === "ws" && !this.#disposed) {
349
- this.#setState("disconnected");
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
- if (data.method === "Base.HeartbeatAck") {
371
- this.#resetHeartbeatTimeout();
372
- if (this.#hbSentAt !== null) {
373
- this.#latencyMs = Math.max(0, Math.round(performance.now() - this.#hbSentAt));
374
- this.#hbSentAt = null;
375
- this.#emitLatency();
376
- }
377
- this.#heartbeatHandlers.forEach((h) => h());
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 === "Base.HeartbeatAck") {
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.#ws.send(JSON.stringify({ jsonrpc: "2.0", method: "Base.Heartbeat" }));
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
+ }