@celestia-island/plana-rpc-client 0.1.3
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 +17 -0
- package/src/client.ts +691 -0
- package/src/index.ts +9 -0
- package/tsconfig.json +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@celestia-island/plana-rpc-client",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"lint": "eslint src/",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^*"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
export type ConnectionState =
|
|
2
|
+
| "connected"
|
|
3
|
+
| "disconnected"
|
|
4
|
+
| "connecting"
|
|
5
|
+
| "reconnecting"
|
|
6
|
+
| "failed";
|
|
7
|
+
|
|
8
|
+
export interface ConnectionStateEvent {
|
|
9
|
+
state: ConnectionState;
|
|
10
|
+
retryIn?: number;
|
|
11
|
+
retryCount?: number;
|
|
12
|
+
maxRetries?: number;
|
|
13
|
+
transportTier?: string;
|
|
14
|
+
attemptNumber?: number;
|
|
15
|
+
countdown?: number;
|
|
16
|
+
/** Round-trip time of the latest heartbeat, in milliseconds (ws tier only). */
|
|
17
|
+
latencyMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RpcErrorKind =
|
|
21
|
+
| "transport"
|
|
22
|
+
| "timeout"
|
|
23
|
+
| "forbidden"
|
|
24
|
+
| "rpc";
|
|
25
|
+
|
|
26
|
+
export class RpcError extends Error {
|
|
27
|
+
readonly kind: RpcErrorKind;
|
|
28
|
+
readonly method: string;
|
|
29
|
+
constructor(kind: RpcErrorKind, method: string, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "RpcError";
|
|
32
|
+
this.kind = kind;
|
|
33
|
+
this.method = method;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RpcNotification {
|
|
38
|
+
method: string;
|
|
39
|
+
params: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RpcClientOpts {
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
rpcPath?: string;
|
|
45
|
+
getToken: () => string | null;
|
|
46
|
+
onAuthLost?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Called when a request is rejected with 401: return a fresh access token
|
|
49
|
+
* to retry the request once (the callback owns persisting it), or null to
|
|
50
|
+
* give up and trigger authLost.
|
|
51
|
+
*/
|
|
52
|
+
refreshToken?: () => Promise<string | null>;
|
|
53
|
+
heartbeatInterval?: number;
|
|
54
|
+
heartbeatTimeout?: number;
|
|
55
|
+
callTimeoutMs?: number;
|
|
56
|
+
sseMaxRetries?: number;
|
|
57
|
+
pollIntervalMs?: number;
|
|
58
|
+
local?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type NotificationHandler = (n: RpcNotification) => void;
|
|
62
|
+
type BinaryHandler = (data: ArrayBuffer) => void;
|
|
63
|
+
type StateHandler = (e: ConnectionStateEvent) => void;
|
|
64
|
+
type HeartbeatHandler = () => void;
|
|
65
|
+
type AuthLostHandler = () => void;
|
|
66
|
+
|
|
67
|
+
interface PendingCall {
|
|
68
|
+
resolve: (v: unknown) => void;
|
|
69
|
+
reject: (e: Error) => void;
|
|
70
|
+
timer: ReturnType<typeof setTimeout>;
|
|
71
|
+
method: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type TransportTier = "local" | "ws" | "sse" | "poll";
|
|
75
|
+
|
|
76
|
+
const HB_INTERVAL = 15_000;
|
|
77
|
+
const HB_TIMEOUT = 10_000;
|
|
78
|
+
const CALL_TIMEOUT = 30_000;
|
|
79
|
+
const LOCAL_CALL_TIMEOUT = 5_000;
|
|
80
|
+
const MAX_RETRIES = 3;
|
|
81
|
+
const POLL_INTERVAL = 30_000;
|
|
82
|
+
const ATTEMPT_TIMEOUTS = [1_000, 3_000, 5_000];
|
|
83
|
+
|
|
84
|
+
function isLocalhost(baseUrl: string): boolean {
|
|
85
|
+
try {
|
|
86
|
+
const host = new URL(baseUrl).hostname;
|
|
87
|
+
return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
|
|
88
|
+
} catch { return false; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class RpcClient {
|
|
92
|
+
readonly #baseUrl: string;
|
|
93
|
+
readonly #rpcPath: string;
|
|
94
|
+
readonly #getToken: () => string | null;
|
|
95
|
+
readonly #onAuthLost?: () => void;
|
|
96
|
+
readonly #refreshToken?: () => Promise<string | null>;
|
|
97
|
+
readonly #heartbeatInterval: number;
|
|
98
|
+
readonly #heartbeatTimeout: number;
|
|
99
|
+
readonly #callTimeoutMs: number;
|
|
100
|
+
readonly #pollIntervalMs: number;
|
|
101
|
+
|
|
102
|
+
#ws: WebSocket | null = null;
|
|
103
|
+
#wsGen = 0;
|
|
104
|
+
#idCounter = 0;
|
|
105
|
+
#pending = new Map<string, PendingCall>();
|
|
106
|
+
#disposed = false;
|
|
107
|
+
|
|
108
|
+
#sessionId: string;
|
|
109
|
+
#eventSource: EventSource | null = null;
|
|
110
|
+
|
|
111
|
+
#pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
112
|
+
#tier: TransportTier = "ws";
|
|
113
|
+
readonly #local: boolean;
|
|
114
|
+
|
|
115
|
+
#hbTimer: ReturnType<typeof setInterval> | null = null;
|
|
116
|
+
#hbAckTimer: ReturnType<typeof setTimeout> | null = null;
|
|
117
|
+
#hbSentAt: number | null = null;
|
|
118
|
+
#latencyMs: number | null = null;
|
|
119
|
+
|
|
120
|
+
#notifHandlers = new Set<NotificationHandler>();
|
|
121
|
+
#binaryHandlers = new Set<BinaryHandler>();
|
|
122
|
+
#stateHandlers = new Set<StateHandler>();
|
|
123
|
+
#heartbeatHandlers = new Set<HeartbeatHandler>();
|
|
124
|
+
#authLostHandlers = new Set<AuthLostHandler>();
|
|
125
|
+
|
|
126
|
+
#state: ConnectionState = "disconnected";
|
|
127
|
+
#retryCount = 0;
|
|
128
|
+
|
|
129
|
+
get state(): ConnectionState { return this.#state; }
|
|
130
|
+
get connected(): boolean { return this.#ws?.readyState === WebSocket.OPEN; }
|
|
131
|
+
get transportTier(): TransportTier { return this.#tier; }
|
|
132
|
+
get retryCount(): number { return this.#retryCount; }
|
|
133
|
+
/** Last measured heartbeat round-trip time (ws tier only); null when unknown. */
|
|
134
|
+
get latencyMs(): number | null { return this.#latencyMs; }
|
|
135
|
+
|
|
136
|
+
constructor(opts: RpcClientOpts) {
|
|
137
|
+
this.#baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
138
|
+
this.#rpcPath = opts.rpcPath ?? "/api/rpc";
|
|
139
|
+
this.#getToken = opts.getToken;
|
|
140
|
+
this.#onAuthLost = opts.onAuthLost;
|
|
141
|
+
this.#refreshToken = opts.refreshToken;
|
|
142
|
+
this.#heartbeatInterval = opts.heartbeatInterval ?? HB_INTERVAL;
|
|
143
|
+
this.#heartbeatTimeout = opts.heartbeatTimeout ?? HB_TIMEOUT;
|
|
144
|
+
this.#callTimeoutMs = opts.callTimeoutMs ?? CALL_TIMEOUT;
|
|
145
|
+
this.#pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL;
|
|
146
|
+
this.#sessionId = randomSessionId();
|
|
147
|
+
this.#local = opts.local ?? isLocalhost(this.#baseUrl);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── main API ────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
async call<T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
|
|
153
|
+
const timeout = timeoutMs ?? (this.#tier === "local" ? LOCAL_CALL_TIMEOUT : this.#callTimeoutMs);
|
|
154
|
+
|
|
155
|
+
if (this.#tier !== "ws") {
|
|
156
|
+
try {
|
|
157
|
+
return await this.#sendOverHttp<T>(method, params, timeout);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (this.#tier === "local" && !this.#disposed) {
|
|
160
|
+
console.info("[RpcClient:local] request failed, downgrading to ws");
|
|
161
|
+
this.#downgradeToWs();
|
|
162
|
+
}
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (this.connected) {
|
|
168
|
+
return this.#sendOverWs<T>(method, params, timeout);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return this.#sendOverHttp<T>(method, params, timeout);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
connect(): void {
|
|
175
|
+
this.#disposed = false;
|
|
176
|
+
this.#retryCount = 0;
|
|
177
|
+
if (this.#local) {
|
|
178
|
+
this.#tier = "local";
|
|
179
|
+
console.info("[RpcClient:local] detected localhost, using direct HTTP");
|
|
180
|
+
this.#setState("connected");
|
|
181
|
+
} else {
|
|
182
|
+
this.#progressiveConnect();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async disconnect(): Promise<void> {
|
|
187
|
+
this.#disposed = true;
|
|
188
|
+
this.#teardownAll();
|
|
189
|
+
this.#setState("disconnected");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
forceReconnect(): void {
|
|
193
|
+
if (this.#disposed) return;
|
|
194
|
+
this.#retryCount = 0;
|
|
195
|
+
this.#teardownAll();
|
|
196
|
+
if (this.#local) {
|
|
197
|
+
this.#tier = "local";
|
|
198
|
+
this.#setState("connected");
|
|
199
|
+
} else {
|
|
200
|
+
this.#progressiveConnect();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
on(event: "notification", handler: NotificationHandler): () => void;
|
|
205
|
+
on(event: "binary", handler: BinaryHandler): () => void;
|
|
206
|
+
on(event: "state", handler: StateHandler): () => void;
|
|
207
|
+
on(event: "heartbeat", handler: HeartbeatHandler): () => void;
|
|
208
|
+
on(event: "authLost", handler: AuthLostHandler): () => void;
|
|
209
|
+
on(event: string, handler: (...args: any[]) => void): () => void {
|
|
210
|
+
const sets: Record<string, Set<(...args: any[]) => void>> = {
|
|
211
|
+
notification: this.#notifHandlers,
|
|
212
|
+
binary: this.#binaryHandlers,
|
|
213
|
+
state: this.#stateHandlers,
|
|
214
|
+
heartbeat: this.#heartbeatHandlers,
|
|
215
|
+
authLost: this.#authLostHandlers,
|
|
216
|
+
};
|
|
217
|
+
const set = sets[event];
|
|
218
|
+
if (!set) throw new Error(`unknown event: ${event}`);
|
|
219
|
+
set.add(handler);
|
|
220
|
+
return () => set.delete(handler);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ═══════════════════════════════════════════════════════════
|
|
224
|
+
// Progressive connect: 3 rounds, each tries ws+sse+poll in parallel.
|
|
225
|
+
// Priority: ws > sse > poll. Timeouts: 1s, 3s, 5s.
|
|
226
|
+
// ═══════════════════════════════════════════════════════════
|
|
227
|
+
|
|
228
|
+
async #progressiveConnect(): Promise<void> {
|
|
229
|
+
this.#tier = "poll";
|
|
230
|
+
for (let round = 0; round < MAX_RETRIES; round++) {
|
|
231
|
+
if (this.#disposed) return;
|
|
232
|
+
const timeoutMs = ATTEMPT_TIMEOUTS[round];
|
|
233
|
+
const roundNum = round + 1;
|
|
234
|
+
this.#retryCount = roundNum;
|
|
235
|
+
|
|
236
|
+
this.#setState("connecting", undefined, undefined, roundNum, Math.ceil(timeoutMs / 1000));
|
|
237
|
+
|
|
238
|
+
let remaining = Math.ceil(timeoutMs / 1000);
|
|
239
|
+
const countdownTimer = setInterval(() => {
|
|
240
|
+
remaining--;
|
|
241
|
+
if (remaining >= 0) {
|
|
242
|
+
this.#stateHandlers.forEach((h) =>
|
|
243
|
+
h({
|
|
244
|
+
state: "connecting",
|
|
245
|
+
attemptNumber: roundNum,
|
|
246
|
+
countdown: remaining,
|
|
247
|
+
retryCount: roundNum,
|
|
248
|
+
maxRetries: MAX_RETRIES,
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}, 1000);
|
|
253
|
+
|
|
254
|
+
const tier = await this.#raceTransports(timeoutMs);
|
|
255
|
+
clearInterval(countdownTimer);
|
|
256
|
+
|
|
257
|
+
if (tier) {
|
|
258
|
+
this.#tier = tier;
|
|
259
|
+
if (tier === "ws") this.#startHeartbeat();
|
|
260
|
+
else { this.#eventSource?.close(); this.#eventSource = null; if (this.#ws) { this.#cleanupWs(this.#ws, this.#wsGen); } }
|
|
261
|
+
this.#setState("connected");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.#tier = "poll";
|
|
267
|
+
this.#setState("failed", undefined, "poll", undefined, undefined);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Try ws, sse, poll in parallel. Return highest-priority tier that succeeded.
|
|
271
|
+
* Always waits the full timeout so the countdown is visible. */
|
|
272
|
+
async #raceTransports(timeoutMs: number): Promise<TransportTier | null> {
|
|
273
|
+
const results = await Promise.allSettled([
|
|
274
|
+
this.#tryWsOnce(timeoutMs).then((ok) => ({ tier: "ws" as TransportTier, ok })),
|
|
275
|
+
this.#trySseOnce(timeoutMs).then((ok) => ({ tier: "sse" as TransportTier, ok })),
|
|
276
|
+
this.#tryPollOnce(timeoutMs).then((ok) => ({ tier: "poll" as TransportTier, ok })),
|
|
277
|
+
// Sentinel keeps the settled-result type homogeneous ({ tier, ok }) so
|
|
278
|
+
// `r.value.ok` below is well-typed; its value is never read.
|
|
279
|
+
sleep(timeoutMs).then(() => ({ tier: null, ok: false })),
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
const priority: TransportTier[] = ["ws", "sse", "poll"];
|
|
283
|
+
for (const tier of priority) {
|
|
284
|
+
const r = results[priority.indexOf(tier)];
|
|
285
|
+
if (r.status === "fulfilled" && r.value.ok) {
|
|
286
|
+
return tier;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async #tryTransportOnce(tier: TransportTier, timeoutMs: number): Promise<boolean> {
|
|
293
|
+
switch (tier) {
|
|
294
|
+
case "ws": return this.#tryWsOnce(timeoutMs);
|
|
295
|
+
case "sse": return this.#trySseOnce(timeoutMs);
|
|
296
|
+
case "poll": return this.#tryPollOnce(timeoutMs);
|
|
297
|
+
default: return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ═══════════════════════════════════════════════════════════
|
|
302
|
+
// Tier 1 — WebSocket (single attempt, promise-based)
|
|
303
|
+
// ═══════════════════════════════════════════════════════════
|
|
304
|
+
|
|
305
|
+
async #tryWsOnce(timeoutMs: number): Promise<boolean> {
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const token = this.#getToken();
|
|
308
|
+
if (!token) { resolve(false); return; }
|
|
309
|
+
|
|
310
|
+
const wsUrl =
|
|
311
|
+
this.#baseUrl.replace(/^http/, "ws") +
|
|
312
|
+
this.#rpcPath +
|
|
313
|
+
"?token=" +
|
|
314
|
+
encodeURIComponent(token);
|
|
315
|
+
const gen = ++this.#wsGen;
|
|
316
|
+
const ws = new WebSocket(wsUrl);
|
|
317
|
+
ws.binaryType = "arraybuffer";
|
|
318
|
+
this.#ws = ws;
|
|
319
|
+
let settled = false;
|
|
320
|
+
|
|
321
|
+
const timer = setTimeout(() => {
|
|
322
|
+
if (settled) return;
|
|
323
|
+
settled = true;
|
|
324
|
+
this.#cleanupWs(ws, gen);
|
|
325
|
+
resolve(false);
|
|
326
|
+
}, timeoutMs);
|
|
327
|
+
|
|
328
|
+
ws.onopen = () => {
|
|
329
|
+
if (settled || this.#wsGen !== gen) return;
|
|
330
|
+
settled = true;
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
resolve(true);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
ws.onerror = () => {
|
|
336
|
+
if (settled || this.#wsGen !== gen) return;
|
|
337
|
+
settled = true;
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
this.#cleanupWs(ws, gen);
|
|
340
|
+
resolve(false);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
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");
|
|
350
|
+
}
|
|
351
|
+
resolve(false);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
ws.onmessage = (event) => {
|
|
355
|
+
if (this.#wsGen !== gen) return;
|
|
356
|
+
this.#resetHeartbeatTimeout();
|
|
357
|
+
|
|
358
|
+
if (event.data instanceof ArrayBuffer) {
|
|
359
|
+
this.#binaryHandlers.forEach((h) => h(event.data as ArrayBuffer));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (event.data instanceof Blob) {
|
|
363
|
+
event.data.arrayBuffer().then((buf) => { this.#binaryHandlers.forEach((h) => h(buf)); }).catch(() => {});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let data: any;
|
|
368
|
+
try { data = JSON.parse(event.data); } catch { return; }
|
|
369
|
+
|
|
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());
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (data.method && data.id === undefined) {
|
|
382
|
+
this.#notifHandlers.forEach((h) => h({ method: data.method, params: data.params }));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (data.id !== undefined) {
|
|
387
|
+
const id = String(data.id);
|
|
388
|
+
const entry = this.#pending.get(id);
|
|
389
|
+
if (entry) {
|
|
390
|
+
this.#pending.delete(id);
|
|
391
|
+
clearTimeout(entry.timer);
|
|
392
|
+
if (data.error) {
|
|
393
|
+
const msg: string = data.error.message || "unknown rpc error";
|
|
394
|
+
const kind: RpcErrorKind = data.error.code === -32003 ? "forbidden" : "rpc";
|
|
395
|
+
entry.reject(new RpcError(kind, entry.method, msg));
|
|
396
|
+
} else {
|
|
397
|
+
entry.resolve(data.result);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#cleanupWs(ws: WebSocket, gen: number): void {
|
|
406
|
+
ws.onopen = null;
|
|
407
|
+
ws.onerror = null;
|
|
408
|
+
ws.onclose = null;
|
|
409
|
+
ws.onmessage = null;
|
|
410
|
+
ws.close();
|
|
411
|
+
if (this.#ws === ws) this.#ws = null;
|
|
412
|
+
this.#clearHeartbeat();
|
|
413
|
+
this.#rejectAllPending("connection lost");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#sendOverWs<T>(method: string, params: unknown, timeoutMs: number): Promise<T> {
|
|
417
|
+
const id = `rpc-${(++this.#idCounter).toString(36)}`;
|
|
418
|
+
return new Promise((resolve, reject) => {
|
|
419
|
+
const timer = setTimeout(() => {
|
|
420
|
+
this.#pending.delete(id);
|
|
421
|
+
reject(new RpcError("timeout", method, `rpc call '${method}' timed out`));
|
|
422
|
+
}, timeoutMs);
|
|
423
|
+
|
|
424
|
+
this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer, method });
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
this.#ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? undefined }));
|
|
428
|
+
} catch (e) {
|
|
429
|
+
this.#pending.delete(id);
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
reject(new RpcError("transport", method, `Failed to send: ${e}`));
|
|
432
|
+
}
|
|
433
|
+
}) as Promise<T>;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ═══════════════════════════════════════════════════════════
|
|
437
|
+
// Tier 2 — EventSource (SSE, single attempt)
|
|
438
|
+
// ═══════════════════════════════════════════════════════════
|
|
439
|
+
|
|
440
|
+
async #trySseOnce(timeoutMs: number): Promise<boolean> {
|
|
441
|
+
if (typeof EventSource === "undefined") return false;
|
|
442
|
+
|
|
443
|
+
return new Promise((resolve) => {
|
|
444
|
+
const cleanPath = this.#rpcPath.split("?")[0];
|
|
445
|
+
const url = this.#baseUrl + cleanPath + "/events?session=" + this.#sessionId;
|
|
446
|
+
let settled = false;
|
|
447
|
+
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
if (settled) return;
|
|
450
|
+
settled = true;
|
|
451
|
+
es.close();
|
|
452
|
+
this.#eventSource = null;
|
|
453
|
+
resolve(false);
|
|
454
|
+
}, timeoutMs);
|
|
455
|
+
|
|
456
|
+
let es: EventSource;
|
|
457
|
+
try {
|
|
458
|
+
es = new EventSource(url);
|
|
459
|
+
this.#eventSource = es;
|
|
460
|
+
} catch {
|
|
461
|
+
resolve(false);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
es.onopen = () => {
|
|
466
|
+
if (settled) return;
|
|
467
|
+
settled = true;
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
resolve(true);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
es.onerror = () => {
|
|
473
|
+
if (settled) return;
|
|
474
|
+
settled = true;
|
|
475
|
+
clearTimeout(timer);
|
|
476
|
+
es.close();
|
|
477
|
+
this.#eventSource = null;
|
|
478
|
+
resolve(false);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
es.onmessage = (event) => {
|
|
482
|
+
try {
|
|
483
|
+
const data = JSON.parse(event.data);
|
|
484
|
+
if (data.method && data.params !== undefined) {
|
|
485
|
+
this.#notifHandlers.forEach((h) => h({ method: data.method, params: data.params }));
|
|
486
|
+
}
|
|
487
|
+
if (data.method === "Base.HeartbeatAck") {
|
|
488
|
+
this.#heartbeatHandlers.forEach((h) => h());
|
|
489
|
+
}
|
|
490
|
+
} catch { /* ignore */ }
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ═══════════════════════════════════════════════════════════
|
|
496
|
+
// Tier 3 — HTTP POST probe (uses health endpoint)
|
|
497
|
+
// ═══════════════════════════════════════════════════════════
|
|
498
|
+
|
|
499
|
+
async #tryPollOnce(timeoutMs: number): Promise<boolean> {
|
|
500
|
+
try {
|
|
501
|
+
const url = this.#baseUrl + "/api/health";
|
|
502
|
+
const headers: Record<string, string> = {};
|
|
503
|
+
const token = this.#getToken();
|
|
504
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
505
|
+
|
|
506
|
+
const resp = await fetch(url, {
|
|
507
|
+
method: "GET",
|
|
508
|
+
headers,
|
|
509
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
510
|
+
credentials: "include",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!resp.ok) return false;
|
|
514
|
+
|
|
515
|
+
return true;
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ═══════════════════════════════════════════════════════════
|
|
522
|
+
// Tier 0 → 1 downgrade
|
|
523
|
+
// ═══════════════════════════════════════════════════════════
|
|
524
|
+
|
|
525
|
+
#downgradeToWs(): void {
|
|
526
|
+
this.#tier = "ws";
|
|
527
|
+
this.#teardownAll();
|
|
528
|
+
this.#progressiveConnect();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ═══════════════════════════════════════════════════════════
|
|
532
|
+
// HTTP POST (used by all tiers)
|
|
533
|
+
// ═══════════════════════════════════════════════════════════
|
|
534
|
+
|
|
535
|
+
async #sendOverHttp<T>(method: string, params: unknown, timeoutMs: number, retried = false): Promise<T> {
|
|
536
|
+
const url = this.#baseUrl + this.#rpcPath;
|
|
537
|
+
const controller = new AbortController();
|
|
538
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
542
|
+
const token = this.#getToken();
|
|
543
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
544
|
+
headers["X-Session-Id"] = this.#sessionId;
|
|
545
|
+
|
|
546
|
+
const resp = await fetch(url, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers,
|
|
549
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "http-" + (++this.#idCounter).toString(36), method, params }),
|
|
550
|
+
signal: controller.signal,
|
|
551
|
+
credentials: "include",
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (resp.status === 401) {
|
|
555
|
+
if (this.#refreshToken && !retried) {
|
|
556
|
+
const fresh = await this.#refreshToken();
|
|
557
|
+
if (fresh) {
|
|
558
|
+
return this.#sendOverHttp(method, params, timeoutMs, true);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
this.#authLostHandlers.forEach((h) => h());
|
|
562
|
+
this.#onAuthLost?.();
|
|
563
|
+
throw new RpcError("forbidden", method, "unauthorized");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (!resp.ok) {
|
|
567
|
+
throw new RpcError("transport", method, `HTTP ${resp.status}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const body = await resp.json() as any;
|
|
571
|
+
if (body.error) {
|
|
572
|
+
const msg: string = body.error.message || "unknown rpc error";
|
|
573
|
+
const kind: RpcErrorKind = body.error.code === -32003 ? "forbidden" : "rpc";
|
|
574
|
+
throw new RpcError(kind, method, msg);
|
|
575
|
+
}
|
|
576
|
+
return body.result as T;
|
|
577
|
+
} catch (e) {
|
|
578
|
+
if (e instanceof RpcError) throw e;
|
|
579
|
+
if (e instanceof DOMException && e.name === "AbortError") {
|
|
580
|
+
throw new RpcError("timeout", method, `HTTP call '${method}' timed out`);
|
|
581
|
+
}
|
|
582
|
+
throw new RpcError("transport", method, String(e));
|
|
583
|
+
} finally {
|
|
584
|
+
clearTimeout(timer);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ═══════════════════════════════════════════════════════════
|
|
589
|
+
// Heartbeat (WS only)
|
|
590
|
+
// ═══════════════════════════════════════════════════════════
|
|
591
|
+
|
|
592
|
+
#startHeartbeat(): void {
|
|
593
|
+
this.#clearHeartbeat();
|
|
594
|
+
this.#hbTimer = setInterval(() => {
|
|
595
|
+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) return;
|
|
596
|
+
if (this.#hbAckTimer) return;
|
|
597
|
+
try {
|
|
598
|
+
this.#hbSentAt = performance.now();
|
|
599
|
+
this.#ws.send(JSON.stringify({ jsonrpc: "2.0", method: "Base.Heartbeat" }));
|
|
600
|
+
this.#hbAckTimer = setTimeout(() => {
|
|
601
|
+
this.#hbAckTimer = null;
|
|
602
|
+
this.#hbSentAt = null;
|
|
603
|
+
if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
|
|
604
|
+
this.#ws.close(4000, "heartbeat timeout");
|
|
605
|
+
}
|
|
606
|
+
}, this.#heartbeatTimeout);
|
|
607
|
+
} catch { /* ignore */ }
|
|
608
|
+
}, this.#heartbeatInterval);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Notify state handlers about a fresh latency measurement (partial event). */
|
|
612
|
+
#emitLatency(): void {
|
|
613
|
+
const latencyMs = this.#latencyMs;
|
|
614
|
+
if (latencyMs === null) return;
|
|
615
|
+
this.#stateHandlers.forEach((h) => h({
|
|
616
|
+
state: this.#state,
|
|
617
|
+
latencyMs,
|
|
618
|
+
transportTier: this.#tier,
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
#resetHeartbeatTimeout(): void {
|
|
623
|
+
if (this.#hbAckTimer) { clearTimeout(this.#hbAckTimer); this.#hbAckTimer = null; }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#clearHeartbeat(): void {
|
|
627
|
+
if (this.#hbTimer) { clearInterval(this.#hbTimer); this.#hbTimer = null; }
|
|
628
|
+
this.#resetHeartbeatTimeout();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ═══════════════════════════════════════════════════════════
|
|
632
|
+
// Teardown
|
|
633
|
+
// ═══════════════════════════════════════════════════════════
|
|
634
|
+
|
|
635
|
+
#teardownAll(): void {
|
|
636
|
+
this.#eventSource?.close();
|
|
637
|
+
this.#eventSource = null;
|
|
638
|
+
if (this.#pollTimer) { clearInterval(this.#pollTimer); this.#pollTimer = null; }
|
|
639
|
+
this.#clearHeartbeat();
|
|
640
|
+
this.#latencyMs = null;
|
|
641
|
+
this.#hbSentAt = null;
|
|
642
|
+
|
|
643
|
+
if (this.#ws) {
|
|
644
|
+
this.#ws.onclose = null;
|
|
645
|
+
this.#ws.onerror = null;
|
|
646
|
+
this.#ws.onmessage = null;
|
|
647
|
+
this.#ws.onopen = null;
|
|
648
|
+
this.#ws.close();
|
|
649
|
+
this.#ws = null;
|
|
650
|
+
}
|
|
651
|
+
this.#wsGen++;
|
|
652
|
+
this.#rejectAllPending("disconnected");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
#setState(state: ConnectionState, retryIn?: number, transportTier?: string, attemptNumber?: number, countdown?: number): void {
|
|
656
|
+
this.#state = state;
|
|
657
|
+
this.#stateHandlers.forEach((h) => h({
|
|
658
|
+
state,
|
|
659
|
+
retryIn,
|
|
660
|
+
retryCount: MAX_RETRIES - this.#retryCount >= 0 ? this.#retryCount : undefined,
|
|
661
|
+
maxRetries: MAX_RETRIES,
|
|
662
|
+
transportTier,
|
|
663
|
+
attemptNumber,
|
|
664
|
+
countdown,
|
|
665
|
+
}));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
#rejectAllPending(reason: string): void {
|
|
669
|
+
for (const [, entry] of this.#pending) {
|
|
670
|
+
clearTimeout(entry.timer);
|
|
671
|
+
entry.reject(new RpcError("transport", entry.method, reason));
|
|
672
|
+
}
|
|
673
|
+
this.#pending.clear();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function sleep(ms: number): Promise<void> {
|
|
678
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Session id for the RPC client. `crypto.randomUUID()` only exists in secure
|
|
683
|
+
* contexts (https or localhost); plain-http deployments would crash at
|
|
684
|
+
* startup, so fall back to a local random id there.
|
|
685
|
+
*/
|
|
686
|
+
function randomSessionId(): string {
|
|
687
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
688
|
+
return crypto.randomUUID();
|
|
689
|
+
}
|
|
690
|
+
return `sess-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
|
691
|
+
}
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"rootDir": "src"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|