239 lines
5.4 KiB
TypeScript
239 lines
5.4 KiB
TypeScript
/**
|
|
* Lightweight read-RPC client over the `/ws` WebSocket.
|
|
*
|
|
* Each `rpcCall` opens a short-lived WebSocket, sends an `rpc_request` frame,
|
|
* waits for the matching `rpc_response`, then closes the connection.
|
|
*
|
|
* On a transient connection failure the call is retried once before rejecting,
|
|
* which lets a freshly-started backend race finish before the user sees an
|
|
* error. Failures surface as `Error` instances whose `.message` is intended
|
|
* to be visible (toast / banner) — callers must not swallow them silently.
|
|
*/
|
|
|
|
let correlationCounter = 0;
|
|
|
|
function nextCorrelationId(): string {
|
|
return `rpc-${Date.now()}-${++correlationCounter}`;
|
|
}
|
|
|
|
/**
|
|
* Build the WebSocket URL for the `/ws` endpoint, deriving the protocol
|
|
* (ws/wss) and host from the current page location.
|
|
*/
|
|
function buildWsUrl(): string {
|
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
return `${proto}//${window.location.host}/ws`;
|
|
}
|
|
|
|
export interface RpcResponse<T = unknown> {
|
|
ok: boolean;
|
|
result?: T;
|
|
error?: string;
|
|
code?: string;
|
|
}
|
|
|
|
/** Error subclass for RPC failures so callers can recognise them. */
|
|
export class RpcError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code?: string,
|
|
public readonly method?: string,
|
|
) {
|
|
super(message);
|
|
this.name = "RpcError";
|
|
}
|
|
}
|
|
|
|
/** Maximum number of automatic retries on transient WebSocket failure. */
|
|
const MAX_RETRIES = 1;
|
|
|
|
/** Delay between retry attempts (ms). */
|
|
const RETRY_DELAY_MS = 250;
|
|
|
|
/**
|
|
* Internal: a single one-shot RPC attempt. Resolves with the result or
|
|
* rejects with an `RpcError`.
|
|
*/
|
|
function rpcAttempt<T>(
|
|
method: string,
|
|
params: object,
|
|
timeoutMs: number,
|
|
): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const correlationId = nextCorrelationId();
|
|
let ws: WebSocket;
|
|
try {
|
|
ws = new WebSocket(buildWsUrl());
|
|
} catch (err) {
|
|
reject(
|
|
new RpcError(
|
|
`Failed to open WebSocket for ${method}: ${(err as Error).message}`,
|
|
"CONNECT_FAILED",
|
|
method,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
let settled = false;
|
|
|
|
const timer = setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
reject(new RpcError(`RPC timeout for ${method}`, "TIMEOUT", method));
|
|
}
|
|
}, timeoutMs);
|
|
|
|
ws.onopen = () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
kind: "rpc_request",
|
|
version: 1,
|
|
correlation_id: correlationId,
|
|
ttl_ms: timeoutMs,
|
|
method,
|
|
params,
|
|
}),
|
|
);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
let data: unknown;
|
|
try {
|
|
data = JSON.parse(event.data);
|
|
} catch {
|
|
// Non-JSON frame is not ours — keep waiting.
|
|
return;
|
|
}
|
|
if (!data || typeof data !== "object") {
|
|
return;
|
|
}
|
|
const frame = data as {
|
|
kind?: unknown;
|
|
correlation_id?: unknown;
|
|
ok?: unknown;
|
|
result?: unknown;
|
|
error?: unknown;
|
|
code?: unknown;
|
|
};
|
|
if (frame.kind !== "rpc_response" || frame.correlation_id !== correlationId) {
|
|
// Not addressed to this call — ignore (pipeline_state, etc.).
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (typeof frame.ok !== "boolean") {
|
|
reject(
|
|
new RpcError(
|
|
`Malformed RPC response for ${method}: missing or non-boolean 'ok' field`,
|
|
"MALFORMED",
|
|
method,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (frame.ok) {
|
|
if (!("result" in frame)) {
|
|
reject(
|
|
new RpcError(
|
|
`Malformed RPC response for ${method}: 'ok:true' frame missing 'result' field`,
|
|
"MALFORMED",
|
|
method,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve(frame.result as T);
|
|
} else {
|
|
const errMsg =
|
|
typeof frame.error === "string" ? frame.error : undefined;
|
|
const errCode = typeof frame.code === "string" ? frame.code : undefined;
|
|
reject(
|
|
new RpcError(
|
|
errMsg || `RPC error: ${errCode || "UNKNOWN"}`,
|
|
errCode,
|
|
method,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(
|
|
new RpcError(
|
|
`WebSocket error during RPC call to ${method}`,
|
|
"CONNECT_FAILED",
|
|
method,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (!settled) {
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(
|
|
new RpcError(
|
|
`WebSocket closed before RPC response for ${method}`,
|
|
"CONNECT_FAILED",
|
|
method,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Return true if the error is one we should retry (connection-level). */
|
|
function isRetryable(err: unknown): boolean {
|
|
return (
|
|
err instanceof RpcError &&
|
|
(err.code === "CONNECT_FAILED" || err.code === "TIMEOUT")
|
|
);
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
/**
|
|
* Send a read-RPC request over a temporary WebSocket connection and return
|
|
* the result. On transient connection failure the call is retried once
|
|
* before rejecting. Rejects with [`RpcError`] on server-side errors,
|
|
* timeouts, or persistent connection failures.
|
|
*/
|
|
export async function rpcCall<T = unknown>(
|
|
method: string,
|
|
params: object = {},
|
|
timeoutMs = 5000,
|
|
): Promise<T> {
|
|
let lastErr: unknown;
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
try {
|
|
return await rpcAttempt<T>(method, params, timeoutMs);
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (attempt < MAX_RETRIES && isRetryable(err)) {
|
|
await sleep(RETRY_DELAY_MS);
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
// Unreachable but TypeScript can't prove it.
|
|
throw lastErr;
|
|
}
|