WebRTC signaling using signal-fire

I spent part of my time recently reading about WebRTC - real time communication between peers using no plugins (in most browsers). I will assume you are aware of what WebRTC is and how it works. If you don't, I suggest you read the Wikipedia page.

WebRTC allows true peer-to-peer connections in the browser environment (and browser-based environments such as Electron). The best part (I think) is that in cases where the user's networks allow it, there is no server required. Actual peer-to-peer in the browser was never this easy!

Note: At the time of writing WebRTC is still an evolving spec and is not supported by all browsers yet. See the Wikipedia page to see which browsers support WebRTC.

Signaling

I lied - a server is still required. A server is necessary to exchange connection options between the peers. This isn't a data-intensive process - it's literally done in JSON. This is a so-called signaling server - a relay server, nothing more.

Implementing a signaling server is outside the scope of the WebRTC spec. This means you're responsible for this yourself. You can use whatever technology you want - WebSockets, for example.

Wouldn't it be great if someone already did that? Well, they did - here, here, and here.

I will not explain the specifics of signaling. Mozilla has some good documentation, which I will not repeat here.

Signaling with signal-fire

Partly as an exercise, partly as an honest attempt to contribute some useful code to the node.js platform, I wrote my own signaling server. My goals were no-nonsense (no useless 'features'), easy to use (almost just click and go), and scalability.

Enter signal-fire. That is the name of my node.js signaling server. It has the following features:

  • WebSockets powered WebRTC signaling server
    • Use ws (default) or µWS for easy communication
    • Messages are passed using simple JSON objects
  • Automatic peer ID generation (also possible to provide your own method)
  • Completely automatic routing of messages
  • Supports one-to-one, one-to-many and many-to-many out of the box
  • Horizontally scalable
    • Relays use any publish-subscribe messaging back-end (Redis, MQTT, ...)
  • Uses ES6 syntax and has been tested on node.js v7.1.0

As you can see, there are some features worth looking at. It's still a simple and lean server - it won't tax your machine or vps much.

The server

Command-line interface

signal-fire is now also available as a command-line application. This allows you
to quickly start a signal-fire server instance with optional verbose output.

Currently there is no support for background jobs (i.e. a daemon), but this may
come in the future.

Install the package globally to access the cli:

npm i -g signal-fire  

Next you can use signal-fire to start a server:

> signal-fire
signal-fire instance started on port 8080  
press ctrl+c to stop  

Use -h to view the help:

  Usage: signal-fire [options]

  Options:

    -h, --help         output usage information
    -V, --version      output the version number
    -p, --port [port]  Port to listen on [8080]
    -v, --verbose      Show verbose output

Installing

Before I show you an example, first install signal-fire using npm:

npm install signal-fire  

Example

The code below is all you need to set up a fully functioning signaling server using signal-fire:

// Require the server
const Server = require('signal-fire').Server

// Instantiate a new Server
const server = new Server({  
  // These options are passed directly to ws
  port: 8080
})

server.on('add_peer', peer => {  
  console.log(`Added peer with peerId ${peer.peerId}`)
})

server.on('remove_peer', peerId => {  
  console.log(`Removed peer with peerId ${peerId}`)
})

server.start().then(() => {  
  console.log('Server started')
})

Every incoming connection receives a unique peerId. This ID can be used to send messages to the peer later.

By default it uses ws as the underlying socket engine. If you want to use µWS instead, you can set the engine option in the constructor options:

const Server = require('signal-fire').Server  
const WebSocketServer = require('uws').Server

const server = new Server({  
  engine: WebSocketServer,
  // ...
})

For a list of all available options you'll have to look at the source code. Documentation is a little sketchy and still being worked on.

Relays

The signaling server also has the concept of relays. A relay is nothing more than an access to a publish/subscribe interface. Using relays you can set up multiple instances of the signaling server, and if an instance encounters an unknown peerId, it simply relays the message to the relay, where it is published and can be picked up by another instance that does have the correct peer in its connection pool.

Relays support all publish/subscribe protocols that use the same programming interface as node-redis and mqtt.js.

The relay has its own package:

npm i signal-fire-relay  

Prior to version 0.4.0 of signal-fire, a different approach for relays was used. This approach is no longer supported!

Adding a relay to your server is easy. In the example below, we use mqtt.js as the pub/sub engine:

const Server = require('signal-fire').Server  
const Relay = require('signal-fire-relay').Relay  
const client = require('mqtt').createClient()

const server = new Server({  
  port: 8080,
  relay: new Relay(client)
})

server.start().then(() => {  
  console.log('Server started')
})

// ...

That's all there is to it! Incoming messages with a receiverId that is unknown to this instance will be published over MQTT, allowing another instance to pick up the message and relay it to the correct peer.

Currently it's impossible to know if a peerId exists on any instance from any other instance. This is because of the way relays work. This may change in the future.

The client

The client is the user's browser that connects to the server using WebSockets. You can either use vanilla JS, or you can use the signal-fire-client.

Vanilla JS

WebSockets are supported in most browsers.

// Connect to the server
const signalServer = new WebSocket('ws://example.com:8080')

signalServer.onopen = () => {  
  console.log('Connection established')
}

let myPeerId = null  
signalServer.onmessage = (event) {  
  const msg = JSON.parse(event.data)

  // The first message received will contain our unique peerId
  if(myPeerId === null && msg.peerId) {
    myPeerId = msg.peerId
  }

  // Process incoming messages here
}

In order to send a message to another peer, you will need its peerId. For now you will have to use some other channel to send the peerIds between peers. You can for example show it and allow the user to input the peerId of another user he/she wishes to connect to.

To send a message from one peer to another, you will need to include a senderId (your own peerId) and a receiverId (the remote party's peerId):

signalServer.send({  
  senderId: peerId,
  receiverId: otherPeerId,
  key: 'value'
})

The messages are sent to the remote client as-is, so any fields you add will be transferred too. This is how you can use signal-fire to pass ICE candidates etc.

signal-fire-client

I wouldn't be me if I didn't also code a client specifically for signal-fire. signal-fire-client abstracts away all the gory details of establishing and maintaining a peer-to-peer connection. Instead you get to focus on using WebRTC in your application.

signal-fire-client is available on bower:

bower install signal-fire-client  

The client library depends on the WebRTC adapter from Google and EventEmitter2, both are not included in the client code, you will have to add them yourself!

You can also get client.js from the Github repository.

The signal-fire-client repository also includes an example

The example below shows you how you can set up the client so that when a new peer connection arrives, getUserMedia is called and the resulting stream(s) is/are added to the connection:

// Connect to the signal-fire server on the given url
const client = new SignalFireClient('ws://example.com:8080')

// This event is triggered when a new peer connection comes in
client.on('incoming', (peerConnection) => {  
  // `peerConnection` is an instance of `SignalFirePeerConnection`

  // Use `getUserMedia` to collect the local stream
  navigator.mediaDevices.getUserMedia(mediaConstraints).then((stream) => {
    // Display the local stream in a <video> element
    localVideo.srcObject = stream
    // And add it to the peer connection
    peerConnection.addStream(stream)
  })

  // This event is triggered when there is an incoming stream
  peerConnection.on('stream', (stream) => {
    // Do with it what you want, like displaying it in a <video> element
    remoteVideo.srcObject = stream
  })
})

// Finally, connect to the server
client.connect().then((myPeerId) => {  
  // `myPeerId` contains our unique ID
  console.log(`Connected with peerId${myPeerId}`)
})

Not that it's not necessary to add a stream to the connection. If you're using DataChannels, no need to add the media stream. The code above only handles connections as they come in, and doesn't actually set up the connection. You can do that as follows:

// `remotePeerId` is the `peerId` of the peer to contact
client.createPeerConnection(remotePeerId).then((peerConnection) => {  
  // Use `getUserMedia` to collect the local stream
  navigator.mediaDevices.getUserMedia(mediaConstraints).then((stream) => {
    // Display the local stream in a <video> element
    localVideo.srcObject = stream
    // Add the stream to the peer connection
    peerConnection.addStream(stream)
  })

  // This event is triggered when there is an incoming stream
  peerConnection.on('stream', (stream) => {
    remoteVideo.srcObject = stream
  })
})

I mentioned DataChannels earlier. Of course these are supported as well. Setting up a data channel is just as simple:

client.createPeerConnection(remotePeerId).then((peerConnection) => {

  // Create a data channel with the label (name) 'label'
  peerConnection.createDataChannel('label').then((channel) => {
    // `channel` is an instance of `SignalFireDataChannel`
    channel.on('message', (data) => {
      // We got a message!
    })

    // Send a message over the channel
    channel.send('Hello!')
  })

  // This event is fired on an incoming data channel
  peerConnection.on('data_channel', (channel) => {
    // `channel` is an instance of `SignalFireDataChannel`
    channel.on('message', (data) => {
      // We got a message! Send one back
      channel.send('Hello back!')
    })
  })
})

With the signal-fire client you don't have to worry about setting up the peer connections, you can just use them!

Get signal-fire on Github

Get signal-fire-client on Github

comments powered by Disqus