Skip to main content
Glama
webrtc-connection.ts9 kB
import { MODULE_ID, CONNECTION_STATES } from './constants.js'; export interface WebRTCConfig { serverHost: string; serverPort: number; namespace: string; stunServers: string[]; connectionTimeout: number; debugLogging: boolean; } /** * WebRTC peer connection for browser-to-server communication * Uses HTTP POST for signaling (localhost exception allows HTTP from HTTPS) * Then establishes encrypted WebRTC DataChannel for P2P connection without SSL certificates */ export class WebRTCConnection { private peerConnection: RTCPeerConnection | null = null; private dataChannel: RTCDataChannel | null = null; private connectionState: string = CONNECTION_STATES.DISCONNECTED; private messageHandler: ((message: any) => Promise<void>) | null = null; constructor(private config: WebRTCConfig) {} async connect(onMessage: (message: any) => Promise<void>): Promise<void> { if (this.connectionState === CONNECTION_STATES.CONNECTED || this.connectionState === CONNECTION_STATES.CONNECTING) { return; } this.connectionState = CONNECTION_STATES.CONNECTING; this.messageHandler = onMessage; this.log('Starting WebRTC connection...'); try { // Step 1: Create WebRTC peer connection this.peerConnection = new RTCPeerConnection({ iceServers: this.config.stunServers.map(url => ({ urls: url })) }); // Step 2: Create data channel this.dataChannel = this.peerConnection.createDataChannel('foundry-mcp', { ordered: true, maxRetransmits: 10 }); this.setupDataChannelHandlers(); this.setupPeerConnectionHandlers(); // Step 3: Create offer const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); // Step 4: Wait for ICE gathering await this.waitForIceGathering(); // Step 5: Send offer to server via signaling WebSocket await this.sendSignalingOffer(this.peerConnection.localDescription!); this.log('WebRTC connection initiated'); } catch (error) { this.log(`WebRTC connection failed: ${error}`); this.connectionState = CONNECTION_STATES.DISCONNECTED; throw error; } } private setupDataChannelHandlers(): void { if (!this.dataChannel) return; this.dataChannel.onopen = () => { this.log('WebRTC data channel opened'); this.connectionState = CONNECTION_STATES.CONNECTED; }; this.dataChannel.onclose = () => { this.log('WebRTC data channel closed'); this.connectionState = CONNECTION_STATES.DISCONNECTED; }; this.dataChannel.onerror = (error) => { this.log(`WebRTC data channel error: ${error}`); }; this.dataChannel.onmessage = async (event) => { try { const message = JSON.parse(event.data); if (this.messageHandler) { await this.messageHandler(message); } } catch (error) { this.log(`Failed to parse WebRTC message: ${error}`); } }; } private setupPeerConnectionHandlers(): void { if (!this.peerConnection) return; this.peerConnection.oniceconnectionstatechange = () => { const state = this.peerConnection?.iceConnectionState; this.log(`ICE connection state: ${state}`); if (state === 'failed' || state === 'disconnected' || state === 'closed') { this.connectionState = CONNECTION_STATES.DISCONNECTED; } }; this.peerConnection.onconnectionstatechange = () => { const state = this.peerConnection?.connectionState; this.log(`Peer connection state: ${state}`); }; } private async waitForIceGathering(): Promise<void> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('ICE gathering timeout')); }, this.config.connectionTimeout * 1000); if (this.peerConnection?.iceGatheringState === 'complete') { clearTimeout(timeout); resolve(); return; } this.peerConnection!.onicegatheringstatechange = () => { if (this.peerConnection?.iceGatheringState === 'complete') { clearTimeout(timeout); resolve(); } }; }); } private async sendSignalingOffer(offer: RTCSessionDescriptionInit): Promise<void> { // Use HTTP POST for signaling to dedicated WebRTC signaling port (31416) // For HTTPS pages, browsers allow HTTP POST to localhost (security exception) // The MCP server must be running on the same machine as the browser const isHttps = window.location.protocol === 'https:'; const signalingHost = isHttps ? 'localhost' : this.config.serverHost; const protocol = 'http'; // Always http:// - localhost exception allows this from HTTPS const WEBRTC_SIGNALING_PORT = 31416; // Dedicated port for WebRTC signaling const httpUrl = `${protocol}://${signalingHost}:${WEBRTC_SIGNALING_PORT}/webrtc-offer`; this.log(`Sending WebRTC offer via HTTP POST: ${httpUrl} (HTTPS page: ${isHttps})`); try { const response = await fetch(httpUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ offer }), signal: AbortSignal.timeout(this.config.connectionTimeout * 1000) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const { answer } = await response.json(); if (!answer) { throw new Error('No answer received from server'); } this.log('Received WebRTC answer from server via HTTP'); await this.peerConnection?.setRemoteDescription( new RTCSessionDescription(answer) ); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.log(`Signaling via HTTP failed: ${errorMsg}`); throw error; // Re-throw original error instead of wrapping } } disconnect(): void { if (this.dataChannel) { this.dataChannel.close(); this.dataChannel = null; } if (this.peerConnection) { this.peerConnection.close(); this.peerConnection = null; } this.connectionState = CONNECTION_STATES.DISCONNECTED; this.log('WebRTC connection closed'); } sendMessage(message: any): void { if (!this.dataChannel || this.dataChannel.readyState !== 'open') { this.log('Cannot send message - data channel not open'); return; } try { const json = JSON.stringify(message); const size = json.length; // WebRTC SCTP constants (keep in sync with server config.ts WEBRTC_CONSTANTS) const MAX_MESSAGE_SIZE = 65536; // 64KB - SCTP hard limit const CHUNK_SIZE = 50 * 1024; // 50KB - safe threshold for chunking if (size > CHUNK_SIZE) { // Split large message into chunks const totalChunks = Math.ceil(json.length / CHUNK_SIZE); const chunkId = `chunk-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; this.log(`Chunking large message: ${size} bytes → ${totalChunks} chunks (type: ${message.type})`); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, json.length); const chunk = json.substring(start, end); const chunkMessage = { type: 'chunked-message', chunkId: chunkId, chunkIndex: i, totalChunks: totalChunks, chunk: chunk, originalType: message.type, originalId: message.id }; const chunkJson = JSON.stringify(chunkMessage); // Verify chunk doesn't exceed SCTP maxMessageSize (safety check) if (chunkJson.length > MAX_MESSAGE_SIZE) { throw new Error( `Chunk ${i + 1}/${totalChunks} size ${chunkJson.length} exceeds ` + `SCTP maxMessageSize of ${MAX_MESSAGE_SIZE} bytes. ` + `Original message may be too large to chunk safely.` ); } this.dataChannel.send(chunkJson); this.log(`Sent chunk ${i + 1}/${totalChunks} (${chunkJson.length} bytes)`); } this.log(`Successfully sent all ${totalChunks} chunks for ${message.type}`); } else { // Send as single message this.dataChannel.send(json); this.log(`Sent WebRTC message: ${message.type} (${size} bytes)`); } } catch (error) { this.log(`Failed to send WebRTC message: ${error}`); throw error; // Re-throw so caller knows send failed } } isConnected(): boolean { return this.connectionState === CONNECTION_STATES.CONNECTED; } getConnectionState(): string { return this.connectionState; } private log(message: string): void { if (this.config.debugLogging) { console.log(`[${MODULE_ID}] WebRTC: ${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