Spaces:
Sleeping
Sleeping
| /** | |
| * Core robotics client for RobotHub TransportServer | |
| * Base class providing REST API and WebSocket functionality | |
| */ | |
| import { EventEmitter } from 'eventemitter3'; | |
| import type { | |
| ParticipantRole, | |
| RoomInfo, | |
| RoomState, | |
| ConnectionInfo, | |
| WebSocketMessage, | |
| JoinMessage, | |
| ListRoomsResponse, | |
| CreateRoomResponse, | |
| GetRoomResponse, | |
| GetRoomStateResponse, | |
| DeleteRoomResponse, | |
| ClientOptions, | |
| ErrorCallback, | |
| ConnectedCallback, | |
| DisconnectedCallback, | |
| } from './types.js'; | |
| export class RoboticsClientCore extends EventEmitter { | |
| protected baseUrl: string; | |
| protected apiBase: string; | |
| protected websocket: WebSocket | null = null; | |
| protected workspaceId: string | null = null; | |
| protected roomId: string | null = null; | |
| protected role: ParticipantRole | null = null; | |
| protected participantId: string | null = null; | |
| protected connected = false; | |
| protected options: ClientOptions; | |
| // Event callbacks | |
| protected onErrorCallback: ErrorCallback | null = null; | |
| protected onConnectedCallback: ConnectedCallback | null = null; | |
| protected onDisconnectedCallback: DisconnectedCallback | null = null; | |
| constructor(baseUrl: string, options: ClientOptions = {}) { | |
| super(); | |
| this.baseUrl = baseUrl.replace(/\/$/, ''); | |
| this.apiBase = `${this.baseUrl}/robotics`; | |
| this.options = { | |
| timeout: 5000, | |
| reconnect_attempts: 3, | |
| heartbeat_interval: 30000, | |
| ...options, | |
| }; | |
| } | |
| // ============= REST API METHODS ============= | |
| async listRooms(workspaceId: string): Promise<RoomInfo[]> { | |
| const response = await this.fetchApi<ListRoomsResponse>(`/workspaces/${workspaceId}/rooms`); | |
| return response.rooms; | |
| } | |
| async createRoom(workspaceId?: string, roomId?: string): Promise<{ workspaceId: string; roomId: string }> { | |
| // Generate workspace ID if not provided | |
| const finalWorkspaceId = workspaceId || this.generateWorkspaceId(); | |
| const payload = roomId ? { room_id: roomId, workspace_id: finalWorkspaceId } : { workspace_id: finalWorkspaceId }; | |
| const response = await this.fetchApi<CreateRoomResponse>(`/workspaces/${finalWorkspaceId}/rooms`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| return { workspaceId: response.workspace_id, roomId: response.room_id }; | |
| } | |
| async deleteRoom(workspaceId: string, roomId: string): Promise<boolean> { | |
| try { | |
| const response = await this.fetchApi<DeleteRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`, { | |
| method: 'DELETE', | |
| }); | |
| return response.success; | |
| } catch (error) { | |
| if (error instanceof Error && error.message.includes('404')) { | |
| return false; | |
| } | |
| throw error; | |
| } | |
| } | |
| async getRoomState(workspaceId: string, roomId: string): Promise<RoomState> { | |
| const response = await this.fetchApi<GetRoomStateResponse>(`/workspaces/${workspaceId}/rooms/${roomId}/state`); | |
| return response.state; | |
| } | |
| async getRoomInfo(workspaceId: string, roomId: string): Promise<RoomInfo> { | |
| const response = await this.fetchApi<GetRoomResponse>(`/workspaces/${workspaceId}/rooms/${roomId}`); | |
| return response.room; | |
| } | |
| // ============= WEBSOCKET CONNECTION ============= | |
| async connectToRoom( | |
| workspaceId: string, | |
| roomId: string, | |
| role: ParticipantRole, | |
| participantId?: string | |
| ): Promise<boolean> { | |
| if (this.connected) { | |
| await this.disconnect(); | |
| } | |
| this.workspaceId = workspaceId; | |
| this.roomId = roomId; | |
| this.role = role; | |
| this.participantId = participantId || `${role}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| // Convert HTTP URL to WebSocket URL | |
| const wsUrl = this.baseUrl | |
| .replace(/^http/, 'ws') | |
| .replace(/^https/, 'wss'); | |
| const wsEndpoint = `${wsUrl}/robotics/workspaces/${workspaceId}/rooms/${roomId}/ws`; | |
| try { | |
| this.websocket = new WebSocket(wsEndpoint); | |
| // Set up WebSocket event handlers | |
| return new Promise((resolve, reject) => { | |
| const timeout = setTimeout(() => { | |
| reject(new Error('Connection timeout')); | |
| }, this.options.timeout || 5000); | |
| this.websocket!.onopen = () => { | |
| clearTimeout(timeout); | |
| this.sendJoinMessage(); | |
| }; | |
| this.websocket!.onmessage = (event) => { | |
| try { | |
| const message: WebSocketMessage = JSON.parse(event.data); | |
| this.handleMessage(message); | |
| // Handle initial connection responses | |
| if (message.type === 'joined') { | |
| this.connected = true; | |
| this.onConnectedCallback?.(); | |
| this.emit('connected'); | |
| resolve(true); | |
| } else if (message.type === 'error') { | |
| this.handleError(message.message); | |
| resolve(false); | |
| } | |
| } catch (error) { | |
| console.error('Failed to parse WebSocket message:', error); | |
| } | |
| }; | |
| this.websocket!.onerror = (error) => { | |
| clearTimeout(timeout); | |
| console.error('WebSocket error:', error); | |
| this.handleError('WebSocket connection error'); | |
| reject(error); | |
| }; | |
| this.websocket!.onclose = () => { | |
| clearTimeout(timeout); | |
| this.connected = false; | |
| this.onDisconnectedCallback?.(); | |
| this.emit('disconnected'); | |
| }; | |
| }); | |
| } catch (error) { | |
| console.error('Failed to connect to room:', error); | |
| return false; | |
| } | |
| } | |
| async disconnect(): Promise<void> { | |
| if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { | |
| this.websocket.close(); | |
| } | |
| this.websocket = null; | |
| this.connected = false; | |
| this.workspaceId = null; | |
| this.roomId = null; | |
| this.role = null; | |
| this.participantId = null; | |
| this.onDisconnectedCallback?.(); | |
| this.emit('disconnected'); | |
| } | |
| // ============= MESSAGE HANDLING ============= | |
| protected sendJoinMessage(): void { | |
| if (!this.websocket || !this.participantId || !this.role) return; | |
| const joinMessage: JoinMessage = { | |
| participant_id: this.participantId, | |
| role: this.role, | |
| }; | |
| this.websocket.send(JSON.stringify(joinMessage)); | |
| } | |
| protected handleMessage(message: WebSocketMessage): void { | |
| switch (message.type) { | |
| case 'joined': | |
| console.log(`Successfully joined room ${message.room_id} as ${message.role}`); | |
| break; | |
| case 'heartbeat_ack': | |
| console.debug('Heartbeat acknowledged'); | |
| break; | |
| case 'error': | |
| this.handleError(message.message); | |
| break; | |
| default: | |
| // Let subclasses handle specific message types | |
| this.handleRoleSpecificMessage(message); | |
| } | |
| } | |
| protected handleRoleSpecificMessage(message: WebSocketMessage): void { | |
| // To be overridden by subclasses | |
| this.emit('message', message); | |
| } | |
| protected handleError(errorMessage: string): void { | |
| console.error('Client error:', errorMessage); | |
| this.onErrorCallback?.(errorMessage); | |
| this.emit('error', errorMessage); | |
| } | |
| // ============= UTILITY METHODS ============= | |
| async sendHeartbeat(): Promise<void> { | |
| if (!this.connected || !this.websocket) return; | |
| const message = { type: 'heartbeat' as const }; | |
| this.websocket.send(JSON.stringify(message)); | |
| } | |
| isConnected(): boolean { | |
| return this.connected; | |
| } | |
| getConnectionInfo(): ConnectionInfo { | |
| return { | |
| connected: this.connected, | |
| workspace_id: this.workspaceId, | |
| room_id: this.roomId, | |
| role: this.role, | |
| participant_id: this.participantId, | |
| base_url: this.baseUrl, | |
| }; | |
| } | |
| // ============= EVENT CALLBACK SETTERS ============= | |
| onError(callback: ErrorCallback): void { | |
| this.onErrorCallback = callback; | |
| } | |
| onConnected(callback: ConnectedCallback): void { | |
| this.onConnectedCallback = callback; | |
| } | |
| onDisconnected(callback: DisconnectedCallback): void { | |
| this.onDisconnectedCallback = callback; | |
| } | |
| // ============= PRIVATE HELPERS ============= | |
| private async fetchApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> { | |
| const url = `${this.apiBase}${endpoint}`; | |
| const response = await fetch(url, { | |
| ...options, | |
| signal: AbortSignal.timeout(this.options.timeout || 5000), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| return response.json() as Promise<T>; | |
| } | |
| // ============= WORKSPACE HELPERS ============= | |
| protected generateWorkspaceId(): string { | |
| // Generate a UUID-like workspace ID | |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | |
| const r = Math.random() * 16 | 0; | |
| const v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| } |