/** * Unity WebSocket Client * * WebSocket client for communicating with Unity Editor via JSON-RPC 2.0 protocol. */ import WebSocket from 'ws'; import { UNITY, JSONRPC } from '@/constants'; import * as logger from '@/utils/logger'; import type { JSONRPCRequest, JSONRPCResponse, JSONRPCErrorResponse, isErrorResponse, } from './protocol'; /** * Custom error class for Unity RPC errors */ export class UnityRPCError extends Error { code: number; data?: unknown; constructor(message: string, code: number, data?: unknown) { super(message); this.name = 'UnityRPCError'; this.code = code; this.data = data; } } /** * Pending request information */ interface PendingRequest { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; method: string; } /** * Unity WebSocket Client */ export class UnityWebSocketClient { private ws: WebSocket | null = null; private connected = false; private host: string; private port: number; private reconnectAttempts = 0; private maxReconnectAttempts = UNITY.MAX_RECONNECT_ATTEMPTS; private reconnectDelay = UNITY.RECONNECT_DELAY; private pendingRequests = new Map(); private requestIdCounter = 0; /** * Create Unity WebSocket Client */ constructor(port: number, host: string = UNITY.LOCALHOST) { // Validate port range (security: prevent invalid connections) if (port < UNITY.DEFAULT_PORT || port > UNITY.MAX_PORT) { throw new Error(`Port must be between ${UNITY.DEFAULT_PORT} and ${UNITY.MAX_PORT}`); } // Validate host (security: only allow localhost) if (host !== '127.0.0.1' && host !== 'localhost') { throw new Error('Only localhost connections are allowed'); } this.host = host; this.port = port; } /** * Connect to Unity Editor WebSocket server */ async connect(): Promise { return new Promise((resolve, reject) => { const wsUrl = `ws://${this.host}:${this.port}`; logger.debug(`Connecting to Unity Editor at ${wsUrl}...`); const ws = new WebSocket(wsUrl, { handshakeTimeout: UNITY.CONNECT_TIMEOUT, }); const timeout = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) { ws.terminate(); reject(new Error(`Connection timeout after ${UNITY.CONNECT_TIMEOUT}ms`)); } }, UNITY.CONNECT_TIMEOUT); ws.on('open', () => { clearTimeout(timeout); this.ws = ws; this.connected = true; this.reconnectAttempts = 0; logger.info('✓ Connected to Unity Editor'); resolve(); }); ws.on('message', (data: WebSocket.Data) => { this.handleMessage(data); }); ws.on('error', (error: Error) => { clearTimeout(timeout); logger.error('WebSocket error', error); if (!this.connected) { reject(error); } }); ws.on('close', (code: number, reason: string) => { clearTimeout(timeout); this.connected = false; logger.warn(`WebSocket closed (code: ${code}, reason: ${reason})`); // Reject all pending requests this.rejectAllPendingRequests(new Error('WebSocket connection closed')); // Auto-reconnect if needed if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } }); }); } /** * Disconnect from Unity Editor */ disconnect(): void { if (this.ws) { // Reject all pending requests before closing this.rejectAllPendingRequests(new Error('Client disconnected')); // Close WebSocket connection this.ws.close(); // Remove all event listeners this.ws.removeAllListeners(); this.ws = null; this.connected = false; // Prevent reconnection this.reconnectAttempts = this.maxReconnectAttempts; logger.info('Disconnected from Unity Editor'); } } /** * Check if connected */ isConnected(): boolean { return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN; } /** * Send JSON-RPC request to Unity Editor */ async sendRequest(method: string, params?: unknown, timeout?: number): Promise { // Validate input parameters if (!method || typeof method !== 'string' || method.trim() === '') { throw new UnityRPCError('Method name is required and must be a non-empty string', JSONRPC.INVALID_PARAMS); } if (timeout !== undefined && (typeof timeout !== 'number' || timeout <= 0)) { throw new UnityRPCError('Timeout must be a positive number', JSONRPC.INVALID_PARAMS); } if (!this.isConnected()) { throw new UnityRPCError('Not connected to Unity Editor', JSONRPC.UNITY_NOT_CONNECTED); } const requestId = `req_${++this.requestIdCounter}`; const request: JSONRPCRequest = { jsonrpc: JSONRPC.VERSION, id: requestId, method: method.trim(), params, }; return new Promise((resolve, reject) => { const requestTimeout = timeout || UNITY.COMMAND_TIMEOUT; const timer = setTimeout(() => { this.pendingRequests.delete(requestId); // Log timeout with detailed information for debugging logger.warn(`Request timeout: ${method} (ID: ${requestId}, timeout: ${requestTimeout}ms)`); // Create detailed timeout error const timeoutError = new UnityRPCError( `Request timeout after ${requestTimeout}ms`, JSONRPC.INTERNAL_ERROR, { method, requestId, timeout: requestTimeout, params, timestamp: new Date().toISOString(), } ); reject(timeoutError); }, requestTimeout); this.pendingRequests.set(requestId, { resolve, reject, timer, method, }); const message = JSON.stringify(request); logger.debug(`→ ${method}: ${message}`); this.ws!.send(message, (error) => { if (error) { clearTimeout(timer); this.pendingRequests.delete(requestId); const sendError = new UnityRPCError( `Failed to send request: ${error.message}`, JSONRPC.INTERNAL_ERROR, { method, requestId, originalError: error.message } ); reject(sendError); } }); }); } /** * Handle incoming WebSocket message */ private handleMessage(data: WebSocket.Data): void { let message: string; let response: any; try { message = data.toString(); logger.debug(`← ${message}`); response = JSON.parse(message); } catch (error) { logger.error('Failed to parse message', error); return; // Parsing failed, pending requests will timeout } // Validate JSON-RPC structure if (!response || typeof response !== 'object') { logger.error('Invalid response structure'); return; } if (!response.id) { logger.warn('Received response without ID, ignoring'); return; } const pending = this.pendingRequests.get(response.id); if (!pending) { logger.warn(`Received response for unknown request ID: ${response.id}`); return; } clearTimeout(pending.timer); this.pendingRequests.delete(response.id); try { if ('error' in response) { const errorResponse = response as JSONRPCErrorResponse; const error = new UnityRPCError( errorResponse.error.message, errorResponse.error.code, errorResponse.error.data ); pending.reject(error); } else if ('result' in response) { pending.resolve(response.result); } else { pending.reject(new Error('Invalid JSON-RPC response')); } } catch (error) { logger.error('Error processing response', error); pending.reject(error instanceof Error ? error : new Error(String(error))); } } /** * Attempt to reconnect */ private async attemptReconnect(): Promise { this.reconnectAttempts++; logger.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay)); try { await this.connect(); logger.info('✓ Reconnected successfully'); } catch (error) { logger.error('Reconnection failed', error); if (this.reconnectAttempts < this.maxReconnectAttempts) { this.attemptReconnect(); } else { logger.error('Max reconnection attempts reached'); } } } /** * Reject all pending requests */ private rejectAllPendingRequests(error: Error): void { for (const [id, pending] of this.pendingRequests.entries()) { clearTimeout(pending.timer); pending.reject(error); } this.pendingRequests.clear(); } /** * Get connection info */ getConnectionInfo(): { host: string; port: number; connected: boolean } { return { host: this.host, port: this.port, connected: this.connected, }; } } /** * Create Unity WebSocket client */ export function createUnityClient(port: number, host?: string): UnityWebSocketClient { return new UnityWebSocketClient(port, host); }