committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
239
src/lib/gateway-client.ts
Normal file
239
src/lib/gateway-client.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user