Files
huskies/frontend/src/api/rpc.ts
T

239 lines
5.4 KiB
TypeScript
Raw Normal View History

2026-04-28 15:31:29 +00:00
/**
* Lightweight read-RPC client over the `/ws` WebSocket.
*
2026-05-13 04:43:48 +00:00
* 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.
2026-04-28 15:31:29 +00:00
*/
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;
}
2026-05-13 04:43:48 +00:00
/** 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;
2026-04-28 15:31:29 +00:00
/**
2026-05-13 04:43:48 +00:00
* Internal: a single one-shot RPC attempt. Resolves with the result or
* rejects with an `RpcError`.
2026-04-28 15:31:29 +00:00
*/
2026-05-13 04:43:48 +00:00
function rpcAttempt<T>(
2026-04-28 15:31:29 +00:00
method: string,
2026-05-13 07:10:00 +00:00
params: object,
2026-05-13 04:43:48 +00:00
timeoutMs: number,
2026-04-28 15:31:29 +00:00
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const correlationId = nextCorrelationId();
2026-05-13 04:43:48 +00:00
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;
}
2026-04-28 15:31:29 +00:00
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
2026-05-13 04:43:48 +00:00
try {
ws.close();
} catch {
/* ignore */
}
reject(new RpcError(`RPC timeout for ${method}`, "TIMEOUT", method));
2026-04-28 15:31:29 +00:00
}
}, timeoutMs);
ws.onopen = () => {
ws.send(
JSON.stringify({
kind: "rpc_request",
version: 1,
correlation_id: correlationId,
ttl_ms: timeoutMs,
method,
params,
}),
);
};
ws.onmessage = (event) => {
2026-05-13 07:10:00 +00:00
let data: unknown;
2026-04-28 15:31:29 +00:00
try {
2026-05-13 07:10:00 +00:00
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();
2026-04-28 15:31:29 +00:00
} catch {
2026-05-13 07:10:00 +00:00
/* 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,
),
);
2026-04-28 15:31:29 +00:00
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
clearTimeout(timer);
2026-05-13 04:43:48 +00:00
reject(
new RpcError(
`WebSocket error during RPC call to ${method}`,
"CONNECT_FAILED",
method,
),
);
2026-04-28 15:31:29 +00:00
}
};
ws.onclose = () => {
if (!settled) {
settled = true;
clearTimeout(timer);
2026-05-13 04:43:48 +00:00
reject(
new RpcError(
`WebSocket closed before RPC response for ${method}`,
"CONNECT_FAILED",
method,
),
);
2026-04-28 15:31:29 +00:00
}
};
});
}
2026-05-13 04:43:48 +00:00
/** 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,
2026-05-13 07:10:00 +00:00
params: object = {},
2026-05-13 04:43:48 +00:00
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;
}