WebRTC - Text Demo
WebRTC - Modern Text Chat Demo
Section titled “WebRTC - Modern Text Chat Demo”In this chapter, we’ll build a client application that allows two users on separate devices to exchange text messages using WebRTC Data Channels. Similar to previous demos, our application will have a login view and a chat view.
The original tutorial included an image here showing the application: a login screen and a chat screen. The chat screen had an area for displaying messages, an input field to type a message, a ‘Send’ button, an input to specify the user to call, a ‘Call’ button, and a ‘Hang Up’ button. We will describe this UI in the HTML section.
The views will be managed by toggling div visibility, and interactions via JavaScript event listeners.
Signaling Server
Section titled “Signaling Server”For this text chat demo, we can reuse the same Node.js WebSocket signaling server built for the Voice/Video Demos. Its role remains the same: to facilitate the exchange of SDP offers, answers, and ICE candidates so that peers can establish a direct RTCPeerConnection. The RTCDataChannel will then be created over this connection.
Refer to the “WebRTC - Modern Voice Demo” chapter for the server.js code. Ensure it’s running (node server.js) before testing the client application. No changes are needed to that server for this text demo.
Client Application (HTML & JavaScript)
Section titled “Client Application (HTML & JavaScript)”As before, testing involves opening the application in two browser tabs and initiating a chat.
Create an index.html file. We’ll use Bootstrap via CDN for basic styling.
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>WebRTC Text Demo</title> <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet"> <style> body { background: #f0f0f0; padding: 20px; } .container { max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } #loginPage, #callPage { margin-top: 20px; } #chatArea { height: 200px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; background-color: #f9f9f9; } .message { margin-bottom: 5px; } .message .sender { font-weight: bold; } </style></head><body> <div class="container"> <div id="loginPage"> <h2 class="text-center">WebRTC Text Chat</h2> <p class="text-center">Please sign in</p> <div class="form-group"> <input type="text" id="usernameInput" class="form-control" placeholder="Enter your username" autofocus required> </div> <button id="loginBtn" class="btn btn-primary btn-block">Sign In</button> </div>
<div id="callPage" style="display: none;"> <h3 class="text-center">Chatting as <span id="currentUser"></span></h3> <div id="chatArea"></div>
<div class="form-group"> <input type="text" id="callToUsernameInput" class="form-control" placeholder="Username to connect with"> </div> <button id="callBtn" class="btn btn-success">Connect</button> <button id="hangUpBtn" class="btn btn-danger">Disconnect</button> <hr> <div class="input-group mb-3"> <input type="text" id="msgInput" class="form-control" placeholder="Type your message..."> <div class="input-group-append"> <button id="sendMsgBtn" class="btn btn-info">Send</button> </div> </div> </div> </div>
<script src="client.js"></script></body></html>This HTML structure is similar to the voice demo, but instead of <audio> elements, it has a div with id="chatArea" for displaying messages and an input field (msgInput) with a ‘Send’ button (sendMsgBtn) for sending messages.
Now, create client.js:
// client.jslet name;let connectedUser;let peerConnection;let dataChannel;
const wsURL = 'ws://localhost:9090';const connection = new WebSocket(wsURL);
// DOM Elementsconst loginPage = document.querySelector('#loginPage');const usernameInput = document.querySelector('#usernameInput');const loginBtn = document.querySelector('#loginBtn');
const callPage = document.querySelector('#callPage');const currentUserDisplay = document.querySelector('#currentUser');const callToUsernameInput = document.querySelector('#callToUsernameInput');const callBtn = document.querySelector('#callBtn');const hangUpBtn = document.querySelector('#hangUpBtn');
const chatArea = document.querySelector('#chatArea');const msgInput = document.querySelector('#msgInput');const sendMsgBtn = document.querySelector('#sendMsgBtn');
// ICE servers configurationconst configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ]};
connection.onopen = () => { console.log('Connected to signaling server');};
connection.onmessage = async (msg) => { console.log('Got message', msg.data); const data = JSON.parse(msg.data);
switch (data.type) { case 'login': handleLogin(data.success, data.message); break; case 'offer': await handleOffer(data.offer, data.name); break; case 'answer': await handleAnswer(data.answer); break; case 'candidate': await handleCandidate(data.candidate); break; case 'leave': handleLeave(); break; case 'error': alert(`Error: ${data.message}`); break; default: break; }};
connection.onerror = (err) => { console.error('WebSocket error:', err); alert('Error connecting to signaling server.');};
function send(message) { if (connectedUser) { message.name = connectedUser; // Target user for signaling messages } connection.send(JSON.stringify(message));}
// UI Event ListenersloginBtn.addEventListener('click', () => { name = usernameInput.value; if (name.length > 0) { send({ type: 'login', name: name }); } else { alert('Please enter a username.'); }});
function handleLogin(success, message) { if (success === false) { alert(`Login failed: ${message || 'Try a different username.'}`); } else { loginPage.style.display = 'none'; callPage.style.display = 'block'; currentUserDisplay.textContent = name; // Initialize RTCPeerConnection after login, ready for calls initializePeerConnection(); }}
function initializePeerConnection() { peerConnection = new RTCPeerConnection(configuration);
// Handle ICE candidates peerConnection.onicecandidate = (event) => { if (event.candidate) { send({ type: 'candidate', candidate: event.candidate }); } };
// This event is triggered on the receiving side when the sender calls createDataChannel peerConnection.ondatachannel = (event) => { console.log('Data channel received'); dataChannel = event.channel; setupDataChannelEvents(dataChannel); };
// Optional: Log connection state changes peerConnection.oniceconnectionstatechange = () => { console.log(`ICE connection state: ${peerConnection.iceConnectionState}`); if (peerConnection.iceConnectionState === 'connected') { console.log('Peers connected!'); } else if (['failed', 'disconnected', 'closed'].includes(peerConnection.iceConnectionState)) { console.log('Peer connection failed or closed.'); // Could trigger handleLeave() or alert user } };}
function setupDataChannelEvents(channel) { channel.onopen = () => { console.log('Data channel is open'); appendMessage('System', 'Chat connected!'); msgInput.disabled = false; sendMsgBtn.disabled = false; };
channel.onmessage = (event) => { console.log(`Message received: ${event.data}`); appendMessage(connectedUser, event.data); };
channel.onclose = () => { console.log('Data channel is closed'); appendMessage('System', 'Chat disconnected.'); msgInput.disabled = true; sendMsgBtn.disabled = true; };
channel.onerror = (error) => { console.error('Data channel error:', error); };}
callBtn.addEventListener('click', async () => { const callToUsername = callToUsernameInput.value; if (callToUsername.length === 0) { alert('Please enter a username to connect with.'); return; } if (callToUsername === name) { alert('You cannot connect to yourself.'); return; }
connectedUser = callToUsername; console.log(`Initiating connection to ${connectedUser}`);
if (!peerConnection) initializePeerConnection();
// Create data channel - this is done by the initiator dataChannel = peerConnection.createDataChannel('chatChannel', { reliable: true }); console.log('Data channel created by initiator'); setupDataChannelEvents(dataChannel);
try { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); send({ type: 'offer', offer: offer }); } catch (error) { console.error('Error creating offer:', error); alert('Error creating connection offer: ' + error.message); }});
async function handleOffer(offer, fromName) { if (!peerConnection) initializePeerConnection();
connectedUser = fromName; console.log(`Received offer from ${connectedUser}`); try { await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); send({ type: 'answer', answer: answer }); } catch (error) { console.error('Error handling offer:', error); }}
async function handleAnswer(answer) { console.log('Received answer'); try { await peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); } catch (error) { console.error('Error handling answer:', error); }}
async function handleCandidate(candidate) { try { if (candidate) { await peerConnection.addIceCandidate(new RTCIceCandidate(candidate)); } } catch (error) { console.error('Error adding ICE candidate:', error); }}
hangUpBtn.addEventListener('click', () => { send({ type: 'leave' }); handleLeave();});
function handleLeave() { console.log('Connection ended or other user left.'); appendMessage('System', 'Disconnected.'); connectedUser = null;
if (dataChannel) { dataChannel.close(); dataChannel = null; } if (peerConnection) { peerConnection.close(); peerConnection.onicecandidate = null; peerConnection.ondatachannel = null; peerConnection.oniceconnectionstatechange = null; peerConnection = null; } callToUsernameInput.value = ''; msgInput.disabled = true; sendMsgBtn.disabled = true; // Re-initialize for next potential connection initializePeerConnection();}
sendMsgBtn.addEventListener('click', () => { const msg = msgInput.value; if (msg.length > 0 && dataChannel && dataChannel.readyState === 'open') { appendMessage(name, msg); // Display my own message dataChannel.send(msg); msgInput.value = ''; } else if (!dataChannel || dataChannel.readyState !== 'open'){ appendMessage('System', 'Message not sent. No active connection.'); }});
msgInput.addEventListener('keypress', (event) => { if (event.key === 'Enter') { sendMsgBtn.click(); }});
function appendMessage(sender, messageText) { const messageElement = document.createElement('div'); messageElement.classList.add('message');
const senderElement = document.createElement('span'); senderElement.classList.add('sender'); senderElement.textContent = `${sender}: `;
const textElement = document.createElement('span'); textElement.textContent = messageText;
messageElement.appendChild(senderElement); messageElement.appendChild(textElement);
chatArea.appendChild(messageElement); chatArea.scrollTop = chatArea.scrollHeight; // Auto-scroll to latest message}
// Disable message input initiallymsgInput.disabled = true;sendMsgBtn.disabled = true;
// Clean up on window closewindow.addEventListener('beforeunload', () => { if (name) { send({ type: 'leave' }); } if (dataChannel) dataChannel.close(); if (peerConnection) peerConnection.close(); if (connection) connection.close();});Key changes and features in client.js for text chat:
RTCDataChannelFocus: The primary goal is to establish and use anRTCDataChannel.initializePeerConnection(): Sets up theRTCPeerConnection. Crucially, it definespeerConnection.ondatachannel. This event handler is triggered on the receiving peer when the initiating peer callscreateDataChannel().callBtn(Connect Button) Logic: When the initiator clicks ‘Connect’:-
* It calls `peerConnection.createDataChannel('chatChannel', { reliable: true })`. We use a reliable channel for chat messages.
-
* It then proceeds with the standard offer/answer exchange for the `RTCPeerConnection` itself.
setupDataChannelEvents(channel): This function is called for both the locally created data channel (initiator) and the remotely received one (receiver). It sets uponopen,onmessage,onclose, andonerrorhandlers for the data channel.-
* `onopen`: Fired when the data channel is ready. Enables message input.
-
* `onmessage`: Fired when a message arrives. Appends it to the `chatArea`.
-
* `onclose`: Fired when the channel closes. Disables message input.
appendMessage(sender, messageText): A helper to display messages in thechatArea.- Modern JavaScript: Uses
async/await,let/constas in previous demos. - Error Handling and Logging: Basic console logging is included for easier debugging. The original tutorial showed an image of console output confirming connection to the signaling server; this is now covered by
console.log.
The deprecated optional: [{RtpDataChannels: true}] constraint in the RTCPeerConnection constructor (mentioned in the original tutorial) is no longer needed as data channels are a standard part of WebRTC.
To run the demo:
- Ensure your signaling server (
server.js) is running. - Save
index.htmlandclient.jsin a new folder. - Open
index.htmlin two browser tabs. - Log in with different usernames.
- In one tab, enter the other tab’s username and click ‘Connect’.
- Once connected, you should be able to exchange text messages.
The original tutorial included an image showing the final application with two users logged in and exchanging messages. This functionality is what the updated code provides.