Files
openhuman/src/services/socketService.ts
T
cyrus 5b9a088141 Refactor: centralize development environment check with IS_DEV
Replaces scattered environment checks with the centralized `IS_DEV` constant from `config.ts`. This improves consistency and simplifies maintenance by reducing redundancy across multiple files.
2026-03-20 17:38:28 +05:30

333 lines
9.8 KiB
TypeScript

import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import debug from 'debug';
import { io, Socket } from 'socket.io-client';
import { MCPTool, MCPToolCall, SocketIOMCPTransportImpl } from '../lib/mcp';
import { skillManager, syncToolsToBackend } from '../lib/skills';
import { store } from '../store';
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
import { BACKEND_URL, IS_DEV } from '../utils/config';
import { createSafeLogData, sanitizeError } from '../utils/sanitize';
// Socket service logger using debug package
// Enable logging by setting DEBUG=socket* in environment or localStorage
const socketLog = debug('socket');
const socketWarn = debug('socket:warn');
const socketError = debug('socket:error');
// Enable socket logging in development by default
if (IS_DEV) {
debug.enable('socket*');
}
interface JwtPayload {
tgUserId?: string;
userId?: string;
sub?: string;
}
function getSocketUserId(): string {
const token = store.getState().auth.token;
if (!token) return '__pending__';
try {
const parts = token.split('.');
if (parts.length !== 3) return '__pending__';
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const payloadJson = atob(payloadBase64);
const payload = JSON.parse(payloadJson) as JwtPayload;
const id = payload.tgUserId || payload.userId || payload.sub;
return id || '__pending__';
} catch {
return '__pending__';
}
}
/**
* Check if running in Tauri (where Rust handles Socket.io).
* When true, this service should NOT create its own socket connection
* because the Rust-native SocketManager handles the connection and MCP.
*/
function isRustSocketMode(): boolean {
try {
return coreIsTauri();
} catch {
return false;
}
}
class SocketService {
private socket: Socket | null = null;
private token: string | null = null;
private mcpTransport: SocketIOMCPTransportImpl | null = null;
/**
* Connect to the socket server with authentication.
*
* NOTE: In Tauri mode, this is a NO-OP. The Rust-native SocketManager
* handles the connection. The frontend calls `connectRustSocket()` instead.
*/
connect(token: string): void {
if (!token) return;
// In Tauri mode, Rust handles the socket connection.
// Don't create a duplicate frontend socket.
if (isRustSocketMode()) {
socketLog('Skipping frontend socket — Rust SocketManager handles connection');
this.token = token;
return;
}
// Don't connect if already connected with the same token
if (this.socket?.connected && this.token === token) return;
// Disconnect existing connection if token changed or socket exists
if (this.socket) {
if (this.token !== token) {
this.disconnect();
} else if (this.socket.connected) {
return;
} else if (!this.socket.disconnected) {
// Socket is connecting, wait for it
return;
}
}
this.token = token;
const uid = getSocketUserId();
socketLog('Connecting', { userId: uid, backendUrl: BACKEND_URL });
store.dispatch(setStatusForUser({ userId: uid, status: 'connecting' }));
const backendUrl = BACKEND_URL;
// Ensure we're not connecting to the wrong URL
if (backendUrl.includes('localhost:1420') || backendUrl.includes(':1420')) {
return;
}
const socketOptions = {
auth: { token },
path: '/socket.io/',
transports: ['websocket', 'polling'] as ('websocket' | 'polling')[],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
forceNew: true,
timeout: 2000,
upgrade: true,
query: {},
};
this.socket = io(backendUrl, socketOptions);
// Initialize MCP transport for client→server MCP requests
this.mcpTransport = new SocketIOMCPTransportImpl(this.socket);
// Connection event handlers
this.socket.on('connect', () => {
const socketId = this.socket?.id || null;
const uid = getSocketUserId();
socketLog('Connected', { socketId, userId: uid });
store.dispatch(setStatusForUser({ userId: uid, status: 'connected' }));
store.dispatch(setSocketIdForUser({ userId: uid, socketId }));
syncToolsToBackend();
});
this.socket.on('ready', () => {
const uid = getSocketUserId();
socketLog('Server ready - authentication successful', { userId: uid });
});
this.socket.on('error', (error: unknown) => {
const uid = getSocketUserId();
socketError('Server error', { userId: uid, error: sanitizeError(error) });
});
this.socket.on('disconnect', (reason: string) => {
const uid = getSocketUserId();
socketLog('Disconnected', { userId: uid, reason });
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
store.dispatch(setSocketIdForUser({ userId: uid, socketId: null }));
});
this.socket.on('connect_error', (error: Error) => {
const uid = getSocketUserId();
socketError('Connection error', { userId: uid, error: sanitizeError(error) });
store.dispatch(setStatusForUser({ userId: uid, status: 'disconnected' }));
});
// MCP handlers — only in web mode (Rust handles MCP in Tauri mode)
this.socket.on('mcp:listTools', (data: { requestId: string }) => {
socketLog('MCP list tools request', { requestId: data.requestId });
// Aggregate tools from all ready skills
const skillsState = store.getState().skills.skills;
const allTools: MCPTool[] = [];
for (const [skillId, skill] of Object.entries(skillsState)) {
if (skill.status === 'ready' && skill.tools?.length) {
for (const tool of skill.tools) {
allTools.push({
name: `${skillId}__${tool.name}`,
description: tool.description,
inputSchema: tool.inputSchema,
});
}
}
}
socketLog('MCP list tools response', {
requestId: data.requestId,
toolCount: allTools.length,
});
this.socket?.emit('mcp:listToolsResponse', { requestId: data.requestId, tools: allTools });
});
this.socket.on('mcp:toolCall', async (data: { requestId: string; toolCall: MCPToolCall }) => {
const { requestId, toolCall } = data;
socketLog('MCP tool call', createSafeLogData({ requestId, toolName: toolCall?.name }, data));
const separatorIdx = toolCall.name.indexOf('__');
if (separatorIdx === -1) {
socketError('MCP tool call - invalid tool name format', { requestId, name: toolCall.name });
this.socket?.emit('mcp:toolCallResponse', {
requestId,
result: {
content: [
{
type: 'text',
text: `Invalid tool name: ${toolCall.name}. Expected format: skillId__toolName`,
},
],
isError: true,
},
});
return;
}
const skillId = toolCall.name.substring(0, separatorIdx);
const toolName = toolCall.name.substring(separatorIdx + 2);
try {
const result = await skillManager.callTool(skillId, toolName, toolCall.arguments);
socketLog('MCP tool call success', { requestId, skillId, toolName });
this.socket?.emit('mcp:toolCallResponse', { requestId, result });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
socketError('MCP tool call failed', {
requestId,
skillId,
toolName,
error: sanitizeError(err),
});
this.socket?.emit('mcp:toolCallResponse', {
requestId,
result: { content: [{ type: 'text', text: msg }], isError: true },
});
}
});
this.socket.connect();
}
/**
* Disconnect from the socket server
*/
disconnect(): void {
if (this.socket) {
const uid = getSocketUserId();
socketLog('Disconnecting', { userId: uid });
this.socket.disconnect();
this.socket = null;
this.token = null;
this.mcpTransport = null;
store.dispatch(resetForUser({ userId: uid }));
}
}
/**
* Get the current socket instance
*/
getSocket(): Socket | null {
return this.socket;
}
/**
* Get the MCP transport for making client→server MCP requests
*/
getMCPTransport(): SocketIOMCPTransportImpl | null {
return this.mcpTransport;
}
/**
* Check if socket is connected
*/
isConnected(): boolean {
return this.socket?.connected || false;
}
/**
* Emit an event to the server
*/
emit(event: string, data?: unknown): void {
if (this.socket?.connected) {
socketLog('Emitting event', createSafeLogData({ event }, data));
this.socket.emit(event, data);
} else {
socketWarn('Cannot emit event - socket not connected', { event });
}
}
/**
* Listen to an event from the server
*/
on(event: string, callback: (...args: unknown[]) => void): void {
if (this.socket) {
const wrappedCallback = (...args: unknown[]) => {
socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
callback(...args);
};
this.socket.on(event, wrappedCallback);
}
}
/**
* Remove an event listener
*/
off(event: string, callback?: (...args: unknown[]) => void): void {
if (this.socket) {
if (callback) {
this.socket.off(event, callback);
} else {
this.socket.off(event);
}
}
}
/**
* Listen to an event once
*/
once(event: string, callback: (...args: unknown[]) => void): void {
if (this.socket) {
const wrappedCallback = (...args: unknown[]) => {
socketLog('Received event (once)', {
event,
argsCount: args.length,
hasData: args.length > 0,
});
callback(...args);
};
this.socket.once(event, wrappedCallback);
}
}
}
export const socketService = new SocketService();