Skip to main content
Glama
plugin-client.ts9.36 kB
import WebSocket from 'ws'; import { EventEmitter } from 'events'; import { buildWebSocketURL, getDefaultHost, getDefaultPort } from '../config.js'; interface PluginCommand { id?: string; command: string; args?: unknown; } export interface PluginResponse { id?: string; success: boolean; data?: unknown; error?: string; windowContext?: { windowLabel: string; totalWindows: number; warning?: string; }; } /** * Client to communicate with the MCP Bridge plugin's WebSocket server */ /* eslint-disable no-plusplus */ export class PluginClient extends EventEmitter { private _ws: WebSocket | null = null; private _url: string; private _host: string; private _port: number; private _reconnectAttempts = 0; private _shouldReconnect = true; // Keep trying forever until explicitly disconnected private _reconnectDelay = 1000; // Start with 1s, max 30s private _pendingRequests: Map<string, { resolve: (value: PluginResponse) => void; reject: (reason: Error) => void; timeout: NodeJS.Timeout; }> = new Map(); /** * Constructor for PluginClient * @param host Host address of the WebSocket server * @param port Port number of the WebSocket server */ public constructor(host: string, port: number) { super(); this._host = host; this._port = port; this._url = buildWebSocketURL(host, port); // CRITICAL: Attach a default error handler to prevent crashes. // In Node.js, if an EventEmitter emits 'error' with no listeners, it throws // an uncaught exception that crashes the process. This is especially important // during port scanning where connections may fail after the caller has moved on. this.on('error', () => { // Silently ignore - errors are also returned via promise rejections }); } /** * Creates a PluginClient with default configuration from environment. */ public static create_default(): PluginClient { return new PluginClient(getDefaultHost(), getDefaultPort()); } /** * Gets the host this client is configured to connect to. */ public get host(): string { return this._host; } /** * Gets the port this client is configured to connect to. */ public get port(): number { return this._port; } /** * Connect to the plugin's WebSocket server */ public async connect(): Promise<void> { return new Promise((resolve, reject) => { if (this._ws?.readyState === WebSocket.OPEN) { resolve(); return; } this._ws = new WebSocket(this._url); this._ws.on('open', () => { // Connected to MCP Bridge plugin this._reconnectAttempts = 0; this.emit('connected'); resolve(); }); this._ws.on('message', (data: WebSocket.Data) => { try { const message = JSON.parse(data.toString()); // Check if this is a response to a pending request if (message.id && this._pendingRequests.has(message.id)) { const pending = this._pendingRequests.get(message.id); if (pending) { clearTimeout(pending.timeout); this._pendingRequests.delete(message.id); pending.resolve(message); } } else { // It's a broadcast event this.emit('event', message); } } catch(e) { // Failed to parse WebSocket message } }); this._ws.on('error', (err) => { // WebSocket error - emit for any listeners, then reject the promise. // Note: The constructor attaches a default error handler to prevent crashes. this.emit('error', err); reject(err); }); this._ws.on('close', () => { // Disconnected from MCP Bridge plugin this.emit('disconnected'); this._ws = null; // Reject all pending requests since the connection is gone for (const [ id, pending ] of this._pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Connection closed')); this._pendingRequests.delete(id); } // Auto-reconnect with exponential backoff (max 30s) if (this._shouldReconnect) { this._reconnectAttempts++; const delay = Math.min(this._reconnectDelay * this._reconnectAttempts, 30000); setTimeout(() => { this.connect().catch(() => { // Reconnection failed - will retry on next close event }); }, delay); } }); }); } /** * Disconnect from the plugin */ public disconnect(): void { this._shouldReconnect = false; // Prevent auto-reconnect if (this._ws) { this._ws.close(); this._ws = null; } } /** * Send a command to the plugin and wait for response */ public async sendCommand(command: PluginCommand, timeoutMs = 5000): Promise<PluginResponse> { // If not connected, try to reconnect first if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { try { await this.connect(); } catch{ throw new Error('Not connected to plugin and reconnection failed'); } } // Double-check connection after reconnect attempt if (!this._ws || this._ws.readyState !== WebSocket.OPEN) { throw new Error('Not connected to plugin'); } // Generate unique ID for this request const id = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const commandWithId = { ...command, id }; return new Promise((resolve, reject) => { // Set up timeout const timeout = setTimeout(() => { this._pendingRequests.delete(id); reject(new Error(`Request timeout after ${timeoutMs}ms`)); }, timeoutMs); // Store pending request this._pendingRequests.set(id, { resolve, reject, timeout }); // Send command // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this._ws!.send(JSON.stringify(commandWithId), (error) => { if (error) { clearTimeout(timeout); this._pendingRequests.delete(id); reject(error); } }); }); } /** * Check if connected */ public isConnected(): boolean { return this._ws?.readyState === WebSocket.OPEN; } } // Singleton instance let pluginClient: PluginClient | null = null; /** * Gets the existing singleton PluginClient without creating or modifying it. * Use this for status checks where you don't want to affect the current connection. * * @returns The existing PluginClient or null if none exists */ export function getExistingPluginClient(): PluginClient | null { return pluginClient; } /** * Gets or creates a singleton PluginClient. * * If host/port are provided and differ from the existing client's configuration, * the existing client is disconnected and a new one is created. This ensures * that session start with a specific port always uses that port. * * @param host Optional host override * @param port Optional port override */ export function getPluginClient(host?: string, port?: number): PluginClient { const resolvedHost = host ?? getDefaultHost(); const resolvedPort = port ?? getDefaultPort(); // If singleton exists but host/port don't match, reset it if (pluginClient && (pluginClient.host !== resolvedHost || pluginClient.port !== resolvedPort)) { pluginClient.disconnect(); pluginClient = null; } if (!pluginClient) { pluginClient = new PluginClient(resolvedHost, resolvedPort); } return pluginClient; } /** * Resets the singleton client (useful for reconnecting with different config). */ export function resetPluginClient(): void { if (pluginClient) { pluginClient.disconnect(); pluginClient = null; } } export async function connectPlugin(host?: string, port?: number): Promise<void> { const client = getPluginClient(host, port); if (!client.isConnected()) { await client.connect(); } } /** * Ensures a session is active and connects to the plugin using session config. * This should be used by all tools that require a connected Tauri app. * * @param appIdentifier - Optional app identifier to target specific app * @throws Error if no session is active */ export async function ensureSessionAndConnect(appIdentifier?: string | number): Promise<PluginClient> { // Import dynamically to avoid circular dependency const { resolveTargetApp } = await import('./session-manager.js'); const session = resolveTargetApp(appIdentifier); // Ensure client is connected if (!session.client.isConnected()) { await session.client.connect(); } return session.client; } export async function disconnectPlugin(): Promise<void> { const client = getPluginClient(); client.disconnect(); }

Latest Blog Posts

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/hypothesi/mcp-server-tauri'

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