mirror of https://github.com/jetkvm/kvm.git
262 lines
7.5 KiB
TypeScript
262 lines
7.5 KiB
TypeScript
import { useRTCStore } from "@/hooks/stores";
|
|
import { sleep } from "@/utils";
|
|
|
|
// JSON-RPC utility for use outside of React components
|
|
|
|
export interface JsonRpcCallOptions {
|
|
method: string;
|
|
params?: unknown;
|
|
attemptTimeoutMs?: number;
|
|
maxAttempts?: number;
|
|
}
|
|
|
|
export interface JsonRpcCallResponse<T = unknown> {
|
|
jsonrpc: string;
|
|
result?: T;
|
|
error?: {
|
|
code: number;
|
|
message: string;
|
|
data?: unknown;
|
|
};
|
|
id: number | string | null;
|
|
}
|
|
|
|
let rpcCallCounter = 0;
|
|
|
|
// Helper: wait for RTC data channel to be ready
|
|
// This waits indefinitely for the channel to be ready, only aborting via the signal
|
|
// Throws if the channel instance changed while waiting (stale connection detected)
|
|
async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
|
|
const pollInterval = 100;
|
|
let lastSeenChannel: RTCDataChannel | null = null;
|
|
|
|
while (!signal.aborted) {
|
|
const state = useRTCStore.getState();
|
|
const currentChannel = state.rpcDataChannel;
|
|
|
|
// Channel instance changed (new connection replaced old one)
|
|
if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) {
|
|
console.debug("[waitForRtcReady] Channel instance changed, aborting wait");
|
|
throw new Error("RTC connection changed while waiting for readiness");
|
|
}
|
|
|
|
// Channel was removed from store (connection closed)
|
|
if (lastSeenChannel && !currentChannel) {
|
|
console.debug("[waitForRtcReady] Channel was removed from store, aborting wait");
|
|
throw new Error("RTC connection was closed while waiting for readiness");
|
|
}
|
|
|
|
// No channel yet, keep waiting
|
|
if (!currentChannel) {
|
|
await sleep(pollInterval);
|
|
continue;
|
|
}
|
|
|
|
// Track this channel instance
|
|
lastSeenChannel = currentChannel;
|
|
|
|
// Channel is ready!
|
|
if (currentChannel.readyState === "open") {
|
|
return currentChannel;
|
|
}
|
|
|
|
await sleep(pollInterval);
|
|
}
|
|
|
|
// Signal was aborted for some reason
|
|
console.debug("[waitForRtcReady] Aborted via signal");
|
|
throw new Error("RTC readiness check aborted");
|
|
}
|
|
|
|
// Helper: send RPC request and wait for response
|
|
async function sendRpcRequest<T>(
|
|
rpcDataChannel: RTCDataChannel,
|
|
options: JsonRpcCallOptions,
|
|
signal: AbortSignal,
|
|
): Promise<JsonRpcCallResponse<T>> {
|
|
return new Promise((resolve, reject) => {
|
|
rpcCallCounter++;
|
|
const requestId = `rpc_${Date.now()}_${rpcCallCounter}`;
|
|
|
|
const request = {
|
|
jsonrpc: "2.0",
|
|
method: options.method,
|
|
params: options.params || {},
|
|
id: requestId,
|
|
};
|
|
|
|
const messageHandler = (event: MessageEvent) => {
|
|
try {
|
|
const response = JSON.parse(event.data) as JsonRpcCallResponse<T>;
|
|
if (response.id === requestId) {
|
|
cleanup();
|
|
resolve(response);
|
|
}
|
|
} catch {
|
|
// Ignore parse errors from other messages
|
|
}
|
|
};
|
|
|
|
const abortHandler = () => {
|
|
cleanup();
|
|
reject(new Error("Request aborted"));
|
|
};
|
|
|
|
const cleanup = () => {
|
|
rpcDataChannel.removeEventListener("message", messageHandler);
|
|
signal.removeEventListener("abort", abortHandler);
|
|
};
|
|
|
|
signal.addEventListener("abort", abortHandler);
|
|
rpcDataChannel.addEventListener("message", messageHandler);
|
|
rpcDataChannel.send(JSON.stringify(request));
|
|
});
|
|
}
|
|
|
|
// Function overloads for better typing
|
|
export function callJsonRpc<T>(
|
|
options: JsonRpcCallOptions,
|
|
): Promise<JsonRpcCallResponse<T> & { result: T }>;
|
|
export function callJsonRpc(
|
|
options: JsonRpcCallOptions,
|
|
): Promise<JsonRpcCallResponse<unknown>>;
|
|
export async function callJsonRpc<T = unknown>(
|
|
options: JsonRpcCallOptions,
|
|
): Promise<JsonRpcCallResponse<T>> {
|
|
const maxAttempts = options.maxAttempts ?? 1;
|
|
const timeout = options.attemptTimeoutMs || 5000;
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
// Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds
|
|
const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000);
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
try {
|
|
// Wait for RTC readiness without timeout - this allows time for WebRTC to connect
|
|
const readyAbortController = new AbortController();
|
|
const rpcDataChannel = await waitForRtcReady(readyAbortController.signal);
|
|
|
|
// Now apply timeout only to the actual RPC request/response
|
|
const rpcAbortController = new AbortController();
|
|
timeoutId = setTimeout(() => rpcAbortController.abort(), timeout);
|
|
|
|
// Send RPC request and wait for response
|
|
const response = await sendRpcRequest<T>(
|
|
rpcDataChannel,
|
|
options,
|
|
rpcAbortController.signal,
|
|
);
|
|
|
|
// Retry on error if attempts remain
|
|
if (response.error && attempt < maxAttempts - 1) {
|
|
await sleep(backoffMs);
|
|
continue;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
// Retry on timeout/error if attempts remain
|
|
if (attempt < maxAttempts - 1) {
|
|
await sleep(backoffMs);
|
|
continue;
|
|
}
|
|
|
|
throw error instanceof Error
|
|
? error
|
|
: new Error(`JSON-RPC call failed after ${timeout}ms`);
|
|
} finally {
|
|
if (timeoutId !== null) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Should never reach here due to loop logic, but TypeScript needs this
|
|
throw new Error("Unexpected error in callJsonRpc");
|
|
}
|
|
|
|
// Specific network settings API calls
|
|
export async function getNetworkSettings() {
|
|
const response = await callJsonRpc({ method: "getNetworkSettings" });
|
|
if (response.error) {
|
|
throw new Error(response.error.message);
|
|
}
|
|
return response.result;
|
|
}
|
|
|
|
export async function setNetworkSettings(settings: unknown) {
|
|
const response = await callJsonRpc({
|
|
method: "setNetworkSettings",
|
|
params: { settings },
|
|
});
|
|
if (response.error) {
|
|
throw new Error(response.error.message);
|
|
}
|
|
return response.result;
|
|
}
|
|
|
|
export async function getNetworkState() {
|
|
const response = await callJsonRpc({ method: "getNetworkState" });
|
|
if (response.error) {
|
|
throw new Error(response.error.message);
|
|
}
|
|
return response.result;
|
|
}
|
|
|
|
export async function renewDHCPLease() {
|
|
const response = await callJsonRpc({ method: "renewDHCPLease" });
|
|
if (response.error) {
|
|
throw new Error(response.error.message);
|
|
}
|
|
return response.result;
|
|
}
|
|
|
|
export interface VersionInfo {
|
|
appVersion: string;
|
|
systemVersion: string;
|
|
}
|
|
|
|
export interface SystemVersionInfo {
|
|
local: VersionInfo;
|
|
remote?: VersionInfo;
|
|
systemUpdateAvailable: boolean;
|
|
appUpdateAvailable: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export async function getUpdateStatus() {
|
|
const response = await callJsonRpc<SystemVersionInfo>({
|
|
method: "getUpdateStatus",
|
|
// This function calls our api server to see if there are any updates available.
|
|
// It can be called on page load right after a restart, so we need to give it time to
|
|
// establish a connection to the api server.
|
|
maxAttempts: 6,
|
|
});
|
|
|
|
if (response.error) throw response.error;
|
|
return response.result;
|
|
}
|
|
|
|
export async function getLocalVersion() {
|
|
const response = await callJsonRpc<VersionInfo>({ method: "getLocalVersion" });
|
|
if (response.error) throw response.error;
|
|
return response.result;
|
|
}
|
|
|
|
export interface updateParams {
|
|
appTargetVersion?: string;
|
|
systemTargetVersion?: string;
|
|
components?: string[];
|
|
}
|
|
|
|
export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
|
|
const response = await callJsonRpc<SystemVersionInfo>({
|
|
method: "checkUpdateComponents",
|
|
params: {
|
|
params,
|
|
includePreRelease,
|
|
},
|
|
});
|
|
if (response.error) throw response.error;
|
|
return response.result;
|
|
} |