Files
gh-dev-gom-claude-code-mark…/skills/scripts/src/unity/client.ts
2025-11-29 18:19:28 +08:00

344 lines
9.2 KiB
TypeScript

/**
* 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<string | number, PendingRequest>();
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<void> {
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<T = unknown>(method: string, params?: unknown, timeout?: number): Promise<T> {
// 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<void> {
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);
}