UNPKG

webrtc-mcp-chat

Version:

A remote WebRTC chat server with secure temporary rooms and MCP support for background agents

492 lines (413 loc) 16.4 kB
class WebRTCChat { constructor() { this.socket = io(); this.localStream = null; this.peerConnections = new Map(); this.username = ''; this.roomId = ''; this.isVideoEnabled = false; this.isAudioEnabled = false; this.initializeElements(); this.setupEventListeners(); this.setupSocketEvents(); } initializeElements() { // Form elements this.joinForm = document.getElementById('join-form'); this.chatInterface = document.getElementById('chat-interface'); this.usernameInput = document.getElementById('username'); this.roomIdInput = document.getElementById('room-id'); this.joinBtn = document.getElementById('join-btn'); this.leaveBtn = document.getElementById('leave-btn'); // Chat elements this.currentRoom = document.getElementById('current-room'); this.currentUser = document.getElementById('current-user'); this.usersList = document.getElementById('users-list'); this.userCount = document.getElementById('user-count'); this.messages = document.getElementById('messages'); this.messageInput = document.getElementById('message-input'); this.sendBtn = document.getElementById('send-btn'); // Video elements this.localVideo = document.getElementById('local-video'); this.remoteVideos = document.getElementById('remote-videos'); this.toggleVideoBtn = document.getElementById('toggle-video'); this.toggleAudioBtn = document.getElementById('toggle-audio'); this.screenShareBtn = document.getElementById('screen-share'); // Status elements this.connectionStatus = document.getElementById('connection-status'); this.statusText = document.getElementById('status-text'); } setupEventListeners() { this.joinBtn.addEventListener('click', () => this.joinRoom()); this.leaveBtn.addEventListener('click', () => this.leaveRoom()); this.sendBtn.addEventListener('click', () => this.sendMessage()); this.messageInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.sendMessage(); }); this.toggleVideoBtn.addEventListener('click', () => this.toggleVideo()); this.toggleAudioBtn.addEventListener('click', () => this.toggleAudio()); this.screenShareBtn.addEventListener('click', () => this.shareScreen()); // Generate random room ID if empty this.roomIdInput.addEventListener('focus', () => { if (!this.roomIdInput.value) { this.roomIdInput.value = 'room_' + Math.random().toString(36).substr(2, 8); } }); } setupSocketEvents() { this.socket.on('connect', () => { this.updateConnectionStatus('connected', 'Connected'); }); this.socket.on('disconnect', () => { this.updateConnectionStatus('disconnected', 'Disconnected'); }); this.socket.on('user-joined', (data) => { this.addUserToList(data); this.addSystemMessage(`${data.username} joined the chat${data.isMCP ? ' via MCP' : ''}`); if (!data.isMCP) { this.createPeerConnection(data.userId, false); } }); this.socket.on('user-left', (data) => { this.removeUserFromList(data.userId); this.addSystemMessage(`${data.username} left the chat`); this.closePeerConnection(data.userId); }); this.socket.on('room-users', (users) => { this.updateUsersList(users); // Create peer connections for existing users users.forEach(user => { if (!user.isMCP) { this.createPeerConnection(user.userId, true); } }); }); this.socket.on('chat-message', (data) => { this.addMessage(data); }); // WebRTC signaling events this.socket.on('offer', async (data) => { await this.handleOffer(data); }); this.socket.on('answer', async (data) => { await this.handleAnswer(data); }); this.socket.on('ice-candidate', async (data) => { await this.handleIceCandidate(data); }); } async joinRoom() { const username = this.usernameInput.value.trim(); const roomId = this.roomIdInput.value.trim() || 'general'; if (!username) { alert('Please enter a username'); return; } this.username = username; this.roomId = roomId; try { // Initialize media await this.initializeMedia(); // Join room this.socket.emit('join-room', { roomId: this.roomId, username: this.username, isMCP: false }); // Update UI this.currentRoom.textContent = this.roomId; this.currentUser.textContent = this.username; this.joinForm.classList.add('hidden'); this.chatInterface.classList.remove('hidden'); this.messageInput.disabled = false; this.sendBtn.disabled = false; this.addSystemMessage(`Welcome to room: ${this.roomId}`); } catch (error) { console.error('Error joining room:', error); alert('Error accessing camera/microphone. You can still chat via text.'); // Join without media this.socket.emit('join-room', { roomId: this.roomId, username: this.username, isMCP: false }); this.currentRoom.textContent = this.roomId; this.currentUser.textContent = this.username; this.joinForm.classList.add('hidden'); this.chatInterface.classList.remove('hidden'); this.messageInput.disabled = false; this.sendBtn.disabled = false; } } leaveRoom() { this.socket.disconnect(); this.cleanup(); this.joinForm.classList.remove('hidden'); this.chatInterface.classList.add('hidden'); this.socket.connect(); } async initializeMedia() { try { this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); this.localVideo.srcObject = this.localStream; this.isVideoEnabled = true; this.isAudioEnabled = true; this.updateMediaButtons(); } catch (error) { console.error('Error accessing media:', error); throw error; } } async createPeerConnection(userId, isInitiator) { const configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; const peerConnection = new RTCPeerConnection(configuration); this.peerConnections.set(userId, peerConnection); // Add local stream if (this.localStream) { this.localStream.getTracks().forEach(track => { peerConnection.addTrack(track, this.localStream); }); } // Handle remote stream peerConnection.ontrack = (event) => { const remoteStream = event.streams[0]; this.addRemoteVideo(userId, remoteStream); }; // Handle ICE candidates peerConnection.onicecandidate = (event) => { if (event.candidate) { this.socket.emit('ice-candidate', { target: userId, candidate: event.candidate }); } }; // Create offer if initiator if (isInitiator) { try { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); this.socket.emit('offer', { target: userId, sdp: offer }); } catch (error) { console.error('Error creating offer:', error); } } } async handleOffer(data) { const peerConnection = this.peerConnections.get(data.sender); if (!peerConnection) return; try { await peerConnection.setRemoteDescription(data.sdp); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); this.socket.emit('answer', { target: data.sender, sdp: answer }); } catch (error) { console.error('Error handling offer:', error); } } async handleAnswer(data) { const peerConnection = this.peerConnections.get(data.sender); if (!peerConnection) return; try { await peerConnection.setRemoteDescription(data.sdp); } catch (error) { console.error('Error handling answer:', error); } } async handleIceCandidate(data) { const peerConnection = this.peerConnections.get(data.sender); if (!peerConnection) return; try { await peerConnection.addIceCandidate(data.candidate); } catch (error) { console.error('Error handling ICE candidate:', error); } } sendMessage() { const message = this.messageInput.value.trim(); if (!message) return; this.socket.emit('chat-message', { message }); this.messageInput.value = ''; } addMessage(data) { const messageDiv = document.createElement('div'); messageDiv.className = `message ${data.isMCP ? 'mcp' : ''}`; messageDiv.innerHTML = ` <div class="message-header"> <span class="message-username">${data.username}${data.isMCP ? ' (MCP)' : ''}</span> <span class="message-time">${new Date(data.timestamp).toLocaleTimeString()}</span> </div> <div class="message-content">${this.escapeHtml(data.message)}</div> `; this.messages.appendChild(messageDiv); this.messages.scrollTop = this.messages.scrollHeight; } addSystemMessage(message) { const messageDiv = document.createElement('div'); messageDiv.className = 'message system'; messageDiv.innerHTML = ` <div class="message-content" style="font-style: italic; opacity: 0.7;"> ${this.escapeHtml(message)} </div> `; this.messages.appendChild(messageDiv); this.messages.scrollTop = this.messages.scrollHeight; } updateUsersList(users) { this.usersList.innerHTML = ''; users.forEach(user => this.addUserToList(user)); this.userCount.textContent = users.length + 1; // +1 for current user } addUserToList(user) { const userDiv = document.createElement('div'); userDiv.className = `user-item ${user.isMCP ? 'mcp' : ''}`; userDiv.dataset.userId = user.userId; userDiv.innerHTML = ` <span>${user.username}</span> ${user.isMCP ? '<span class="user-badge mcp">MCP</span>' : '<span class="user-badge">WEB</span>'} `; this.usersList.appendChild(userDiv); this.updateUserCount(); } removeUserFromList(userId) { const userElement = this.usersList.querySelector(`[data-user-id="${userId}"]`); if (userElement) { userElement.remove(); this.updateUserCount(); } } updateUserCount() { const userCount = this.usersList.children.length + 1; this.userCount.textContent = userCount; } addRemoteVideo(userId, stream) { const videoElement = document.createElement('video'); videoElement.className = 'remote-video'; videoElement.autoplay = true; videoElement.srcObject = stream; videoElement.dataset.userId = userId; this.remoteVideos.appendChild(videoElement); } toggleVideo() { if (!this.localStream) return; this.isVideoEnabled = !this.isVideoEnabled; const videoTrack = this.localStream.getVideoTracks()[0]; if (videoTrack) { videoTrack.enabled = this.isVideoEnabled; } this.updateMediaButtons(); } toggleAudio() { if (!this.localStream) return; this.isAudioEnabled = !this.isAudioEnabled; const audioTrack = this.localStream.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = this.isAudioEnabled; } this.updateMediaButtons(); } async shareScreen() { try { const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); // Replace video track in all peer connections const videoTrack = screenStream.getVideoTracks()[0]; this.peerConnections.forEach(async (peerConnection) => { const sender = peerConnection.getSenders().find(s => s.track && s.track.kind === 'video' ); if (sender) { await sender.replaceTrack(videoTrack); } }); // Update local video this.localVideo.srcObject = screenStream; // Listen for screen share end videoTrack.addEventListener('ended', () => { this.stopScreenShare(); }); } catch (error) { console.error('Error sharing screen:', error); } } async stopScreenShare() { if (!this.localStream) return; // Replace screen share with camera const videoTrack = this.localStream.getVideoTracks()[0]; this.peerConnections.forEach(async (peerConnection) => { const sender = peerConnection.getSenders().find(s => s.track && s.track.kind === 'video' ); if (sender && videoTrack) { await sender.replaceTrack(videoTrack); } }); this.localVideo.srcObject = this.localStream; } updateMediaButtons() { this.toggleVideoBtn.textContent = this.isVideoEnabled ? '📹 Video' : '📹 Video (Off)'; this.toggleAudioBtn.textContent = this.isAudioEnabled ? '🎤 Audio' : '🎤 Audio (Off)'; this.toggleVideoBtn.style.opacity = this.isVideoEnabled ? '1' : '0.5'; this.toggleAudioBtn.style.opacity = this.isAudioEnabled ? '1' : '0.5'; } updateConnectionStatus(status, text) { this.connectionStatus.className = `status-indicator ${status}`; this.statusText.textContent = text; } closePeerConnection(userId) { const peerConnection = this.peerConnections.get(userId); if (peerConnection) { peerConnection.close(); this.peerConnections.delete(userId); } // Remove remote video const remoteVideo = this.remoteVideos.querySelector(`[data-user-id="${userId}"]`); if (remoteVideo) { remoteVideo.remove(); } } cleanup() { // Close all peer connections this.peerConnections.forEach((pc, userId) => { this.closePeerConnection(userId); }); // Stop local stream if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } // Clear UI this.messages.innerHTML = ''; this.usersList.innerHTML = ''; this.remoteVideos.innerHTML = ''; this.userCount.textContent = '0'; } escapeHtml(unsafe) { return unsafe .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } } // Initialize the application document.addEventListener('DOMContentLoaded', () => { new WebRTCChat(); });