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.