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
JavaScript
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
// Initialize the application
document.addEventListener('DOMContentLoaded', () => {
new WebRTCChat();
});