Modern JavaScript Applications
上QQ阅读APP看书,第一时间看更新

Creating a chatroulette

The chatroulette that we will build is only for people residing in India, that is, a peer cannot connect to the PeerServer if the IP address of the peer doesn't resolve to India. We added this filter to make the website a little more complex to code so that you can learn how to check whether a user is allowed to connect to PeerServer or not.

We will use a single server that will serve webpages and also act as a PeerServer, that is, we will integrate PeerServer with the Express server.

We won't get into designing the frontend of our chatroulette. We will only be concentrating on building the architecture and functionalities.

The exercise files for this chapter contain two directories: Chatroulette and Custom-PeerServer. In the Chatroulette directory, there are two directories: Initial and Final. In the Final directory, you will find the complete chatroulette source code. In the Initial directory, you will only find the HTML code for our chatroulette. The Initial directory is to help you quickly get started with building the chatroulette.

You will place the code related to the frontend functionality of the site in the Initial/public/js/main.js file and you will place the code related to the server side functionality in the Initial/app.js file.

Building the backend

Our site will basically contain three URL end points: a root path for serving the home page, the /find path to find the ID of a free user for chatting, and finally the /signaling path that serves as the end point for PeerServer.

Every user will have a unique ID that is generated by PeerServer. For a user to retrieve the ID of another free user using the /find URL, they must be first be connected to PeerServer.

The server will maintain two different arrays, that is, the first array contains IDs of the users connected to PeerServer and the second array contains IDs of the users that need a partner to chat.

Let's get started with building our backend. Place the following code in the app.js file to create our web server and serve the home page of our site:

var express = require("express");
var app = express();

app.use(express.static(__dirname + "/public"));

app.get("/", function(httpRequest, httpResponse, next){
  httpResponse.sendFile(__dirname + "/public/html/index.html");
})

var server = app.listen(8080);

Here we are serving the index.html file as our home page. Run the node app.js command to start the server. I am assuming that you are running node.js on the localhost, so open the http://localhost:8080/ URL on the browser to see the home page. The home page should look similar to the following image:

The following are the different elements of the home page:

  • At the top of the home page, we will display the status of the PeerServer connection, DataConnection, and MediaConnection.
  • Then we will display a video element and message box. MediaStream of the remote peer will be rendered on the video element.
  • Then we have drop-down boxes for the user to select a microphone and webcam that they want to use if they have multiple microphones or webcams connected to their computer.
  • Then we have checkboxes that allow the users to pause or resume their audio and video.
  • Finally, we have a button that allows the user to disconnect from the current user and chat with another user.

Every interactive element in the HTML page has an ID associated with it. While coding the frontend of the website, we will be using their IDs to get their reference.

Now let's create our signaling server. Here is the code for this. Place it in the app.js file:

var requestIp = require("request-ip");
var geoip = require("geoip-lite");

app.use("/signaling", function(httpRequest, httpResponse, next){

  var clientIp = requestIp.getClientIp(httpRequest);
  var geo = geoip.lookup(clientIp);

  if(geo != null)
  {
    if(geo.country == "IN")
    {
      next();
    }
    else
    {
      httpResponse.end();
    }
  }
  else
  {
    next();
  }
});

var ExpressPeerServer = require("peer").ExpressPeerServer(server);

app.use("/signaling", ExpressPeerServer);

var connected_users = [];

ExpressPeerServer.on("connection", function(id){
  var idx = connected_users.indexOf(id); 
  if(idx === -1) //only add id if it's not in the array yet
  {
    connected_users.push(id);
  }
});

ExpressPeerServer.on("disconnect", function(id){
  var idx = connected_users.indexOf(id); 
  if(idx !== -1) 
  {
    connected_users.splice(idx, 1);
  }

  idx = waiting_peers.indexOf(id);
  if(idx !== -1) 
  {
    waiting_peers.splice(idx, 1);
  }  
});

The following is how the code works:

  • Before the user can connect to PeerServer, we will find the country to which the IP address of the user belongs. We will find the IP address using the request-ip module and resolve the IP address to the country using the geoip-lite module. If the country is IN or the country name couldn't be resolved, then we will allow the user to connect to PeerServer by triggering the next middleware, otherwise we will stop them by sending an empty response.
  • When a user connects to PeerServer, we will add the ID of the user in the connected_users array that maintains a list IDs if the users that are connected to PeerServer. Similarly, when the user disconnects from the PeerServer, we will remove the ID of the user from the connected_users array.

Now let's define route for the /find path using which a user can find another user who is free to chat. The following is the code for this. Place this code in the app.js file:

var waiting_peers = [];

app.get("/find", function(httpRequest, httpResponse, next){

  var id = httpRequest.query.id;

  if(connected_users.indexOf(id) !== -1)
  {

    var idx = waiting_peers.indexOf(id); 
     if(idx === -1) 
    {
      waiting_peers.push(id);
    }

    if(waiting_peers.length > 1)
    {
      waiting_peers.splice(idx, 1);  
      var user_found = waiting_peers[0];
      waiting_peers.splice(0, 1);
      httpResponse.send(user_found);
    }
    else
    {
      httpResponse.status(404).send("Not found");
    }
  }
  else
  {
    httpResponse.status(404).send("Not found");
  }
})

Here is how the code works:

  • The waiting_users array holds the IDs of the users who are free and looking for a partner to chat to.
  • When a user makes a request to the /find path, the route handler first checks whether the user is connected to PeerServer or not by checking whether the user ID is present in the connected_users array.
  • If the user is not connected to PeerServer, then it sends an HTTP 404 error. If the user is connected to PeerServer, then it checks whether the user's ID is present in the waiting_list array. If not, it adds in the array and proceeds.
  • Now it checks whether any other user ID is also present in the waiting_list array, and if yes, then it sends the first user ID in the list and then removes all user IDs from the waiting_list array. If it doesn't find any other user ID in the waiting_list array, then it simply sends 404 error.

Now we are done building the backend of our website. Before we get into building the frontend of our site, make sure that you restart the server with the latest code.

Building the frontend

First of all, as soon as the home page loads, we need to find the microphones and webcams connected to the user computer and list them so that the user can choose the desired device. The following is the code to do this. Place this code in the main.js file:

window.addEventListener("load", function(){
  MediaStreamTrack.getSources(function(devices){
    var audioCount = 1;
    var videoCount = 1;

    for(var count = 0; count < devices.length; count++)
    {
      if(devices[count].kind == "audio")
      {
        var name = "";

        if(devices[count].label == "")
        {
          name = "Microphone " + audioCount;
          audioCount++;
        }
        else
        {
          name = devices[count].label;
        }

        document.getElementById("audioInput").innerHTML = document.getElementById("audioInput").innerHTML + "<option value='" + devices[count].id + "'>" + name + "</option>";
      }
      else if(devices[count].kind == "video")
      {
        var name = "";

        if(devices[count].label == "")
        {
          name = "Webcam " + videoCount;
          videoCount++;
        }
        else
        {
          name = devices[count].label;
        }

        document.getElementById("videoInput").innerHTML = document.getElementById("videoInput").innerHTML + "<option value='" + devices[count].id + "'>" + name + "</option>";
      }
    }
  });
});

Here we are retrieving the audio and video input devices using MediaStream.getSources and populating the <select> tags so that the user can choose an option.

As soon as the home page loads, we also need to create a Peer instance. Here is the code to do this. Place this code in the main.js file:

var peer = null;
var dc = null;
var mc = null;
var ms = null;
var rms = null;

window.addEventListener("load", function(){
  peer = new Peer({host: "localhost", port: 8080, path: "/signaling", debug: true}); 

  peer.on("disconnected", function(){

    var interval = setInterval(function(){
      if(peer.open == true || peer.destroyed == true)
      {
        clearInterval(interval);
      }
      else
      {
        peer.reconnect();
      }
    }, 4000)
  })

  peer.on("connection", function(dataConnection){
    if(dc == null || dc.open == false)
    {
      dc = dataConnection;

      dc.on("data", function(data){
        document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + "<li><span class='right'>" + data + "</span><div class='clear'></div></li> ";
        document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
      })

      dc.on("close", function(){
        document.getElementById("messages").innerHTML = "";
      })
    }
    else
    {
      dataConnection.close();
    }
  })

  peer.on("call", function(mediaConnection){
    if(mc == null || mc.open == false)
    {
      mc = mediaConnection;
      navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
      navigator.getUserMedia({video: true, audio: true}, function(mediaStream) {
        ms = mediaStream;
        mc.answer(mediaStream);
        mc.on("stream", function(remoteStream){
          rms = remoteStream;
          document.getElementById("peerVideo").setAttribute("src", URL.createObjectURL(remoteStream));
          document.getElementById("peerVideo").play();
        })

      }, function(e){ alert("An error occured while retrieving webcam and microphone stream"); })
    }
    else
    {
      mediaConnection.close();
    }
  })
});

Here is how the code works:

  • First we declared five global variables. peer will hold reference for the Peer instance, dc will hold reference for DataConnection, mc will hold reference for MediaConnection, ms will hold reference for the local MediaStream, and rms will hold reference for the remote MediaStream.
  • Then, as soon as the page finished loading, we connected to PeerServer, creating a Peer instance and attaching event handlers for the disconnected, connection, and call event handlers.
  • Then we made sure that in case a peer gets disconnected from PeerServer due to some reason, then it automatically tries to connect to PeerServer.
  • If another peer tries to establish DataConnection with us, then we will only accept it if there is no other DataConnection currently established, otherwise we will reject it. After accepting DataConnection, we attached the event handlers for the data and close events to print the incoming messages in the chat box, and clear all messages in the chat box if DataConnection is closed.
  • Similarly, if another peer tries to establish MediaConnection with us, we will only accept it if there is no other MediaConnection currently established, otherwise we will reject it. After accepting the MediaConnection, we will attach the event handler for the stream event so that when remote MediaStream arrives, we can display it.

In the preceding code, we are waiting for another peer to establish DataConnection and MediaConnection with us.

Now let's write a code to find a free peer and establish DataConnection and MediaConnection with it. The following is the code for this. Place this code in the main.js file:

function ajaxRequestObject()
{
  var request;
  if(window.XMLHttpRequest)
  {
    request = new XMLHttpRequest();
  }
  else if(window.ActiveXObject) 
  {
    try 
    {
      request = new ActiveXObject('Msxml2.XMLHTTP');
    }
    catch (e)
    {
      request = new ActiveXObject('Microsoft.XMLHTTP');
    }
  }

  return request;
}

function connectToNextPeer()
{
  var request = ajaxRequestObject();

  var url = "/find?id=" + peer.id;

  request.open("GET", url);

  request.addEventListener("load", function(){
    if(request.readyState === 4) 
    {
      if(request.status === 200) 
      {
        dc = peer.connect(request.responseText, {reliable: true, ordered: true});

        dc.on("data", function(data){
          document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + 
          "<li><span class='right'>" + data + "</span><div class='clear'></div></li>";
          document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
        })

        dc.on("close", function(){
          document.getElementById("messages").innerHTML = "";
        })

        navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
        
        var audioInputID = document.getElementById("audioInput").options[document.getElementById("audioInput").selectedIndex].value;
        var videoInputID = document.getElementById("videoInput").options[document.getElementById("videoInput").selectedIndex].value;

        navigator.getUserMedia({video: {mandatory: {sourceId: videoInputID}}, audio: {mandatory: {sourceId: audioInputID}}}, function(mediaStream) {
          ms = mediaStream;

          if(document.getElementById("audioToggle").checked)
          {
            var tracks = ms.getAudioTracks();
            if(document.getElementById("audioToggle").checked)
            {
              tracks[0].enabled = true;
            }
            else
            {
              tracks[0].enabled = false;
            }
          }

          if(document.getElementById("videoToggle").checked)
          {
            var tracks = ms.getVideoTracks();
            if(document.getElementById("videoToggle").checked)
            {
              tracks[0].enabled = true;
            }
            else
            {
              tracks[0].enabled = false;
            }
          }

          mc = peer.call(request.responseText, ms);

          mc.on("stream", function(remoteStream){
            rms = remoteStream;
            document.getElementById("peerVideo").setAttribute("src", URL.createObjectURL(remoteStream));
            document.getElementById("peerVideo").play();
          })

        }, function(e){ alert("An error occured while retrieving webcam and microphone stream"); });

      }
    }
  }, false);

  request.send(null);
}

function communication()
{
  if(peer != null && peer.disconnected == false && peer.destroyed == false)
  {
    if(dc == null || mc == null || dc.open == false || mc.open == false)
    {
      connectToNextPeer();
    }
  }
}

setInterval(communication, 4000);

This code is long but easy to understand. Here is how the code works:

  • First we defined a ajaxRequestObject() function that just returns an AJAX object and hides browser differences by creating an AJAX object.
  • Then we defined the connectToNextPeer() method that makes requests for a free ID from the /next path, and if found, it establishes DataConnection and MediaConnection with this peer. It also attaches the necessary event handlers that are same as the previous code.
  • While retrieving MediaStream, it uses the device selected by the user in the dropdown.
  • Before calling the other peer, it sets the enabled property to true or false, depending on whether the checkbox is checked or not respectively.
  • Finally, we set a timer that calls the connectToNext() peer once in every four second if the peer is connected to PeerServer, and MediaConnection or DataConnection is currently not established with another peer.

Now we need to write code to send the message to a connected peer when the user presses the Enter key on the text input fields of the message box. Here is the code to do this. Place this code in the main.js file:

document.getElementById("message-input-box").addEventListener("keypress", function(){
  if(dc != null && dc.open == true)
  {
    var key = window.event.keyCode;
    if (key == 13) 
    {
      var message = document.getElementById("message-input-box").value;
      document.getElementById("message-input-box").value = "";
      dc.send(message);
      document.getElementById("messages").innerHTML = document.getElementById("messages").innerHTML + "<li><span class='left'>" + message + "</span><div class='clear'></div></li> ";
      document.getElementById("messages-container").scrollTop = document.getElementById("messages-container").scrollHeight;
    }
    else
    {
      return;
    }
  }
})

Here, at first, we are checking whether DataConnection is established or not. If DataConnection is currently established, then we will send a message to the connected peer and also display the message in the message box.

Now we need to write the code to pause or resume audio and video when the user toggles the checkboxes. The following is the code to do this. Place this code in the main.js file:

document.getElementById("videoToggle").addEventListener("click", function(){
  if(ms !== null)
  {
    var tracks = ms.getVideoTracks();

    if(document.getElementById("videoToggle").checked)
    {
      tracks[0].enabled = true;
    }
    else
    {
      tracks[0].enabled = false;
    }
  }
});

document.getElementById("audioToggle").addEventListener("click", function(){
  if(ms !== null)
  {
    var tracks = ms.getAudioTracks();

    if(document.getElementById("audioToggle").checked)
    {
      tracks[0].enabled = true;
    }
    else
    {
      tracks[0].enabled = false;
    }
  }
});

Here we are achieving this functionality by assigning true or false to the enabled property of the tracks.

We need to close MediaConnection and DataConnection and find another user for chatting when the user clicks on the Next User button. The following is the code to do this. Place this code in the main.js file:

document.getElementById("next").addEventListener("click", function(){
  if(mc != null)
  {
    mc.close();
  }

  if(dc != null)
  {
    dc.close();
  }

  connectToNextPeer();  
})

If there is any MediaConnection or DataConnection currently established, then we are closing it. Then we will call the connectToNextPeer() method to establish MediaConnection and DataConnection.

Now we finally need to display the status of the peer-to-peer connection and PeerServer connection. Here is the code to do this. Place this code in the main.js file:

setInterval(function(){
  if(dc == null || mc == null || dc.open == false || mc.open == false)
  {
    document.getElementById("peerStatus").innerHTML = "Waiting for a free peer";
  }
  else
  {
    document.getElementById("peerStatus").innerHTML = "Connected to a peer";
  }

  if(peer != null && peer.disconnected == false && peer.destroyed == false)
  {
    document.getElementById("peerServerStatus").innerHTML = "Connected to PeerServer";
  }
  else
  {
    document.getElementById("peerServerStatus").innerHTML = "Not connected to PeerServer";
  }
}, 4000);

Here we are checking and updating the status every 4 seconds.

Testing the website

To test the chatroulette website we just created, first make sure that the server is running and then open the http://localhost:8080/ URL in two different tabs, browsers, or devices.

Now you will see that both of them automatically get connected and are able to chat with each other.