Files
DeskClaw/src/lib/gateway-client.ts
paisley 2c5c82bb74 Refactor clawx (#344)
Co-authored-by: ashione <skyzlxuan@gmail.com>
2026-03-09 13:10:42 +08:00

240 lines
6.8 KiB
TypeScript

import { hostApiFetch } from './host-api';
type GatewayInfo = {
wsUrl: string;
token: string;
port: number;
};
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
};
type GatewayEventHandler = (payload: unknown) => void;
class GatewayBrowserClient {
private ws: WebSocket | null = null;
private connectPromise: Promise<void> | null = null;
private gatewayInfo: GatewayInfo | null = null;
private pendingRequests = new Map<string, PendingRequest>();
private eventHandlers = new Map<string, Set<GatewayEventHandler>>();
async connect(): Promise<void> {
if (this.ws?.readyState === WebSocket.OPEN) {
return;
}
if (this.connectPromise) {
await this.connectPromise;
return;
}
this.connectPromise = this.openSocket();
try {
await this.connectPromise;
} finally {
this.connectPromise = null;
}
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
for (const [, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Gateway connection closed'));
}
this.pendingRequests.clear();
}
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
await this.connect();
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Gateway socket is not connected');
}
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const request = {
type: 'req',
id,
method,
params,
};
return await new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Gateway RPC timeout: ${method}`));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
this.ws!.send(JSON.stringify(request));
});
}
on(eventName: string, handler: GatewayEventHandler): () => void {
const handlers = this.eventHandlers.get(eventName) || new Set<GatewayEventHandler>();
handlers.add(handler);
this.eventHandlers.set(eventName, handlers);
return () => {
const current = this.eventHandlers.get(eventName);
current?.delete(handler);
if (current && current.size === 0) {
this.eventHandlers.delete(eventName);
}
};
}
private async openSocket(): Promise<void> {
this.gatewayInfo = await hostApiFetch<GatewayInfo>('/api/app/gateway-info');
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(this.gatewayInfo!.wsUrl);
let resolved = false;
let challengeTimer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
};
const resolveOnce = () => {
if (!resolved) {
resolved = true;
cleanup();
resolve();
}
};
const rejectOnce = (error: Error) => {
if (!resolved) {
resolved = true;
cleanup();
reject(error);
}
};
ws.onopen = () => {
challengeTimer = setTimeout(() => {
rejectOnce(new Error('Gateway connect challenge timeout'));
ws.close();
}, 10000);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(String(event.data)) as Record<string, unknown>;
if (message.type === 'event' && message.event === 'connect.challenge') {
const nonce = (message.payload as { nonce?: string } | undefined)?.nonce;
if (!nonce) {
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
return;
}
const connectFrame = {
type: 'req',
id: `connect-${Date.now()}`,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
displayName: 'ClawX',
version: '0.1.0',
platform: navigator.platform,
mode: 'ui',
},
auth: {
token: this.gatewayInfo?.token,
},
caps: [],
role: 'operator',
scopes: ['operator.admin'],
},
};
ws.send(JSON.stringify(connectFrame));
return;
}
if (message.type === 'res' && typeof message.id === 'string') {
if (String(message.id).startsWith('connect-')) {
this.ws = ws;
resolveOnce();
return;
}
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
if (message.ok === false || message.error) {
const errorMessage = typeof message.error === 'object' && message.error !== null
? String((message.error as { message?: string }).message || JSON.stringify(message.error))
: String(message.error || 'Gateway request failed');
pending.reject(new Error(errorMessage));
} else {
pending.resolve(message.payload);
}
return;
}
if (message.type === 'event' && typeof message.event === 'string') {
this.emitEvent(message.event, message.payload);
return;
}
if (typeof message.method === 'string') {
this.emitEvent(message.method, message.params);
}
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error(String(error)));
}
};
ws.onerror = () => {
rejectOnce(new Error('Gateway WebSocket error'));
};
ws.onclose = () => {
this.ws = null;
if (!resolved) {
rejectOnce(new Error('Gateway WebSocket closed before connect'));
return;
}
for (const [, request] of this.pendingRequests) {
clearTimeout(request.timeout);
request.reject(new Error('Gateway connection closed'));
}
this.pendingRequests.clear();
this.emitEvent('__close__', null);
};
});
}
private emitEvent(eventName: string, payload: unknown): void {
const handlers = this.eventHandlers.get(eventName);
if (!handlers) return;
for (const handler of handlers) {
try {
handler(payload);
} catch {
// ignore handler failures
}
}
}
}
export const gatewayClient = new GatewayBrowserClient();