Making Things P2P With WebRTC

Cam White
4 min readMar 21, 2018

--

What is WebRTC?

The long and short of it is, WebRTC is an open source javascript library that allows the browser to do something it was never designed for. The ability to exchange data directly from one client to another. That’s a pretty big deal and has opened the doors for very robust p2p applications in the browser.

How does it work?

Magic

Let’s Code Something 💯

A p2p video stream.

Tech Stack

  • ES2015
  • Node
  • Socket.io

I will not be providing all the code to do the job, but I will do my best to clue you in on how to do it yourself.

Our Signaling ⚡

Imagine we have our server up and running on localhost:3000 this will be where we do our exchange of information between clients to allow the p2p connection. In other words, each client has to pass data from one to another containing their own localDescription to be set as their peer's remoteDescription. There is also the process of adding ICECandidates, to break through any firewall that may be in place. The following server-side code could broker that process.

io.on('connection', (socket) => {  
io.broadcast.emit('call:join', {
initiatorId: socket.id
});
socket.on('call:signal', (signal) => {
io.to(signal.to).emit('call:signal', signal);
});
});

In the above code, a call:join event is being emitted on connection containing that specific socket's unique id. Keep in mind we are using the broadcast.emit method to signal this event to all clients but ourselves. We also listen for a call:signal event, which in turn emits to a single client using the socket.io concept of rooms.

Our Client 💻

Here is where the fun really happens! We will use the ES2015 class syntax to encapsulate our client-side logic, passing the constructor a connected socket and the localStream.

Get the localStream via the navigator and in most cases we need to include a webrtc adapter, this is a neat one.

class WebRTC {  
constructor(socket, localStream) {
this.socket = socket;
this.localStream = localStream;
this.socket.on('call:join', (call) => {
this.peerId = call.initiatorId;
this.makeOffer()
});
this.socket.on('call:signal', (signal) => this.handleSignal(signal))
}
......

Here we bind the constructor’s arguments to this, making them available to the methods we will create. We also listen for the call:join and call:signal socket events that will come from the server.

Next we need a method, which we will call later on, to handle creation and configuration of the RTCPeerConnection object.

......
createPc() {
let servers = {'iceServers': [
{'url': 'stun:stun.ekiga.net'}
]};
this.pc = new RTCPeerConnection(servers);
this.pc.addStream(this.localStream);
this.pc.onicecandidate = (evt) => {
if(evt.candidate != null) {
this.socket.emit('call:signal', {
by: this.socket.socket.id,
to: this.peerId,
ice: evt.candidate,
type: 'ice'
});
}
};
this.pc.onaddstream = (evt) => {
if(evt.stream != null) {
this.remoteStream = window.URL.createObjectURL(evt.stream));
}
};
}
......

In this method we construct our RTCPeerConnection passing it a stun server then adding our own stream. We also set the event handlers needed for this stripped down example, the two of them being onicecandidate and onaddstream. Notice how on adding a succesfull ice candidate we pass it on to our peer via the socket.

Going further, we need to make an offer to our peer. Who, remember, needs our localDescription to be set as their remoteDescription and vice-versa.

......
makeOffer() {
this.createPc();
this.pc.createOffer(sdp => {
this.pc.setLocalDescription(sdp, () => {
this.socket.emit('call:signal', {
by: this.socket.socket.id,
to: this.peerId,
sdp: sdp,
type: 'sdp-offer'
});
}, (err) => this.handleErrors(err));
}, (err) => this.handleErrors(err));
}
......

What’s going on above? First, we build our pc object by invoking the createPc method we coded earlier. After which we call the createOffer method that is now available on this.pc, that accepts two callbacks as arguments. The first of these callbacks contains our session description or sdp while second would contain any errors if they were to arise. In the case of no errors we set our local description to the newly created session description. Finally, within setLocalDescription 's callback we emit our sdp along with the information necessary to pass it along to our peer.

Alright, if you’re feeling a little overwhelmed at this point I totally understand. I know I was, but let’s soldier on to the most integral part. The handling of our call:signal event we set a listener for way back in the constructor.

......
handleSignal(signal) {
switch(signal.type) {
case 'sdp-offer':
this.pc.setRemoteDescription(new RTCSessionDescription(signal.sdp), () => {
console.log('Setting remote description by offer');
this.pc.createAnswer(sdp => {
this.pc.setLocalDescription(sdp, () => {
this.socket.emit('call:signal', {
by: signal.to,
to: signal.by,
sdp: sdp,
type: 'sdp-answer'
});
}, (err) => this.handleErrors(err));
}, (err) => this.handleErrors(err));
}, (err) => this.handleErrors(err));
break;
case 'sdp-answer':
this.pc.setRemoteDescription(new RTCSessionDescription(signal.sdp), () => {
console.log('Setting remote description by answer');
}, (err) => this.handleErrors(err));
break;
case 'ice':
if(signal.ice) {
console.log('Adding ice candidates');
this.pc.addIceCandidate(new RTCIceCandidate(signal.ice));
}
break;
}
}
handleErrors(err) {
// Do something with your errors :x
}
}

To handle our signal we switch on the type of signal being caught, with a case for the three types of signals we can receive. In the case of an sdp-offer, we set our remoteDescription to reflect the session description we have just received from our peer. Then we call createAnswer which returns us our own session description to be be set as our localDescription. In turn passing it back to our peer, keep in mind we set the type to sdp-answer so our switch knows what to do with it. Next we have a case for the aforementioned spd-answer signal type. Where we simply set the session description delivered, again via the server, from our peer to our own remoteDescription. The last case, ice, handles the process of NAT traversal allowing connection even if a firewall is in place.

At this point, as long as we didn’t hit any errors, our peers are connected. Which would fire the onaddstream event we set a handler for earlier, giving us our peer's video stream that could easily be set to a video element's src attribute. I'm not going to go get into that in this post, though.

--

--