108 lines
2.5 KiB
TypeScript
108 lines
2.5 KiB
TypeScript
/**
|
|||
|
|
* Lightweight read-RPC client over the `/ws` WebSocket.
|
||
|
|
*
|
||
|
|
* Opens a short-lived WebSocket, sends an `rpc_request` frame, waits for the
|
||
|
|
* matching `rpc_response`, then closes the connection.
|
||
|
|
*/
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send a read-RPC request over a temporary WebSocket connection and return
|
||
|
|
* the result. Rejects if the server responds with `ok: false` or if the
|
||
|
|
* connection times out.
|
||
|
|
*/
|
||
|
|
export function rpcCall<T = unknown>(
|
||
|
|
method: string,
|
||
|
|
params: Record<string, unknown> = {},
|
||
|
|
timeoutMs = 5000,
|
||
|
|
): Promise<T> {
|
||
|
|
return new Promise<T>((resolve, reject) => {
|
||
|
|
const correlationId = nextCorrelationId();
|
||
|
|
const ws = new WebSocket(buildWsUrl());
|
||
|
|
let settled = false;
|
||
|
|
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
if (!settled) {
|
||
|
|
settled = true;
|
||
|
|
ws.close();
|
||
|
|
reject(new Error(`RPC timeout for ${method}`));
|
||
|
|
}
|
||
|
|
}, timeoutMs);
|
||
|
|
|
||
|
|
ws.onopen = () => {
|
||
|
|
ws.send(
|
||
|
|
JSON.stringify({
|
||
|
|
kind: "rpc_request",
|
||
|
|
version: 1,
|
||
|
|
correlation_id: correlationId,
|
||
|
|
ttl_ms: timeoutMs,
|
||
|
|
method,
|
||
|
|
params,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onmessage = (event) => {
|
||
|
|
try {
|
||
|
|
const data = JSON.parse(event.data);
|
||
|
|
// Only process rpc_response frames matching our correlation ID.
|
||
|
|
if (
|
||
|
|
data.kind === "rpc_response" &&
|
||
|
|
data.correlation_id === correlationId
|
||
|
|
) {
|
||
|
|
settled = true;
|
||
|
|
clearTimeout(timer);
|
||
|
|
ws.close();
|
||
|
|
if (data.ok) {
|
||
|
|
resolve(data.result as T);
|
||
|
|
} else {
|
||
|
|
reject(
|
||
|
|
new Error(data.error || `RPC error: ${data.code || "UNKNOWN"}`),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Ignore other messages (pipeline_state, onboarding_status, etc.)
|
||
|
|
} catch {
|
||
|
|
// Ignore non-JSON or unparseable messages
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onerror = () => {
|
||
|
|
if (!settled) {
|
||
|
|
settled = true;
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(new Error(`WebSocket error during RPC call to ${method}`));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
ws.onclose = () => {
|
||
|
|
if (!settled) {
|
||
|
|
settled = true;
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(new Error(`WebSocket closed before RPC response for ${method}`));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|