Skip to content

WebRTC - Text 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.

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.

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.

index.html
<!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.js
let name;
let connectedUser;
let peerConnection;
let dataChannel;
const wsURL = 'ws://localhost:9090';
const connection = new WebSocket(wsURL);
// DOM Elements
const 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 configuration
const 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 Listeners
loginBtn.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 initially
msgInput.disabled = true;
sendMsgBtn.disabled = true;
// Clean up on window close
window.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:

  • RTCDataChannel Focus: The primary goal is to establish and use an RTCDataChannel.
  • initializePeerConnection(): Sets up the RTCPeerConnection. Crucially, it defines peerConnection.ondatachannel. This event handler is triggered on the receiving peer when the initiating peer calls createDataChannel().
  • 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 up onopen, onmessage, onclose, and onerror handlers 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 the chatArea.
  • Modern JavaScript: Uses async/await, let/const as 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:

  1. Ensure your signaling server (server.js) is running.
  2. Save index.html and client.js in a new folder.
  3. Open index.html in two browser tabs.
  4. Log in with different usernames.
  5. In one tab, enter the other tab’s username and click ‘Connect’.
  6. 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.