Skip to main content

WebRTC: Setup a One-to-One Video Call

By July 20, 2020June 27th, 2022Streaming Media, Tutorials, WebRTC

What is WebRTC?

WebRTC is a set of standards, protocols and APIs that allow connected peers to share video, audio, and/or data communications. It uses stun/turn servers for NAT traversal to help peers discover the origin of their location. Once this info is known the peers can exchange this information manually or through a signaling server to establish a connection between each other. It does this without the need for plugins and is supported among desktop and mobile browsers. If you want to dig into the details of WebRTC the following articles have a great amount of helpful information:

Bringing it Back

A few years ago when I released the first Bearcoda blog I wrote an article on WebRTC. This was a new technology at the time and examples on how to get something working was limited. As a long time streaming expert the topic interested me so I decided to write a tutorial on how to get a one-to-one call going. Over time the article got lost as we shifted our focus. In the spirit of bringing things back I decided to revive the tutorial and see how the tech has changed. Now if you want to skip the tutorial and just get the sample you’ll find it at the following location:

https://github.com/joejustcodes/webrtc-sample

Requirements

  • Familiarity with HTML, CSS, Javascript
  • NodeJS

For this tutorial you will need a basic understanding of web development. I’ll help and cover the details but having some understanding will definitely help. You should also have NodeJS with npm installed on your local dev machine. We’re going to use it to setup the signaling server and to host the public files. Though we can have fun with the latest front-end frameworks I’m going to omit them for now and just use the minimal setup.

Configuring the Server

You’ll want to start by creating a new folder you in your dev environment and call it webrtc-sample. In the folder go ahead and open a new console window (powershell, terminal, etc). If you already successfully installed node with npm then you should have access to the npm command. You’ll want to run the following line:

npm init

Follow the prompts, most of the defaults should work but you can change it up. Once your done you should notice a new package.json file. If it’s there then you’re ready for the next step. We’re going to install both Express and Socket.io. Run the following command:

npm install express socket.io --save

Create an index.js file and paste the following code in there.

//Load express and server
const express = require('express');
const app = express();
const port = 3000;

//Secure options
//Uncomment below for https secure setup
/*
const https = require('https');
const fs = require('fs');
const options = {
	key: fs.readFileSync('./server.key'),
	cert: fs.readFileSync('./server.cert')
};

const server = https.createServer(options, app);
*/

//Comment the line below if you are setting up https
const server = require('http').Server(app);

//Setup app to use public folder to serve static files 
app.use(express.static('public'));

//Activate socket server
let io = require('socket.io').listen(server);

//client list
let clients = {};

//Fires when a new socket io connection has been detected.
io.sockets.on( 'connection', socket => {

	//Save User
	clients[socket.id] = {
		id:socket.id,
		socket:socket	
	}
	
	//Add dispatchEvent to listeners
	socket.on( "dispatchEvent", data => {
		for( let i in clients ) {
			if( socket.id != i ) clients[i].socket.emit( "onEvent", data );
		}
	});
	
	//Fires when a client has disconnected
	socket.on( 'disconnect', () => {
		clients[socket.id] = undefined;
		delete clients[socket.id];
	});
});

//Start and listen on server
server.listen( port, () => console.log(`Listening on ${port}`) );

This code basically contains the Nodejs code we need to serve the static Http files and also run the mini signaling server we need for peers to exchange information. I won’t go into the details but you can check out the comments in the code for information.

Note in the code that there is a section of the script for https setup. You will need this for testing outside of the localhost environment. Generally browsers may restrict access to required WebRTC features without a secure connection. You will need to provide your own signed certificates to run under https so make sure to update the path to those in the code.

Setting up the web files

Next, create a new folder and call it public. We’re going to use this folder to place all our web files. Inside that folder you will create another folder called js with a file called signaling.js. Include the following code:

(function()
{
  Signaling = function( httpPath )
  {
    if( httpPath ) {
      this._socket = io.connect(httpPath);
      this._socket.on( "onEvent", this.__onServerMessage.bind(this) );
    }
  }
  
  Signaling.prototype._socket;
  Signaling.prototype._callback;
  
  /*
   * PUBLIC API
   */
  
  Signaling.prototype.send = function( data ) {
    if( this._socket ) this._socket.emit( "dispatchEvent", data );
  }
  
  Signaling.prototype.onMessage = function( callback ) {
    if( typeof(callback) != "function" ) return;
    this._callback = callback;
  }

  /*
   * PRIVATE API
   */

  Signaling.prototype.__onServerMessage = function( event ) {
    if( this._callback ) this._callback(event);
  }
})();

If you analyze the code a bit you’ll see that all the code does is dispatch events which the node backend distributes to all connected users. This makes it easy to send the rtc descriptions required to allow the peers to connect. We’ll go into the details in a bit.

In the public folder create an index.html file. Include the following template code:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8"/>
  <meta name="description" content="" />
  
  <title>WebRTC Sample</title>

  <style type="text/css">
    video {
      background-color: #000;
    }
    
    #startBtn {
      width: 320px;
      display: block;
    }
    
  </style>

</head>
<body>
  
  <div>
    <video id="selfView" autoPlay="true" width="320" height="240"></video>
    <video id="remoteView" style="display: none;" autoPlay="true" width="320" height="240"></video>
  </div>
  <button id="startBtn" onclick="startCall();">Start</button>

  <!-- Dependency Classes -->
  <script src="https://webrtc.github.io/adapter/adapter-latest.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js" type="text/javascript"></script>
  <script src="js/signaling.js" type="text/javascript"></script>

  <script type="text/javascript">
    
    /**
     *
     * WEBRTC LOGIC HERE
     *  
     */

  </script>
</body>
</html>

Okay so let’s go over what’s in the template. Within the body, you’ll find the first element that holds the two video elements. One will be for your local cam and the other will be for the remote user’s camera feed. The start button will connect the two users together and start the call.

Following that, you will notice three script tags. The first script loads a helpful utility that deals with support fragmentation across browsers. Since the API across different browsers, this basically helps you bring them together into one. The second script loads in the Socket.io client API to help you connect to the node backend. The last script is the signaling server script we created earlier.

Lastly, I included another script tag we will use to write in our logic.

Writing the WebRTC Logic

To start locate the script tag that has “WEBRTC LOGIC HERE” here comment within.

<script type="text/javascript">
    
  /**
   *
   * WEBRTC LOGIC HERE
   *  
   */

</script>

Within the script tag you’re going to add the following code:

var rtcConnection, signaling;
    
//Get elements from DOM
var selfView = document.getElementById("selfView"),
    remoteView = document.getElementById("remoteView");		

//setup connection
setupConnection();

The first line are variables that will hold instances for the RTCPeerConnection and Signaling objects. The next line queries the document for the video elements we placed in the html. The last line calls the method we will use to setup the connection.

Next, we’re going to setup the setupConnection method. Add the following code to your script:

//Setup local cam and signaling server
function setupConnection() {
  
  //Uncomment to use NodeJS sample
  signaling = new Signaling("http://localhost:3000");
  
  //For own implementation use same API or comment out signaling methods and implement own API
  signaling.onMessage( onSignalingEvent );
  
  //Create new rtc connection
  rtcConnection = new RTCPeerConnection({iceServers: [{urls: "stun:stun.l.google.com:19305"}]});
  
  //Listen to rtc events
  rtcConnection.onicecandidate = function( evt ) 
  {
    //console.log('candidate info', evt.candidate);
    if( evt.candidate != undefined ) signaling.send( JSON.stringify({"type":"candidate","candidate":evt.candidate }));
  }
  
  rtcConnection.onaddstream = function( evt ) {

    remoteView.setAttribute('style', null);

    //Attach incoming stream to remote view
    attachMediaStream(remoteView, evt.stream);
  }

  navigator.mediaDevices.getUserMedia({"video":true, "audio":true})
  .then( function(stream) {
    
    //Attach local media stream to video element
    attachMediaStream(selfView, stream);
    
    //Attach stream to the RTC connection
    rtcConnection.addStream(stream);
  })
  .catch( function( err ) {
    console.log("##ERROR:There was an error getting the requested media#");
  });
}

Okay so the first line creates a new connection to the signaling server. Remember we will need this so that peers can send their local descriptions to connect to each other. The next line sets up a callback to receive a notification when another user has sent an event. Next, we set up a new RTCPeerConnection. This helps other peers connect to you by returning information that will allow other peers to locate you. If you notice we’re using stun server that is publicly available.

Keep in mind this should help most peers connect but in order to get more coverage you’ll need access to a turn server. There are resources out there that will help you setup like Coturn and you can also go with a hosted solution from providers like Twilio and Xirsys.

You’ll also notice that we listen to two events on the rtc connection. The first one is to detect when candidates have been added. We’ll want to relay this info to the remote user to make sure everyone is on sync. The second detects when a new stream has been detected which will come from the remote user. In this case, we’ll want to attach the remote stream to our local video element. Lastly, in order to start, we’ll need access to our local camera. We use the getUserMedia method to get access. The method returns a promise which if successful returns a reference to the camera stream. We handle connecting the local video element and stream together via attachMediaStream. That should complete the setupConnection method for now.

Next, add the following code:

//Attaches the stream to target video element
function attachMediaStream( video, stream ) {

  const videoTracks = stream.getVideoTracks();
  console.log(`Using video device: ${videoTracks[0].label}`);
  
  video.srcObject = stream;
}

The attachMediaStream method simply attaches the given video element to the stream and also displays on the console what camera is being used.

Okay so now we’re going to work on the logic that initiates the call:

function startCall() {
  rtcConnection.createOffer( function( desc ) {
    
    //Fires when the offer creates the description
    rtcConnection.setLocalDescription(desc);
    signaling.send( JSON.stringify({"type":"sdp","sdp":desc }));
  },

  //Handle any error calls
  function() {
    console.log("##ERROR:There was an error with the description#");
  });
}

The startCall method gets called when the button element is clicked. When it’s called it creates an offer to the WebRTC connection. This returns an sdp description which contains kind of like a state of the current connection. It will include information on like stream tracks and peers that are already connected. This essentially helps other peers connect to your stream. If you noticed once we get the sdp we attach it to our local description and also send that information to other connected users via the signaling server.

Next, we need to write the logic that allows us to handle the events coming from other users through the signaling server. Go ahead and add the following code:

function onSignalingEvent( event ) {
    
  //parse data
  event = JSON.parse(event);
  switch( event.type )
  {
    case "candidate" : 
      rtcConnection.addIceCandidate(new RTCIceCandidate(event.candidate));
      break;
    case "sdp" :  
      //Gets description from caller and set it to RTC connection
      rtcConnection.setRemoteDescription(new RTCSessionDescription(event.sdp));
      
      //Create a description to send back to the caller
      rtcConnection.createAnswer( function(desc) {
        rtcConnection.setLocalDescription(desc);
        signaling.send( JSON.stringify({"type":"sdpRemote","sdp":desc }));
      },
      //Handles Error
      function() {
        console.log("##ERROR:There was an error with the description#");
      });
      break;
    case "sdpRemote" : 
      //Get the description from the callee and set it to the RTC connection
      rtcConnection.setRemoteDescription(new RTCSessionDescription(event.sdp));
      break;
  }
}

The method is broken down into three parts. Handling of a new ice candidate, receiving the new incoming sdp description and receiving the remote sdp information. So this is a break down of the steps:

  1. First user initiates the call thus sending the sdp information through the signaling server where other peers could be connected.
  2. If a peer is listening then this method receives the initial request and sets it to their local rtc connection via setRemoteDescription.
  3.  Then that peer also creates an offer of their own and sends it back to peer who initiated the call via the sdpRemote event.
  4. The caller gets the offer from the remote peer and also adds the sdp info through setRemoteDescription.
  5. Adding the sdp information will fire onaddstream on the rtc connection on both ends. If you remember we added this listener in the setupConnection method.
rtcConnection.onaddstream = function( evt ) {

  remoteView.setAttribute('style', null);

  //Attach incoming stream to remote view
  attachMediaStream(remoteView, evt.stream);
}

That’s basically it, fire it up and have yourself a call. Keep in mind this only works for two users on at the same time. This does not maintain a state of who is broadcasting and who has arrive or left. Make sure you check out the repo to make sure everything is in order.

Challenge Yourself!

The sample above is a base example to show you how to initiate and establish a call. You can still have more fun with it. Here are some cool things you can work on if you want to build on it.

  1. Detect when a user has disconnected to make sure you end the call on both ends. You can do this by listening to the socket.io ondisconnect event on the server and firing an event to the listener still left.
  2. Give each user their own id and allow the caller to target a specific id like a phonenumber. You can do this by assigning a random id to the connected user onconnect. When a user initiates the call have them input and send the id to the target user. When the sdp info is received if the info matches then you alert the user of the call request.

Well that’s it for now. If you liked this topic and would like to see more let me know!