Skip to main content
Glama
socket-bridge.ts17.6 kB
import { MODULE_ID, CONNECTION_STATES } from './constants.js'; import { WebRTCConnection, type WebRTCConfig } from './webrtc-connection.js'; export interface BridgeConfig { enabled: boolean; serverHost: string; serverPort: number; namespace: string; reconnectAttempts: number; reconnectDelay: number; connectionTimeout: number; debugLogging: boolean; connectionType?: 'auto' | 'webrtc' | 'websocket'; // Connection type: auto (HTTPS→WebRTC, HTTP→WebSocket), webrtc, websocket } /** * Browser-compatible socket bridge that supports both WebSocket and WebRTC */ export class SocketBridge { private ws: WebSocket | null = null; private webrtc: WebRTCConnection | null = null; private connectionState: string = CONNECTION_STATES.DISCONNECTED; private reconnectAttempts = 0; private maxReconnectAttempts = 5; private reconnectTimer: any = null; private activeConnectionType: 'websocket' | 'webrtc' | null = null; constructor(private config: BridgeConfig) { this.maxReconnectAttempts = config.reconnectAttempts; } async connect(): Promise<void> { if (this.connectionState === CONNECTION_STATES.CONNECTED || this.connectionState === CONNECTION_STATES.CONNECTING) { return; } this.connectionState = CONNECTION_STATES.CONNECTING; this.log('Connecting to MCP server...'); // Determine connection type const connectionType = this.determineConnectionType(); this.log(`Using connection type: ${connectionType}`); if (connectionType === 'webrtc') { await this.connectWebRTC(); } else { await this.connectWebSocket(); } } private determineConnectionType(): 'websocket' | 'webrtc' { const configType = this.config.connectionType || 'auto'; if (configType === 'auto') { // Use WebRTC for HTTPS (secure), WebSocket for HTTP (localhost) // WebRTC provides P2P encrypted channel without needing SSL certificates const isHttps = window.location.protocol === 'https:'; const type = isHttps ? 'webrtc' : 'websocket'; this.log(`Auto-detected connection type: ${type} (page is ${window.location.protocol})`); return type; } // Use explicit connection type from config return configType as 'websocket' | 'webrtc'; } private async connectWebRTC(): Promise<void> { this.activeConnectionType = 'webrtc'; const webrtcConfig: WebRTCConfig = { serverHost: this.config.serverHost, serverPort: this.config.serverPort, namespace: this.config.namespace, stunServers: [], // Empty for localhost - must match server configuration connectionTimeout: this.config.connectionTimeout, debugLogging: this.config.debugLogging }; this.webrtc = new WebRTCConnection(webrtcConfig); try { await this.webrtc.connect(this.handleMessage.bind(this)); this.connectionState = CONNECTION_STATES.CONNECTED; this.reconnectAttempts = 0; this.log('Connected via WebRTC'); } catch (error) { this.log(`WebRTC connection failed: ${error}`); this.connectionState = CONNECTION_STATES.DISCONNECTED; this.scheduleReconnect(); throw error; } } private async connectWebSocket(): Promise<void> { this.activeConnectionType = 'websocket'; // WebSocket for HTTP localhost connections only const protocol = 'ws'; const host = this.config.serverHost; this.log(`Using WebSocket (${protocol}://${host}:${this.config.serverPort})`); const wsUrl = `${protocol}://${host}:${this.config.serverPort}${this.config.namespace}`; return new Promise((resolve, reject) => { const connectTimeout = setTimeout(() => { this.log('Connection timeout'); this.connectionState = CONNECTION_STATES.DISCONNECTED; reject(new Error('Connection timeout')); }, this.config.connectionTimeout * 1000); try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { clearTimeout(connectTimeout); this.connectionState = CONNECTION_STATES.CONNECTED; this.reconnectAttempts = 0; this.log('Connected to MCP server via WebSocket'); this.setupEventHandlers(); resolve(); }; this.ws.onerror = (error) => { clearTimeout(connectTimeout); // Use more informative message for connection failures const isFirstAttempt = this.reconnectAttempts === 0; const errorMsg = isFirstAttempt ? 'MCP server not available (this is normal if server isn\'t running)' : `Connection error after ${this.reconnectAttempts} attempts: ${error}`; this.log(errorMsg); this.connectionState = CONNECTION_STATES.DISCONNECTED; this.scheduleReconnect(); reject(new Error('WebSocket connection failed')); }; this.ws.onclose = (event) => { this.log(`Disconnected: ${event.reason || 'Connection closed'}`); this.connectionState = CONNECTION_STATES.DISCONNECTED; if (event.wasClean) { // Clean disconnect, don't reconnect return; } this.scheduleReconnect(); }; } catch (error) { clearTimeout(connectTimeout); this.log(`Failed to create WebSocket: ${error}`); this.connectionState = CONNECTION_STATES.DISCONNECTED; reject(error); } }); } disconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.webrtc) { this.webrtc.disconnect(); this.webrtc = null; } if (this.ws) { this.ws.close(1000, 'Manual disconnect'); this.ws = null; } this.activeConnectionType = null; this.connectionState = CONNECTION_STATES.DISCONNECTED; this.log('Disconnected from MCP server'); } private setupEventHandlers(): void { if (!this.ws) return; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleMessage(message); } catch (error) { this.log(`Failed to parse message: ${error}`); } }; } private async handleMessage(message: any): Promise<void> { try { if (message.type === 'mcp-query') { await this.handleMCPQuery(message.data, (response) => { this.sendMessage({ type: 'mcp-response', id: message.id, data: response }); }); } else if (message.type === 'ping') { this.sendMessage({ type: 'pong', id: message.id, data: { timestamp: Date.now(), status: 'ok' } }); } else if (message.type === 'job-completed') { await this.handleJobCompleted(message.data); } else if (message.type === 'map-generation-progress') { this.handleProgressUpdate(message.data); } } catch (error) { console.error(`[foundry-mcp-bridge] ERROR in handleMessage:`, error); this.log(`Error handling message: ${error}`); } } private handleProgressUpdate(data: any): void { try { if (!data) { // Silently ignore empty progress updates (can happen during initialization) return; } const { progress, status, queueInfo } = data; // Build progress message let message = `🎨 Generating battlemap: ${progress}%`; if (queueInfo) { const { currentStep, totalSteps, estimatedTimeRemaining } = queueInfo; if (currentStep !== undefined && totalSteps !== undefined) { message += ` (Step ${currentStep}/${totalSteps})`; } if (estimatedTimeRemaining) { const minutes = Math.floor(estimatedTimeRemaining / 60); const seconds = Math.floor(estimatedTimeRemaining % 60); if (minutes > 0) { message += ` - ${minutes}m ${seconds}s remaining`; } else { message += ` - ${seconds}s remaining`; } } } if (status) { message += ` - ${status}`; } // Show as banner notification ui.notifications?.info(message); this.log(`Progress: ${message}`); } catch (error) { console.error(`[foundry-mcp-bridge] Error handling progress update:`, error); } } private async handleMCPQuery(data: any, callback: (response: any) => void): Promise<void> { try { this.log(`Handling MCP query: ${data.method}`); // Check if the query handler exists in CONFIG.queries const queryKey = data.method; // Method already includes full path like 'foundry-mcp-bridge.listActors' const handler = CONFIG.queries[queryKey]; if (!handler || typeof handler !== 'function') { throw new Error(`No handler found for query: ${data.method}`); } // Execute the query handler const result = await handler(data.data || {}); this.log(`Query completed: ${data.method}`); callback({ success: true, data: result }); } catch (error) { this.log(`Query failed: ${data.method} - ${error instanceof Error ? error.message : 'Unknown error'}`); callback({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } } private async handleJobCompleted(data: any): Promise<void> { try { console.log(`[foundry-mcp-bridge] Map generation completed, creating scene...`); console.log(`[foundry-mcp-bridge] Job completion data:`, data); // Handle mapgen-style data structure if (!data.result) { console.error(`[foundry-mcp-bridge] ERROR: No scene result data provided`); throw new Error('No scene result data provided'); } if (!data.image_path) { console.error(`[foundry-mcp-bridge] ERROR: No image path provided for scene creation`); throw new Error('No image path provided for scene creation'); } // Use the complete scene data from backend (like mapgen does) const sceneData = data.result; console.log(`[foundry-mcp-bridge] Scene data to create:`, sceneData); console.log(`[foundry-mcp-bridge] Scene name: "${sceneData.name}"`); // Ensure "AI Generated Maps" folder exists and get its ID console.log(`[foundry-mcp-bridge] Ensuring AI Generated Maps folder exists...`); const folderId = await this.ensureAIMapsFolderExists(); console.log(`[foundry-mcp-bridge] Folder ID:`, folderId); // Add folder to scene data if (folderId) { sceneData.folder = folderId; console.log(`[foundry-mcp-bridge] Added folder ID to scene data`); } // Create the scene using the complete payload from backend console.log(`[foundry-mcp-bridge] Attempting to create scene...`); const scene = await (globalThis as any).Scene.create(sceneData); console.log(`[foundry-mcp-bridge] Scene created successfully:`, scene); // CRITICAL: Foundry v13 bug workaround (like working mapgen system) if (!scene.img && sceneData.img) { await scene.update({ img: sceneData.img, background: { src: sceneData.img } }); } if (sceneData.walls && sceneData.walls.length > 0) { await this.createSceneWalls(scene, sceneData.walls); } ui.notifications?.info(`Scene "${sceneData.name}" created successfully!`); // Auto-activate the scene if enabled const autoActivate = true; // You might want to make this configurable if (autoActivate) { await scene.activate(); ui.notifications?.info(`Switched to "${sceneData.name}" - Ready for token placement!`); } this.log(`Scene "${sceneData.name}" created and activated`); } catch (error) { this.log(`Failed to create scene from generated map: ${error instanceof Error ? error.message : 'Unknown error'}`); ui.notifications?.error(`Failed to create scene: ${error instanceof Error ? error.message : 'Unknown error'}`); } } private async createSceneWalls(scene: any, wallsData: any[]): Promise<void> { if (!wallsData || !Array.isArray(wallsData) || wallsData.length === 0) { this.log('No wall data provided'); return; } try { this.log(`Creating ${wallsData.length} walls for scene ${scene.name}`); // Filter out walls with invalid coordinates const validWalls = wallsData.filter((wall: any) => { if (!wall.c || !Array.isArray(wall.c) || wall.c.length !== 4) { this.log(`Invalid wall coordinates: ${JSON.stringify(wall)}`); return false; } if (!wall.c.every((coord: any) => typeof coord === 'number' && !isNaN(coord))) { this.log(`Invalid coordinate values: ${JSON.stringify(wall.c)}`); return false; } return true; }); this.log(`${validWalls.length} valid walls out of ${wallsData.length} total`); const wallDocuments = validWalls.map((wall: any) => ({ c: wall.c, // Wall coordinates [x1, y1, x2, y2] move: wall.movement || 0, sense: wall.sight || 0, doorSound: "", dir: wall.direction || 0, door: wall.door || 0, ds: wall.doorState || 0, flags: wall.flags || {} })); if (wallDocuments.length > 0) { await scene.createEmbeddedDocuments("Wall", wallDocuments); ui.notifications?.info(`Created ${wallDocuments.length} walls in scene "${scene.name}"`); } else { this.log('No valid walls to create'); ui.notifications?.warn('No valid walls could be created from detection data'); } } catch (error) { this.log(`Failed to create walls: ${error instanceof Error ? error.message : 'Unknown error'}`); ui.notifications?.warn(`Some walls could not be created: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Ensure "AI Generated Maps" folder exists for organizing generated scenes */ private async ensureAIMapsFolderExists(): Promise<string | null> { try { const folderName = 'AI Generated Maps'; // Check if folder already exists const existingFolder = (globalThis as any).game.folders.find((f: any) => f.type === 'Scene' && f.name === folderName ); if (existingFolder) { this.log(`AI Generated Maps folder already exists with ID: ${existingFolder.id}`); return existingFolder.id; } // Create the folder this.log('Creating AI Generated Maps folder...'); const folder = await (globalThis as any).Folder.create({ name: folderName, type: 'Scene', description: 'Scenes created by AI Map Generation', color: '#4a90e2', // Nice blue color sorting: 'a' // Sort alphabetically }); if (folder) { this.log(`Created AI Generated Maps folder with ID: ${folder.id}`); return folder.id; } this.log('Failed to create AI Generated Maps folder'); return null; } catch (error) { this.log(`Error managing AI Generated Maps folder: ${error instanceof Error ? error.message : 'Unknown error'}`); return null; } } private scheduleReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.log(`Max reconnection attempts reached (${this.maxReconnectAttempts})`); return; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); // Exponential backoff, max 30s this.reconnectAttempts++; this.log(`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay}ms`); this.connectionState = CONNECTION_STATES.RECONNECTING; this.reconnectTimer = setTimeout(async () => { try { await this.connect(); } catch (error) { // Connection failed, scheduleReconnect will be called again from connect() } }, delay); } private sendMessage(message: any): void { if (this.connectionState !== CONNECTION_STATES.CONNECTED) { this.log(`Cannot send message - not connected`); return; } try { if (this.activeConnectionType === 'webrtc' && this.webrtc) { this.webrtc.sendMessage(message); } else if (this.activeConnectionType === 'websocket' && this.ws) { this.ws.send(JSON.stringify(message)); } else { this.log('No active connection to send message'); return; } this.log(`Sent message via ${this.activeConnectionType}: ${message.type}`); } catch (error) { this.log(`Failed to send message: ${error}`); } } emitToServer(event: string, data?: any): void { this.sendMessage({ type: event, data: data, timestamp: Date.now() }); } isConnected(): boolean { return this.connectionState === CONNECTION_STATES.CONNECTED; } getConnectionState(): string { return this.connectionState; } getConnectionInfo(): any { return { type: this.activeConnectionType, state: this.connectionState, reconnectAttempts: this.reconnectAttempts, maxReconnectAttempts: this.maxReconnectAttempts, config: { host: this.config.serverHost, port: this.config.serverPort, namespace: this.config.namespace, }, }; } private log(message: string): void { if (this.config.debugLogging) { console.log(`[${MODULE_ID}] Socket Bridge: ${message}`); } } }

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/adambdooley/foundry-vtt-mcp'

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