Using WebRTC and React to build a basic chat server

Tags:

In this article, we’re going to look at how to use WebRTC to relay chat messages between browsers in a React application. Last time we learned how to store and handle data in a React app. If you’ve just looking to learn about React and WebRTC, you should be able to follow this without reading the previous entries in the series.

Now at the fourth article in the series, we’re ready to start Connecting People (look at that funny Nokia reference). We’re going to use WebRTC to set up a direct connection between different chat participants, no backend required – well, we do need a little bit of backend for the initial connection handling… but it’ll all make sense soon!

WebRTC basics

WebRTC is an HTML5 tech for sending data browser-to-browser, without a trip through a server. It’s even possible to use it for video chat or BitTorrent in the browser!

We won’t do video or audio – we’ll simply use WebRTC as a convenient way of sending chat messages. The benefit of this is we won’t need to build a backend to relay messages between clients. Instead, we can handle it entirely client-side in the browser. Convenient, isn’t it?

There’s a tiny hiccup in the no-backend plan though. To connect two people over WebRTC, we first need to exchange information to allow browsers to talk to each other. This process is called signaling.

WebRTC doesn’t specify how signaling is done. You could use IP over Avian Carrier if you so desired. A more realistic method would be to ask the user to copy-paste the signaling data, but that’s tedious and error prone.

Instead, we’ll set up a tiny signaling server with Node.js to deal with this. Other than signaling, no data needs to be sent through a backend!

The API’s we need to use can be a bit complicated. They have a lot of moving parts, so we’ll be using some libraries to make them easier to use.

Setting up the Node.js signaling server

The basic process of setting up a new WebRTC connection works something like this:

  • Browser A sends initiation signal
  • Browser B receives signal, sends back its own response signal
  • Browser A receives response and uses it to establish a connection

Behind the scenes, there’s a bit more going on – for example, if Browser A is behind a NAT, there needs to be some trickery for that – but we won’t need to worry about any of that. Some of that is handled by the browser, and the rest by the WebRTC library we’ll use.

Now, we need some method to send and receive signaling data through the server. Let’s use WebSockets for that. They’re convenient for when we want to both send and receive data.

Below is the source code for the signaling server.

signal-server.js

var WebsocketServer = require('ws').Server;
 
var server = new WebsocketServer({ port: 3210 });
server.on('connection', function(socket) {
  socket.on('message', function(msg) {
    server.clients.forEach(function(other) {
      if(other === socket) {
        return;
      }
 
      other.send(msg);
    });
  });
});

First, we use the ws package to help us set up a WebSocket server. You can install it using npm install --save ws.

We create a server on port 3210 (feel free to change this if you’d like). When a new socket connects, we set a listener for messages. Upon receiving a message, it’s simply sent to every other client currently connected.

We could make the server more clever – for example, to allow changing the port more easily – but it doesn’t really matter right now. If needed, we’ll improve on it later.

WebRTC architecture

When using WebRTC, to send messages to multiple clients, you need a connection open to every one of them.

We can handle the connections between chatters in two ways:

  • A client-server architecture, where one user acts as a server and all others connect to them
  • A mesh architecture, where every user connects to every other user

For a chat, a client-server architecture has several benefits. For example, the server is the administrator of the chat, and can have the power to kick other users. In a mesh system, this could be problematic. The downside of a client-server arch is if the server disconnects, there needs to be a mechanism to choose a new server.

I decided to go with a client-server architecture, so one user acts as the server and other clients connect to them. We’re won’t need to worry about the disconnect-case yet. Maybe at some point in the future.

Before we jump into code, let’s think about how to approach this.

  • We want the UI (that is, our React components) to not care about connections too much. For most parts, the UI doesn’t need to know if we’re hosting or joining.
  • This means we should have some kind of an object that manages connections for us, so we can keep our UI code simple.
  • And the connections themselves should have an API which is similar, to keep the connection manager object simple.

Thus, I’m going to introduce three new modules to deal with this: ConnectionManager, ClientConnection and HostConnection.

But is this the best possible architecture? I don’t know – and that’s fine. You don’t need to know this up-front. If we keep our code clean and modular, it’s easy to change if we discover our idea doesn’t work out longer term.

Implementing the chat host

Let’s start by creating the host connection. This acts as the server and keeps track of all the connected clients.

You can install the necessary new libraries we’ll use with…

npm install --save simple-peer simple-websocket

Below is the initial skeleton:

src/HostConnection.js

var SimplePeer = require('simple-peer');
var SimpleWebsocket = require('simple-websocket');
var EventEmitter = require('events').EventEmitter;
 
var peers = [];
var emitter = new EventEmitter();
 
module.exports = function() {
  var socket = new SimpleWebsocket('ws://localhost:3210');
  socket.on('close', function() { console.log('Socket closed'); });
  socket.on('error', function(err) { console.log('Socket error'); console.log(err); });
  socket.on('connect', function() { console.log('Connected'); });
 
  socket.on('data', function(data) {
 
  });
 
  return {
 
  };
};

We set up the requires and some variables. peers will hold a list of all connected clients, while emitter will be used to send connection-related events similar to how we’re using EventEmitter in the data store (which we discussed in the previous article)

Instead of exporting an object, this time we assign a function to module.exports. This means we can require it and then directly call it.

Within the function, we set up a WebSocket and add a few listeners to it. This is what we’ll use for signaling and setting up WebRTC. How this works will become much clearer in a second.

Lastly, we return an empty object. The function we’re exporting returns an object representing the host connection. This means we could have multiple connections if we want. This is also why we’re directly exporting the function rather than an object. Exporting an object is convenient for things we’ll always have only one of, like the message store, but sometimes we return constructor functions like this. Exported React components also behave in a somewhat similar way.

Next, let’s fill in the socket data listener. This is where we’ll add in the WebRTC code as well.

  socket.on('data', function(data) {
    var rtc = new SimplePeer({ initiator: false, trickle: false });
 
    rtc.signal(data);
    rtc.on('signal', function(data) {
      socket.send(data);
    });
 
    rtc.on('connect', function() {
      peers.push(rtc);
    });
 
    rtc.on('data', function(msg) {
      emitter.emit('message', msg);
 
      //as host, we need to broadcast the data to the other peers
      peers.forEach(function(p) {
        if(p === rtc) {
          return;
        }
 
        p.send(msg);
      });
    });
  });

Since there’s a bit more going on here, let’s go through it in detail. When the socket receives data, we create a new SimplePeer. This object handles the WebRTC connection for us. We set initiator to false. This means we’re not the one initiating the connection, instead, we’re responding to a new connection. It makes sense to make the client be the initiator, since as a host, we’re waiting for clients rather than actively connecting to them. Later when we implement ClientConnection, we’ll set this the other way.

We also set trickle to false. This disables so called trickle ICE. ICE is a process in establishing a WebRTC connection, where the browser tries to figure out the IP address and other necessary information. Trickle ICE is faster than “traditional” ICE, but the code required for it would be more complex, so we’re disabling it for now.

The data coming over the socket is WebRTC signaling data, which we need to establish a connection. We pass this data to our WebRTC object using rtc.signal(data), which triggers the generation of our own signal data. We set a signal event listener on the connection to capture this, and send it back over the socket for the client waiting for it.

When the connect event fires, it means the WebRTC connection has been established. We can then add the RTC connection to our list of connected peers.

And lastly, we set up a listener for the data event. This is fired whenever data is received from the WebRTC peer. For this, we emit an event, which we’ll use to set up callbacks like we did with MessageStore.

The host is also responsible for broadcasting the data to everyone else who is connected. Otherwise, the chat messages would only be visible to us, since the clients only send it to the host. To broadcast the messages, we loop the peers array and send the data to all other peers.

With that, we’ve got all the connection related logic out of the way. The basic flow for the host is this:

  • We establish a WebSocket connection with the signal server
  • If we receive data from the socket, it’s a signal for a new WebRTC connection
  • We open a WebRTC connection using the signal we received, grabbing our own signal data and sending it back over the socket
  • At this point, we just wait for the connect event. When it fires, the WebRTC connection is ready and we add it into our peer list
  • We could now close the socket if we wanted – we have a working peer to peer WebRTC connection. However, we’re going to keep it open. If we closed it, we could only support 1-to-1 chats

The final thing we need to add to make the host part work is the returned object.

Right now it’s empty, which isn’t of much use, but let’s fill in a couple of functions:

  return {
    onReady: function(callback) {
      //the host is always "ready" although it may
      //not have any clients
      callback();
    },
 
    send: function(message) {
      peers.forEach(function(p) { p.send(message); });
    },
 
    onMessage: function(callback) {
      emitter.on('message', callback);
    }
  };

The first function seems a bit pointless – why do we have an onReady function when the connection is always ready? We’ll make this interface shared with our client connection, and the client won’t be ready until a connection has been established. By adding the function into the host, we avoid having to check which kind of connection we’re dealing with.

The second and third functions should make more sense. send is used to send data to the connected peers, and onMessage is used to register callbacks that handle messages.

Implementing the chat client

With the host done, all we need for our connection objects is the client.

The client works a bit differently from the host:

  • The client’s WebRTC initialization is slightly different
  • The client doesn’t need to hold a list of peers (only the host does)
  • The client can disconnect the socket after the WebRTC connection is ready (it isn’t needed for anything afterwards)

Since you know about WebSockets and WebRTC now, I won’t chop this code down into pieces, but let’s go through it after:

src/ClientConnection.js

var SimplePeer = require('simple-peer');
var SimpleWebsocket = require('simple-websocket');
var EventEmitter = require('events').EventEmitter;
 
var emitter = new EventEmitter();
 
module.exports = function() {
  var socket = new SimpleWebsocket('ws://localhost:3210');
  var rtc;
  socket.on('close', function() { console.log('Socket closed'); });
  socket.on('error', function(err) { console.log('Socket error'); console.log(err); });
 
  socket.on('connect', function() {
    rtc = new SimplePeer({ initiator: true, trickle: false });
    rtc.on('signal', function(data) {
      socket.send(data);
    });
 
    socket.on('data', function(data) {
      rtc.signal(data);
    });
 
    rtc.on('connect', function() {
      emitter.emit('connected');
      //we no longer need the signaler
      socket.destroy();
    });
 
    rtc.on('data', function(message) {
      emitter.emit('message', message);
    });
  });
 
  return {
    onReady: function(callback) {
      emitter.on('connected', callback);
    },
 
    send: function(message) {
      rtc.send(message);
    },
 
    onMessage: function(cb) {
      emitter.on('message', cb);
    }
  };
};

The basics are the same, but we don’t hold onto a peer list here. The main difference is when the socket connects: When that happens, we immediately start establishing a WebRTC connection. This time, we set initiator to true.

For an initiating WebRTC connection, the signal is generated right away. We catch it with a handler and pass it to the socket, so a host can receive it. When we receive data from the socket, we assume it to be a response signal from the host, so we call rtc.signal with it. This code should be enough to establish a WebRTC connection with our host.

Unlike the host, the client uses a connect listener on the RTC connection. From this, we send out a connected event, which is used to fire the onReady listeners later. We also destroy the socket, as it’s no longer necessary when we’ve established the RTC connection.

For the RTC connection’s data event, we emit the message event like in the host. This will trigger the message listeners.

The object we return is similar, but with two key differences: The onReady function now listens for the connected event, and the send function only sends data to the single connection to the host.

Now, we just need to set up the connection manager and fiddle a bit with React components to build a UI for this.

Connection management

Earlier, we started by putting a bunch of logic into the Chat component. We might want to avoid that now that we’ve gotten a bit further along.

Instead of putting connection-handling logic into Chat, let’s set up a new object for that, so the code remains cleaner.

src/ConnectionManager.js

var EventEmitter = require('events').EventEmitter;
 
var ClientConnection = require('./ClientConnection');
var HostConnection = require('./HostConnection');
 
var connection = null;
var emitter = new EventEmitter();
 
function setupConnection(conn) {
  conn.onReady(function() {
    connection = conn;
    emitter.emit('status');
  });
 
  conn.onMessage(function(msg) {
    emitter.emit('message', msg);
  });
}
 
module.exports = {
  isConnected: function() {
    return connection !== null;
  },
 
  sendMessage: function(message) {
    connection.send(message);
  },
 
  onMessage: function(cb) {
    emitter.on('message', cb);
  },
 
  onStatusChange: function(cb) {
    emitter.on('status', cb);
  },
 
  offMessage: function(cb) {
    emitter.off('message', cb);
  },
 
  offStatusChange: function(cb) {
    emitter.off('status', cb);
  },
 
  host: function() {
    setupConnection(HostConnection());
  },
 
  join: function() {
    setupConnection(ClientConnection());
  }
};

For now, this is mostly passing events – we’ll find some more use for this later. The more interesting bits here are the host and join functions, which set up a new host connection and a new client connection respectively.

Adding a user interface for connecting

With all the plumbing finished, we can now get back to the React parts of this thing. We’ll implement a connection form, and update the Chat component to use it.

Let’s start with the form. This is fairly simple: Two buttons, one for hosting, one for joining and a message about the connection status.

var React = require('react');
 
module.exports = React.createClass({
  render: function() {
    return <div>
      {this.props.connected ? 'Connected' : 'Not connected'}
      <button onClick={this.props.onHost}>Host</button>
      <button onClick={this.props.onJoin}>Join</button>
    </div>;
  }
});

Doesn’t get much simpler than that. Everything just comes in as a prop. Next, Chat:

var React = require('react');
 
var MessageList = require('./MessageList');
var MessageForm = require('./MessageForm');
var MessageStore = require('./MessageStore');
var ConnectionManager = require('./ConnectionManager');
var ConnectionForm = require('./ConnectionForm');
 
module.exports = React.createClass({
    getInitialState: function() {
        return {
            messages: MessageStore.getMessages(),
            connected: ConnectionManager.isConnected()
        };
    },
 
    componentWillMount: function() {
        MessageStore.subscribe(this.updateMessages);
        ConnectionManager.onStatusChange(this.updateConnection);
        ConnectionManager.onMessage(MessageStore.newMessage);
    },
 
    componentWillUnmount: function() {
        MessageStore.unsubscribe(this.updateMessages);
        ConnectionManager.offStatusChange(this.updateConnection);
        ConnectionManager.offMessage(MessageStore.newMessage);
    },
 
    updateMessages: function() {
        this.setState({
            messages: MessageStore.getMessages()
        });
    },
 
    updateConnection: function() {
        this.setState({
            connected: ConnectionManager.isConnected()
        });
    },
 
    onSend: function(newMessage) {
        ConnectionManager.sendMessage(newMessage);
        MessageStore.newMessage(newMessage);
    },
 
    render: function() {
        return <div>
            <MessageList messages={this.state.messages} />
            <MessageForm onSend={this.onSend} />
            <ConnectionForm
                connected={this.state.connected}
                onHost={ConnectionManager.host}
                onJoin={ConnectionManager.join}
                />
        </div>;
    }
});

Most of the changes here relate to ConnectionManager. We add its state into getInitialState, and set up code to handle event listeners. Note that for the onMessage handling, we directly pass MessageStore.newMessage – this way, any new message coming from a connection is added into the store. The MessageStore emits an event as a result, and our UI updates. We didn’t need to change the storage code at all for supporting network connections! When you keep each module focused on doing a single task, you don’t need to change it as often.

We also add ConnectionManager.sendMessage to the message sender function. This way any locally added message gets sent over the WebRTC connection.

In the render function, we’ve added the ConnectionForm component, for which we pass in the connection state and the two functions from ConnectionManager.

Important side note: You’ll note that we’re putting ConnectionManager.isConnected()‘s value into state. Why can’t we directly pass it into ConnectionForm?

React uses state to track what components it needs to update. If we passed the connection status value directly, there is no way React can tell when it needs to update the component. There are some ways around this, such as using forceUpdate for the component, but it’s generally not recommended. Tracking updates via state and props is usually simpler to manage than manual forced updates.

Conclusion

With everything wired up, we can now actually talk to other people with our chat! Simply run npm run build to bundle everything together, and launch the signal server using node signal-server.js. It’s quite basic, but hey, we’re getting there!

WebRTC allows us to build 99% backend-free client-server applications in the browser. We only need a backend to help initialize the connections between peers. Our current RTC and socket implementation doesn’t handle all special cases, but we can make it more robust later.

You can grab the full source up to this point here.

In the next article in the series, we look into a React feature called functional stateless components and we also add a bit of ES6 into our code, as there’s a couple of convenient features we can make use of.

Sign up for my newsletter with the form below, and I’ll send you all the follow-ups right to your inbox