Skip to main content
Glama
ws-client.ts23.5 kB
/** * WebSocket client for communicating with the VRChat OSC relay server. */ import { createLogger } from '@vrchat-mcp-osc/utils'; import { v4 as uuidv4 } from 'uuid'; import WebSocket from 'ws'; import { ParameterValue, WebSocketClientParams, WebSocketRequest } from './types/index.js'; /** * Client for communicating with the VRChat OSC relay server via WebSockets. */ export class WebSocketClient { private host: string; private port: number; private wsUrl: string; private reconnectAttempts: number; private reconnectDelay: number; private websocket: WebSocket | null = null; private connected = false; private connectionPromise: Promise<boolean> | null = null; private eventHandlers: Map<string, Array<(data: any) => Promise<void>>> = new Map(); private pendingRequests: Map<string, { resolve: (value: any) => void; reject: (reason: any) => void; timer: NodeJS.Timeout; }> = new Map(); private logger = createLogger('WebSocketClient'); /** * Initialize the WebSocket client. * * @param params - Client configuration parameters */ constructor(params: WebSocketClientParams) { this.host = params.host || 'localhost'; this.port = params.port || 8765; this.wsUrl = `ws://${this.host}:${this.port}`; this.reconnectAttempts = params.reconnectAttempts || 5; this.reconnectDelay = params.reconnectDelay || 2000; } /** * Connect to the WebSocket server. * * @returns Promise resolving to true if connection was successful, false otherwise */ public async connect(): Promise<boolean> { if (this.connectionPromise) { this.logger.info('Connection already in progress, returning existing promise'); return this.connectionPromise; } if (this.connected && this.websocket) { this.logger.info('Already connected to WebSocket server'); return true; } this.logger.info(`Connecting to WebSocket server at ${this.wsUrl}`); this.connectionPromise = new Promise<boolean>(async (resolve) => { for (let attempt = 0; attempt < this.reconnectAttempts; attempt++) { this.logger.info(`Connection attempt ${attempt + 1} of ${this.reconnectAttempts}`); try { // Create new WebSocket connection with timeout const connectionTimeout = setTimeout(() => { if (this.websocket && this.websocket.readyState !== WebSocket.OPEN) { this.logger.error('WebSocket connection timed out'); this.websocket.terminate(); } }, 10000); // 10 second connection timeout this.websocket = new WebSocket(this.wsUrl); // Set up event handlers const connectedPromise = new Promise<boolean>((resolveConn, rejectConn) => { if (!this.websocket) { clearTimeout(connectionTimeout); rejectConn(new Error('WebSocket is null')); return; } this.websocket.on('open', () => { clearTimeout(connectionTimeout); this.logger.info('Connected to WebSocket server'); this.connected = true; // Start message handler this.setupMessageHandler(); // Test connection with multiple retries for stability let testAttempts = 0; const maxTestAttempts = 3; const testConnection = async (): Promise<boolean> => { try { testAttempts++; this.logger.info(`Testing connection (attempt ${testAttempts} of ${maxTestAttempts})`); const success = await this.testConnection(); if (success) { this.logger.info('Connection test successful'); return true; } else if (testAttempts < maxTestAttempts) { this.logger.warn(`Connection test failed, retrying (${testAttempts}/${maxTestAttempts})`); await new Promise(r => setTimeout(r, 500)); // Short delay before retry return testConnection(); } else { this.logger.warn('All connection tests failed'); return false; } } catch (error) { if (testAttempts < maxTestAttempts) { this.logger.warn(`Connection test error: ${error instanceof Error ? error.message : String(error)}, retrying`); await new Promise(r => setTimeout(r, 500)); return testConnection(); } else { this.logger.error(`Connection test failed after ${maxTestAttempts} attempts`); throw error; } } }; testConnection() .then(success => { if (success) { resolveConn(true); } else { this.logger.warn('Connection test failed'); this.disconnect(); rejectConn(new Error('Connection test failed')); } }) .catch(error => { this.logger.error(`Connection test error: ${error instanceof Error ? error.message : String(error)}`); rejectConn(error); }); }); this.websocket.on('error', (error) => { this.logger.error(`WebSocket error: ${error.message}`); rejectConn(error); }); this.websocket.on('close', () => { this.logger.info('WebSocket connection closed'); this.connected = false; this.websocket = null; // Reject any pending requests this.pendingRequests.forEach((request, id) => { clearTimeout(request.timer); request.reject(new Error('WebSocket connection closed')); this.pendingRequests.delete(id); }); }); }); try { const success = await connectedPromise; resolve(success); this.connectionPromise = null; return success; } catch (error) { this.logger.warn(`Connection attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`); if (attempt < this.reconnectAttempts - 1) { this.logger.info(`Retrying in ${this.reconnectDelay / 1000} seconds...`); await new Promise(r => setTimeout(r, this.reconnectDelay)); } } } catch (error) { this.logger.error(`Error creating WebSocket: ${error instanceof Error ? error.message : String(error)}`); if (attempt < this.reconnectAttempts - 1) { this.logger.info(`Retrying in ${this.reconnectDelay / 1000} seconds...`); await new Promise(r => setTimeout(r, this.reconnectDelay)); } } } this.logger.error(`Failed to connect after ${this.reconnectAttempts} attempts`); this.connected = false; this.connectionPromise = null; resolve(false); return false; }); return this.connectionPromise; } /** * Disconnect from the WebSocket server. */ public async disconnect(): Promise<void> { this.logger.info('Disconnecting from WebSocket server'); if (this.websocket) { this.websocket.close(); this.websocket = null; } this.connected = false; this.logger.info('Disconnected from WebSocket server'); } /** * Set up handler for incoming WebSocket messages. */ private setupMessageHandler(): void { if (!this.websocket) { this.logger.error('Cannot handle messages: No WebSocket connection'); return; } this.websocket.on('message', (data: WebSocket.Data) => { try { // Get raw data as string const rawData = data.toString(); this.logger.debug(`Received WebSocket message: ${rawData.substring(0, 200)}${rawData.length > 200 ? '...' : ''}`); // Enhanced BOM handling let messageText = rawData; if (rawData.length > 0 && rawData.charCodeAt(0) === 0xFEFF) { messageText = rawData.substring(1); this.logger.debug('Removed BOM marker from message'); } // Ensure the text is properly trimmed messageText = messageText.trim(); if (messageText.length === 0) { this.logger.warn('Received empty message, ignoring'); return; } // Enhanced JSON parsing with additional error handling let message; try { message = JSON.parse(messageText); } catch (jsonError) { // Try to fix common JSON issues and retry this.logger.warn(`JSON parse error: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`); this.logger.debug(`Attempting to sanitize and retry parsing...`); // Handle potential invalid JSON by ensuring it starts and ends correctly const sanitized = messageText.replace(/\n/g, ' ').replace(/\t/g, ' '); try { message = JSON.parse(sanitized); this.logger.info('Successfully parsed JSON after sanitization'); } catch (retryError) { throw new Error(`Failed to parse JSON after sanitization: ${retryError instanceof Error ? retryError.message : String(retryError)}`); } } // Verify message has required properties before processing if (!message || typeof message !== 'object') { throw new Error('Message is not a valid object'); } this.processMessage(message); } catch (error) { this.logger.error(`Failed to parse message: ${error instanceof Error ? error.message : String(error)}`); this.logger.debug(`Raw message content (full): ${data.toString().replace(/[\r\n]/g, ' ')}`); // Add more diagnostic information if (data instanceof Buffer) { this.logger.debug(`Message as hex: ${data.toString('hex').substring(0, 100)}...`); } } }); } /** * Process an incoming WebSocket message. * * @param data - The parsed message data */ private processMessage(data: any): void { // Validate message structure if (!data || typeof data !== 'object') { this.logger.error('Invalid message format: not an object'); return; } const messageType = data.type; if (!messageType) { this.logger.error('Invalid message format: missing type field'); this.logger.debug(`Message content: ${JSON.stringify(data).substring(0, 200)}`); return; } this.logger.debug(`Processing message of type: ${messageType}`); if (messageType === 'response') { // Handle response to a request const requestId = data.id; if (!requestId) { this.logger.error('Invalid response: missing ID field'); return; } if (this.pendingRequests.has(requestId)) { const request = this.pendingRequests.get(requestId)!; clearTimeout(request.timer); if (data.error) { const errorMsg = data.error.message || 'Unknown error'; this.logger.warn(`Request error: ${errorMsg}`); request.reject(new Error(`Request error: ${errorMsg}`)); } else { this.logger.debug(`Request ${requestId} succeeded`); request.resolve(data.data || {}); } this.pendingRequests.delete(requestId); } else { this.logger.warn(`Received response for unknown request ID: ${requestId}`); } } else if (messageType === 'event') { // Handle event notification const action = data.action || 'unknown'; this.logger.debug(`Received event: ${action}`); // Trigger specific event handlers if (this.eventHandlers.has(action)) { const handlers = this.eventHandlers.get(action)!; handlers.forEach(async (handler) => { try { await handler(data.data || {}); } catch (error) { this.logger.error(`Error in event handler for ${action}: ${error instanceof Error ? error.message : String(error)}`); } }); } else { this.logger.debug(`No handlers registered for event: ${action}`); } // Trigger 'all' event handlers if (this.eventHandlers.has('all')) { const handlers = this.eventHandlers.get('all')!; handlers.forEach(async (handler) => { try { await handler(data); } catch (error) { this.logger.error(`Error in 'all' event handler: ${error instanceof Error ? error.message : String(error)}`); } }); } } else { this.logger.warn(`Unknown message type: ${messageType}`); } } /** * Send a request to the WebSocket server and wait for a response. * * @param action - The action to perform * @param data - Request data * @param timeout - Timeout in milliseconds * @returns Promise resolving to the response data * @throws Error if not connected, if the request times out, or if the response contains an error */ public async sendRequest<T>( action: string, data: Record<string, any> = {}, timeout: number = 5000 ): Promise<T> { if (!this.connected || !this.websocket) { throw new Error('Not connected to WebSocket server'); } const requestId = uuidv4(); const request: WebSocketRequest = { type: 'request', id: requestId, action, data }; return new Promise<T>((resolve, reject) => { // Set up timeout const timer = setTimeout(() => { if (this.pendingRequests.has(requestId)) { this.pendingRequests.delete(requestId); reject(new Error(`Request timed out: ${action}`)); } }, timeout); // Store the pending request this.pendingRequests.set(requestId, { resolve, reject, timer }); // Send the request this.websocket!.send(JSON.stringify(request), (error) => { if (error) { clearTimeout(timer); this.pendingRequests.delete(requestId); reject(new Error(`Failed to send request: ${error.message}`)); } }); }); } /** * Register a handler for a specific event type. * * @param eventType - Event type to handle * @param handler - Async function taking event data as argument */ public registerEventHandler(eventType: string, handler: (data: any) => Promise<void>): void { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, []); } this.eventHandlers.get(eventType)!.push(handler); } /** * Test the connection to the WebSocket server. * * @returns Promise resolving to true if the test was successful, false otherwise */ public async testConnection(): Promise<boolean> { try { if (!this.connected || !this.websocket) { this.logger.error('Cannot test connection: Not connected to WebSocket server'); return false; } if (this.websocket.readyState !== WebSocket.OPEN) { this.logger.error(`Cannot test connection: WebSocket is in state ${this.websocket.readyState} (not OPEN)`); return false; } this.logger.debug('Sending test request to WebSocket server'); const response = await this.sendRequest<{ status: string }>('test', {}, 3000); // Increased timeout for reliability // Enhanced validation of response if (!response) { this.logger.error('Connection test failed: Empty response'); return false; } const isValid = response.status === 'ok'; if (isValid) { this.logger.debug('Connection test succeeded'); } else { this.logger.error(`Connection test failed: Unexpected status: ${response.status}`); } return isValid; } catch (error) { this.logger.error(`Connection test failed: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Get information about the current avatar. * * @returns Promise resolving to avatar information (id, name) */ public async getAvatarInfo(): Promise<{ id: string; name: string }> { try { // Enhanced debug logging this.logger.debug('Requesting avatar info'); // Use increased timeout for avatar information const response = await this.sendRequest<{ avatar: { id: string; name: string } }>('avatar/getInfo', {}, 8000); // Validate response structure if (!response || !response.avatar) { throw new Error('Invalid response format: missing avatar data'); } this.logger.info(`Avatar info received: ${JSON.stringify(response.avatar)}`); return response.avatar; } catch (error) { this.logger.error(`Error getting avatar info: ${error instanceof Error ? error.message : String(error)}`); // Return default value rather than throwing return { id: 'unknown', name: 'Unknown Avatar' }; } } /** * Get a list of parameters for the current avatar. * * @returns Promise resolving to a list of parameter names */ public async getAvatarParameters(): Promise<string[]> { try { // デバッグ用にリクエスト送信をログ this.logger.debug(`Requesting avatar parameters...`); const response = await this.sendRequest<{ parameters: string[] | Record<string, unknown> }>('avatar/getParameters'); this.logger.debug(`Avatar parameters response: ${JSON.stringify(response)}`); // パラメータが配列の場合はそのまま返す if (Array.isArray(response?.parameters)) { this.logger.debug(`Avatar parameters: ${JSON.stringify(response.parameters)}`); return response.parameters; } // パラメータがオブジェクトの場合はキーの配列に変換 if (response?.parameters && typeof response.parameters === 'object') { const parameterNames = Object.keys(response.parameters); this.logger.debug(`Avatar parameters (converted from object): ${JSON.stringify(parameterNames)}`); return parameterNames; } // パラメータが存在しないか無効な場合 this.logger.warn('Invalid or missing parameters in response'); return []; } catch (error) { this.logger.error(`Error getting avatar parameters: ${error instanceof Error ? error.message : String(error)}`); // Return empty array rather than throwing return []; } } /** * Set an avatar parameter. * * @param name - Parameter name * @param value - Parameter value * @returns Promise resolving to true if successful, false otherwise */ public async setAvatarParameter(name: string, value: ParameterValue): Promise<boolean> { try { if (!name) { throw new Error('Parameter name is required'); } // Enhanced debug logging this.logger.debug(`Setting avatar parameter: ${name} = ${value}`); // // Validate parameter value type // if (typeof value !== 'number' && typeof value !== 'boolean') { // throw new Error(`Invalid parameter value type: ${typeof value}. Must be number or boolean.`); // } const response = await this.sendRequest<{ success: boolean }>('avatar/setParameter', { name, value }, 5000); if (response && response.success) { this.logger.debug(`Successfully set avatar parameter: ${name} = ${value}`); return true; } else { this.logger.warn(`Failed to set avatar parameter: ${name} = ${value}`); return false; } } catch (error) { this.logger.error(`Error setting avatar parameter ${name}: ${error instanceof Error ? error.message : String(error)}`); return false; } } /** * Send an input command to VRChat. * * @param inputName - Input name (e.g., "Jump", "Vertical") * @param value - Input value * @returns Promise resolving to true if successful, false otherwise */ public async sendInput(inputName: string, value: number): Promise<boolean> { const response = await this.sendRequest<{ success: boolean }>('input/sendCommand', { name: inputName, value }); this.logger.info(`Input response: ${JSON.stringify(response)}`); return response.success; } /** * Send a message to the VRChat chatbox. * * @param message - Message to send * @param sendImmediately - Whether to send immediately * @param notification - Whether to play a notification sound * @returns Promise resolving to true if successful, false otherwise */ public async sendChatboxMessage( message: string, sendImmediately: boolean = true, notification: boolean = true ): Promise<boolean> { const response = await this.sendRequest<{ success: boolean }>('chatbox/sendMessage', { message, immediate: sendImmediately, notification }); this.logger.info(`Chatbox message response: ${JSON.stringify(response)}`); return response.success; } /** * Get a list of all available avatars. * * @returns Promise resolving to an object with avatar IDs as keys and names as values */ public async getAvatarlist(): Promise<{ [avatarId: string]: string }> { try { const response = await this.sendRequest<{ avatars: { [avatarId: string]: string } }>('avatar/getAvatarlist'); return response.avatars || {}; } catch (error) { this.logger.error(`Error getting avatar list: ${error instanceof Error ? error.message : String(error)}`); return {}; } } /** * Send a raw OSC message. * * @param address - OSC address to send to * @param value - Value to send * @returns Promise resolving to true if successful, false otherwise */ /** * Change to a different avatar. * * @param avatarId - ID of the avatar to change to * @returns Promise resolving to true if successful, false otherwise */ public async setAvatar(avatarId: string): Promise<boolean> { try { const response = await this.sendRequest<{ success: boolean }>('avatar/setAvatar', { avatarId }); return response.success || false; } catch (error) { this.logger.error(`Error changing avatar: ${error instanceof Error ? error.message : String(error)}`); return false; } } public async send_message(address: string, value: any): Promise<boolean> { try { const response = await this.sendRequest<{ success: boolean }>('sendOsc', { address, value }); return response.success || false; } catch (error) { this.logger.error(`Error sending OSC message: ${error instanceof Error ? error.message : String(error)}`); return false; } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Krekun/vrchat-mcp-osc'

If you have feedback or need assistance with the MCP directory API, please join our Discord server