Chapter 4. The Need for a Signaling Channel

As we anticipated in Chapter 3, a signaling channel is needed in a WebRTC-enabled application in order to allow for the exchanging of both session descriptions and network reachability information. Up until now, we have disregarded this specific aspect by sticking to a local perspective. This turned out to be useful, since it allowed us to just focus on the details of the WebRTC APIs, while leaving aside all networking-related aspects. The time is now ripe to also tackle these last issues. In this chapter, we will describe how we can create a proper signaling channel between any pair of peers that are interested in successfully setting up a WebRTC-enabled communication session.

The material presented in this chapter is only loosely related to the main topic of the book. More precisely, we will herein just focus on the creation of the above-mentioned signaling channel by describing the design and implementation of a very simple JavaScript application involving two clients and a server. The example itself should provide the reader with a set of tools that can be easily reused in a wide set of application scenarios. In the following chapter we will finally put all pieces together in order to complete the 10-step WebRTC recipe in a distributed setting.

Building Up a Simple Call Flow

As usual, we will continue to embrace the learn-by-example approach in order to let you figure out how to build a server-assisted signaling channel between two remote peers. In this chapter, we will focus on the realization of a simple interaction scenario, as formally depicted in the sequence diagram in Figure 4-1.

Signaling channel example: sequence diagram
Figure 4-1. Signaling channel example: Sequence diagram

The diagram in the picture involves three different actors:

  • A channel initiator, such as the peer that first takes the initiative of creating a dedicated communication channel with a remote party
  • A signaling server, managing channel creation and acting as a message relaying node
  • A channel joiner, for instance, a remote party joining an already existing channel

The idea is that the channel is created on demand by the server after receiving a specific request issued by the initiator. As soon as the second peer joins the channel, conversation can start. Message exchanging always happens through the server, which basically acts as a transparent relay node. When one of the peers decides to quit an ongoing conversation, it issues an ad hoc message (called Bye in the figure) towards the server, before disconnecting. This message is dispatched by the server to the remote party, which also disconnects, after having sent an acknowledgment back to the server. The receipt of the acknowledgment eventually triggers the channel reset procedure on the server’s side, thus bringing the overall scenario back to its original configuration.

Let’s start by building a simple HTML5 page (see Example 4-1), containing an initially empty <div> element which will be used to track down the evolution of the communication between two remote peers interacting through the signaling server.

Example 4-1. Simple signaling channel
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
                                "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>

<title>WebRTC client</title>

</head>

<body>

<script src='/socket.io/socket.io.js'></script>

<div id="scratchPad"></div>

<script type="text/javascript" src="js/simpleNodeClient.js"></script>


</body>
</html>

As you can see from the HTML code, the page includes two JavaScript files. The former (socket.io.js) refers to the well-known socket.io library for real-time web applications.

The latter file (simpleNodeClient.js) is presented in the following:

// Get <div> placeholder element from DOM
div = document.getElementById('scratchPad');

// Connect to server
var socket = io.connect('http://localhost:8181');

// Ask channel name from user
channel = prompt("Enter signaling channel name:");

if (channel !== "") {
    console.log('Trying to create or join channel: ', channel);
    // Send 'create or join' to the server
    socket.emit('create or join', channel);
}


// Handle 'created' message
socket.on('created', function (channel){
        console.log('channel ' + channel + ' has been created!');
        console.log('This peer is the initiator...');

        // Dynamically modify the HTML5 page
        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) + ' --> Channel '
                + channel + ' has been created! </p>');

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> This peer is the initiator...</p>');
});


// Handle 'full' message
socket.on('full', function (channel){
        console.log('channel ' + channel + ' is too crowded! \
                Cannot allow you to enter, sorry :-(');

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) + ' --> \
         channel ' + channel + ' is too crowded! \
                 Cannot allow you to enter, sorry :-( </p>');
});

// Handle 'remotePeerJoining' message
socket.on('remotePeerJoining', function (channel){
        console.log('Request to join ' + channel);
        console.log('You are the initiator!');

        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:red">Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Message from server: request to join channel ' +
                channel + '</p>');
});

// Handle 'joined' message
socket.on('joined', function (msg){
        console.log('Message from server: ' + msg);

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Message from server: </p>');
        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
                msg + '</p>');

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Message from server: </p>');
        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
                msg + '</p>');
});

// Handle 'broadcast: joined' message
socket.on('broadcast: joined', function (msg){

        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:red">Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Broadcast message from server: </p>');
        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:red">' +
                msg + '</p>');

        console.log('Broadcast message from server: ' + msg);

        // Start chatting with remote peer:
        // 1. Get user's message
        var myMessage = prompt('Insert message to be sent to your peer:', "");

        // 2. Send to remote peer (through server)
        socket.emit('message', {
                channel: channel,
                message: myMessage});
});

// Handle remote logging message from server
socket.on('log', function (array){
        console.log.apply(console, array);
});

// Handle 'message' message
socket.on('message', function (message){
        console.log('Got message from other peer: ' + message);

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Got message from other peer: </p>');
        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
                message + '</p>');

        // Send back response message:
        // 1. Get response from user
        var myResponse = prompt('Send response to other peer:', "");

        // 2. Send it to remote peer (through server)
        socket.emit('response', {
                channel: channel,
                message: myResponse});

});

// Handle 'response' message
socket.on('response', function (response){
        console.log('Got response from other peer: ' + response);

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Got response from other peer: </p>');
        div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
                response + '</p>');

        // Keep on chatting
        var chatMessage = prompt('Keep on chatting. \
        Write "Bye" to quit conversation', "");

        // User wants to quit conversation: send 'Bye' to remote party
        if(chatMessage == "Bye"){
                div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                        (performance.now() / 1000).toFixed(3) +
                        ' --> Sending "Bye" to server...</p>');
                console.log('Sending "Bye" to server');

                socket.emit('Bye', channel);

                div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                        (performance.now() / 1000).toFixed(3) +
                        ' --> Going to disconnect...</p>');
                console.log('Going to disconnect...');

                // Disconnect from server
                socket.disconnect();
        }else{
                // Keep on going: send response back
                // to remote party (through server)
                socket.emit('response', {
                        channel: channel,
                        message: chatMessage});
        }
});

// Handle 'Bye' message
socket.on('Bye', function (){
        console.log('Got "Bye" from other peer! Going to disconnect...');

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Got "Bye" from other peer!</p>');

        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Sending "Ack" to server</p>');

        // Send 'Ack' back to remote party (through server)
        console.log('Sending "Ack" to server');

        socket.emit('Ack');

        // Disconnect from server
        div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
                (performance.now() / 1000).toFixed(3) +
                ' --> Going to disconnect...</p>');
        console.log('Going to disconnect...');

        socket.disconnect();
});

The code performs the following actions:

  1. Allows the client to connect to the server (through the socket.io library)
  2. Prompts the user for the name of the channel she wants to join
  3. Sends a create or join request to the server
  4. Starts to asynchronously handle server-sent events.

In the remainder of this chapter, we will follow a complete call flow in a step-by-step fashion. Before doing this, though, we will take a look at the server-side behavior. The server has been written by leveraging the Node.js JavaScript library.

Let’s go over the server-side code. It basically looks after the creation of a server instance listening on port 8181. The code allows for the creation of server-side “rooms” hosting two client sockets at most. The first client that asks for the creation of a room is the channel initiator.

After channel creation, the server-side policy is the following:

  1. The second client arriving is allowed to join the newly created channel.
  2. All other clients are denied access to the room (and are consequently notified of such an event).

    var static = require('node-static');
    
    var http = require('http');
    
    // Create a node-static server instance listening on port 8181
    var file = new(static.Server)();
    
    // We use the http module’s createServer function and
    // use our instance of node-static to serve the files
    var app = http.createServer(function (req, res) {
      file.serve(req, res);
    }).listen(8181);
    
    // Use socket.io JavaScript library for real-time web applications
    var io = require('socket.io').listen(app);
    
    // Let's start managing connections...
    io.sockets.on('connection', function (socket){
    
        // Handle 'message' messages
        socket.on('message', function (message) {
            log('S --> Got message: ', message);
    
            socket.broadcast.to(message.channel).emit('message', \
                    message.message);
        });
    
        // Handle 'create or join' messages
        socket.on('create or join', function (channel) {
            var numClients = io.sockets.clients(channel).length;
            console.log('numclients = ' + numClients);
    
            // First client joining...
            if (numClients == 0){
                socket.join(channel);
                socket.emit('created', channel);
            // Second client joining...
            } else if (numClients == 1) {
            // Inform initiator...
                    io.sockets.in(channel).emit('remotePeerJoining', channel);
                    // Let the new peer join channel
            socket.join(channel);
    
            socket.broadcast.to(channel).emit('broadcast: joined', 'S --> \
                broadcast(): client ' + socket.id + ' joined channel ' \
                            + channel);
            } else { // max two clients
                    console.log("Channel full!");
                socket.emit('full', channel);
            }
        });
    
        // Handle 'response' messages
        socket.on('response', function (response) {
            log('S --> Got response: ', response);
    
            // Just forward message to the other peer
            socket.broadcast.to(response.channel).emit('response',
                response.message);
        });
    
        // Handle 'Bye' messages
        socket.on('Bye', function(channel){
            // Notify other peer
            socket.broadcast.to(channel).emit('Bye');
    
            // Close socket from server's side
            socket.disconnect();
        });
    
        // Handle 'Ack' messages
        socket.on('Ack', function () {
            console.log('Got an Ack!');
            // Close socket from server's side
            socket.disconnect();
        });
    
        // Utility function used for remote logging
        function log(){
            var array = [">>> "];
            for (var i = 0; i < arguments.length; i++) {
                    array.push(arguments[i]);
            }
            socket.emit('log', array);
        }
    });

We’re now ready to get started with our signaling example walk-through.

Creating the Signaling Channel

We herein focus on the very first steps of the example call flow, as illustrated in Figure 4-2.

Let’s assume that a first client using the Chrome browser loads the HTML5 page of Example 4-1. The page first connects to the server and then prompts the user for the name of the channel (Figure 4-3):

...
// Connect to server
var socket = io.connect('http://localhost:8181');

// Ask channel name from user
channel = prompt("Enter signaling channel name:");
...
Moving the first steps: channel creation
Figure 4-2. The first steps: Channel creation
The example page loaded in Chrome (channel initiator)
Figure 4-3. The example page loaded in Chrome (channel initiator)

Once the user fills in the channel name field and hits the OK button, the JavaScript code in the page sends a create or join message to the server:

...
if (channel !== "") {
      console.log('Trying to create or join channel: ', channel);
      // Send 'create or join' to the server
      socket.emit('create or join', channel);
}
...

Upon reception of the client’s request, the server performs the following actions:

  1. Verifies that the mentioned channel is a brand new one (i.e., there are no clients in it)
  2. Associates a server-side room with the channel
  3. Allows the requesting client to join the channel
  4. Sends back to the client a notification message called created

The following snippet shows this sequence of actions:

...
socket.on('create or join', function (channel) {
    var numClients = io.sockets.clients(channel).length;
    console.log('numclients = ' + numClients);

    // First client joining...
    if (numClients == 0){
        socket.join(channel);
        socket.emit('created', channel);
...

Figure 4-4 shows the server’s console right after the aforementioned actions have been performed.

When the initiating client receives the server’s answer, it simply logs the event both on the JavaScript console and inside the <div> element contained in the HTML5 page:

...
// Handle 'created' message
socket.on('created', function (channel){
    console.log('channel ' + channel + ' has been created!');
    console.log('This peer is the initiator...');

    // Dynamically modify the HTML5 page
    div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
        (performance.now() / 1000).toFixed(3) + ' --> Channel ' +
        channel + ' has been created! </p>');

    div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
        (performance.now() / 1000).toFixed(3) +
        ' --> This peer is the initiator...</p>');
});
...
Signaling server managing inititator’s request
Figure 4-4. Signaling server managing initiator’s request

The situation described above is illustrated in Figure 4-5.

Initiator’s window after channel creation
Figure 4-5. Initiator’s window after channel creation

Joining the Signaling Channel

Let’s now move on to the second client, the channel joiner, focusing on the call flow section shown in Figure 4-6.

Joining an already existing channel
Figure 4-6. Joining an already existing channel

For the sake of completeness, we will this time use Firefox as the client browser, the look and feel of which, right after loading the application page, is illustrated in Figure 4-7.

As already described, the client first connects to the server and then sends it a create or join request. Since this time the requesting peer is not the initiator, the server’s behavior will be driven by the following code snippet:

...
   } else if (numClients == 1) {
        // Inform initiator...
        io.sockets.in(channel).emit('remotePeerJoining', channel);
                // Let the new peer join channel
        socket.join(channel);

        socket.broadcast.to(channel).emit('broadcast: joined', 'S -->
            broadcast(): client ' + socket.id + ' joined channel ' + channel);
...

Basically, the server will:

  1. Notify the channel initiator of the arrival of a new join request.
  2. Allow the new client to enter the already existing room.
  3. Update (through a broadcast message) the channel initiator about the successful completion of the join operation, allowing it to prepare to start a new conversation.
The example page loaded in Firefox (channel ‘joiner')
Figure 4-7. The example page loaded in Firefox (channel joiner)

Such a sequence of actions is reported in Figure 4-8, which shows the server’s console at this stage of the call flow.

Signaling server managing joiner’s request
Figure 4-8. Signaling server managing joiner’s request

Figures 4-9 and 4-10 show, respectively, the joiner’s and initiator’s windows right after the former has successfully joined the signaling channel created by the latter. As the reader will recognize, this sequence of server-side actions is reported in red in the initiator’s HTML5 page in Figure 4-10, which now prompts the user for the very first message to be exchanged across the server-mediated communication path.

Joiner’s window after joining the channel
Figure 4-9. Joiner’s window after joining the channel
Starting a conversation after channel setup
Figure 4-10. Starting a conversation after channel setup

Starting a Server-Mediated Conversation

We have now arrived at the call flow stage reported in Figure 4-11, which basically captures the core of the application. In this phase, in fact, the initiator sends a first message to the joiner, who is first notified of this event and then prompted for the introduction of a proper answer.

Starting a conversation
Figure 4-11. Starting a conversation

As usual, the client retrieves the user’s input and emits a message towards the server in order for it to be properly dispatched. On the server’s side, the received message is simply broadcast[2] on the channel:

...
    // Handle 'message' messages
    socket.on('message', function (message) {
        log('S --> Got message: ', message);

        socket.broadcast.to(message.channel).emit('message', message.message);
    });
...

The above described server’s behavior is illustrated in the console snapshot of Figure 4-12.

Signalling server acting as a relay node
Figure 4-12. Signaling server acting as a relay node

Figure 4-13 shows the remote peer (the joiner) that has just received the message relayed by the server.

Remote peer receiving relayed message from signaling server
Figure 4-13. Remote peer receiving relayed message from signaling server

As evidenced by the figure, the following actions are performed:

  1. Logging the received message both on the JavaScript console and on the HTML5 page
  2. Prompting the receiver for proper input
  3. Sending the receiver’s answer back to the sender (across the signaling channel)

Such a sequence is driven by the following code snippet:

...
// Handle 'message' message
socket.on('message', function (message){
  console.log('Got message from other peer: ' + message);

  div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
    (performance.now() / 1000).toFixed(3) +
    ' --> Got message from other peer: </p>');

  div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
    message + '</p>');

  // Send back response message:
  // 1. Get response from user
  var myResponse = prompt('Send response to other peer:', "");

  // 2. Send it to remote peer (through server)
  socket.emit('response', {
    channel: channel,
        message: myResponse});

});
...

As soon as the receiver hits the OK button on the prompt window in Figure 4-14, the response message is emitted towards the server, which forwards it to the remote party:

...
   // Handle 'response' messages
    socket.on('response', function (response) {
        log('S --> Got response: ', response);

        // Just forward message to the other peer
        socket.broadcast.to(response.channel).emit('response',
            response.message);
    });
...

This behavior is once again illustrated by the server’s console snapshot in Figure 4-14.

Signaling server relaying remote peer’s response
Figure 4-14. Signaling server relaying remote peer’s response

Continuing to Chat Across the Channel

We are now in the steady-state portion of the application (Figure 4-15), where the two peers simply take turns in asking the server to relay messages towards the other party.

Signaling channel use in the steady-state
Figure 4-15. Signaling channel use in the steady state

Message exchanging is achieved on the client’s side through the following code:

...
// Handle 'response' message
socket.on('response', function (response){
  console.log('Got response from other peer: ' + response);

  div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
    (performance.now() / 1000).toFixed(3) +
    ' --> Got response from other peer: </p>');

  div.insertAdjacentHTML( 'beforeEnd', '<p style="color:blue">' +
    response + '</p>');

  // Keep on chatting
  var chatMessage = prompt('Keep on chatting. Write \
                            "Bye" to quit conversation', "");
  ...
  ...
     // Keep on going: send response back to remote party (through server)
   socket.emit('response', {
      channel: channel,
      message: chatMessage});
  }
});

Basically, upon reception of a new message, each peer performs the usual logging operations and then prompts the user for new input. As long as the inserted text has a value other than Bye, it sends a new message to the remote party. Figure 4-16 shows the initiator’s window right before a new message is emitted across the channel.

Keeping on chatting (initiator’s side)
Figure 4-16. Continuing the chat (initiator’s side)

Figure 4-17 in turn shows the server’s console upon reception of such a message, which is, as usual, broadcast to the remote party.

Keeping on chatting (server’s side)
Figure 4-17. Continuing the chat (server’s side)

Finally, Figure 4-18 shows reception of the relayed message on the receiver’s side.

Keeping on chatting (joiner’s side)
Figure 4-18. Continuing the chat (joiner’s side)

Closing the Signaling Channel

We are now ready to analyze channel teardown, as described in the call flow snippet in Figure 4-19.

Closing the signaling channel
Figure 4-19. Closing the signaling channel

The teardown procedure is actually triggered by the insertion of a Bye message in one of the two browsers (see Figure 4-20).

What happens behind the scenes is the following:

...
  // User wants to quit conversation: send 'Bye' to remote party
  if(chatMessage == "Bye"){
    div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
        (performance.now() / 1000).toFixed(3) +
        ' --> Sending "Bye" to server...</p>');
    console.log('Sending "Bye" to server');

    socket.emit('Bye', channel);

    div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
        (performance.now() / 1000).toFixed(3) +
        ' --> Going to disconnect...</p>');
        console.log('Going to disconnect...');

   // Disconnect from server
   socket.disconnect();
   }
...
Going to close channel through ‘Bye’ message
Figure 4-20. Closing channel through a Bye message

As we can see in the code, the disconnecting client first sends a Bye message across the channel and immediately thereafter closes the web socket (Figure 4-21).

As soon as the server gets the Bye message, it first relays it to the remote party and then closes the communication channel towards the disconnecting client:

...
    // Handle 'Bye' messages
    socket.on('Bye', function(channel){
        // Notify other peer
        socket.broadcast.to(channel).emit('Bye');

        // Close socket from server's side
        socket.disconnect();
    });

...
Initiator’s disconnection
Figure 4-21. Initiator’s disconnection

Let’s finally analyze the behavior of the peer receiving the Bye message from the remote party. The peer first logs information about the received message (both on the JavaScript console and inside the HTML5 page):

...
// Handle 'Bye' message
socket.on('Bye', function (){
  console.log('Got "Bye" from other peer! Going to disconnect...');

  div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
    (performance.now() / 1000).toFixed(3) +
    ' --> Got "Bye" from other peer!</p>');
...

Then, an Ack message is sent back to the server to confirm reception of the disconnection request:

...
  div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
    (performance.now() / 1000).toFixed(3) +
    ' --> Sending "Ack" to server</p>');

  // Send 'Ack' back to remote party (through server)
  console.log('Sending "Ack" to server');

  socket.emit('Ack');

  // Disconnect from server
  div.insertAdjacentHTML( 'beforeEnd', '<p>Time: ' +
    (performance.now() / 1000).toFixed(3) +
    ' --> Going to disconnect...</p>');

  console.log('Going to disconnect...');

  socket.disconnect();
...

Finally, the receiving peer tears down its own connection to the server:

...
  console.log('Going to disconnect...');

  socket.disconnect();
});

The above sequence of actions can be easily identified in the snapshot in Figure 4-22.

Remote peer handling relayed disconnection message and disconnecting
Figure 4-22. Remote peer handling relayed disconnection message and disconnecting

The final actions are undertaken on the server’s side. Reception of the Ack message is logged on the console (see Figure 4-23) and the channel is eventually torn down:

    // Handle 'Ack' messages
    socket.on('Ack', function () {
        console.log('Got an Ack!');
        // Close socket from server's side
        socket.disconnect();
    });
Closing channel on the server’s side
Figure 4-23. Closing channel on the server’s side


[2] Note that broadcasting on a channel made of just two peers is equivalent to sending a notification to the peer who was not the sender of the message itself.

Get Real-Time Communication with WebRTC now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.