Building an Internet-Connected Phone with PeerJS

lola odelola
Samsung Internet Developers
11 min readOct 20, 2020
An image of a corded phone
Photo by Alexander Andrews on Unsplash

Since the start of 2020, video calling has taken over many of our lives. While we’re crushed under the weight of Zoom & Google Meet invite links, this boom has brought many internet based video and audio apps to the forefront. If you read my last post you’ll know that I’ve been fiddling around with WebRTC. WebRTC is a group of API endpoints and protocols that make it possible to send data (in the form of audio, video or anything else really) from one peer/device to another without the need of a traditional server. The issue is, WebRTC is pretty complicated to use and develop with in and of itself, handling the signalling service and knowing when to call the right endpoint can get confusing. But I come bearing good news; PeerJS is a WebRTC framework that abstracts all of the ice and signalling logic so that you can focus on the functionality of your application. There are two parts to PeerJS, the client-side framework and the server, we’ll be using both but most of our work will be handling the client-side code.

Let’s Build A Phone Since We’re All Fed Up of Video

Before we get started, this is an intermediate level tutorial so you should already be comfortable with:

  • Vanilla JavaScript
  • Node
  • Express
  • HTML

I’ll be focussing solely on the JavaScript side of things, so you can copy the HTML & CSS files directly, we won’t be fiddling with them too much. Before we get started, you’ll want to make sure you’ve installed node and your favourite package manager, I’ll be using Yarn but you can use npm or any manager you’re comfortable with.

If you learn better by looking at code and following step by step code, I’ve also written this tutorial in code, which you can use instead.

Setup

So let’s set things up. First you’ll need to run mkdir audio_app
and then cd audio_app & finally you’ll want to create a new app by running yarn init . Follow the prompts, give a name, version, description, etc to your project. Next install the dependencies:

Peer will be used for the peer server and PeerJS will be used to access the PeerJS API and framework. Your package.json should look like this when you’ve finished installing the dependencies:

To finish up the setup, you’ll want to copy the HTML & CSS files I mentioned before, into your project folder.

Building the Server

The server file will look like a regular Express server file with one difference, the Peer server.

You’ll need to require the peer server at the top of the file const {ExpressPeerServer} = require('peer') , this will ensure that we have access to the peer server.

You then need to actually create the peer server:

const peerServer = ExpressPeerServer(server, {
proxied: true,
debug: true,
path: '/myapp',
ssl: {}
});

We use the ExpressPeerServer object created earlier to create the peer server and pass it some options. The peer server is what will handle the signalling required for WebRTC so we don’t have to worry about STUN/TURN servers or other protocols as this abstracts that logic for us.

Finally, you’ll need to tell your app to use the peerServer by calling app.use(peerServer) . Your finished server.js should include the other necessary dependencies as you’d include in a server file, as well as serving the index.html file to the root path so, it should look like this when finished:

You should be able to connect to your app via local host, in my server.js I’m using port 8000(defined on line 7) but you may be using another port number. Run node . in your terminal and visit localhost:8000 in your browser and you should see a page that looks like this

A screenshot of the homepage of the app. With a header “Phone a friend”, some text: “connecting…” & a button “call”
The home page

The Good Part

This is the part you’ve been waiting for, actually creating the peer connection and call logic. This is going to be an involved process so strap in. First up, create a script.js file, this is where all your logic will live.

We need to create a peer object with an ID. The ID will be used to connect two peers together and if you don’t create one, one will be assigned to the peer.

const peer = new Peer(''+Math.floor(Math.random()*2**18).toString(36).padStart(4,0), {
host: location.hostname,
debug: 1,
path: '/myapp'
});

You’ll then need to attach the peer to the window so that it’s accessible

window.peer = peer;

In another tab in your terminal, start the peer server by running:

peerjs --port 443 --key peerjs --path /myapp

After you’ve created the peer, you’ll want to get the browser’s permission to access the microphone. We’ll be using the getUserMedia function on the navigator.MediaDevices object, which is part of the Media Devices Web interface. The getUserMedia endpoint takes a constraints object which specifies which permissions are needed. getUserMedia is a promise which when successfully resolved returns a MediaStream object. In this case this is going to be the audio from our stream. If the promise isn’t successfully resolved, you’ll want to catch and display the error.

function getLocalStream() {
navigator.mediaDevices.getUserMedia({video: false, audio: true}).then( stream => {
window.localStream = stream; // A
window.localAudio.srcObject = stream; // B
window.localAudio.autoplay = true; // C
}).catch( err => {
console.log("u got an error:" + err)
});
}

A. window.localStream = stream : here we’re attaching the MediaStream object (which we have assigned to stream on the previous line) to the window as the localStream .

B. window.localAudio.srcObject = stream: in our HTML, we have an audio element with the ID localAudio, we’re setting that element’s src attribute to be the MediaStream returned by the promise.

C. window.localAudio.autoplay = true: we’re setting the autoplay attribute for the audio element to auto play.

When you call your getLocalStream function and refresh your browser, you should see the following permission pop up:

The browser permission pop up asking for access to the microphone
The permission pop up

Use headphones before you allow so that when you unmute yourself later, you don’t get any feedback. If you don’t see this, open the inspector and see if you have any errors. Make sure your javascript file is correctly linked to your index.html too.

This what it should all look like together

Alright, so you’ve got the permissions, now you’ll want to make sure each user knows what their peer ID is so that they can make connections. The peerJS framework gives us a bunch of event listeners we can call on the peer we created earlier on. So when the peer is open, display the peer’s ID:

peer.on('open', function () {
window.caststatus.textContent = `Your device ID is: ${peer.id}`;
});

Here you’re replacing the text in the HTML element with the ID caststatus so instead of connecting... , you should see Your device ID is: <peer ID>

A screenshot of the homepage. With a header “Phone a friend”, some text: “your device ID is 3b77” & a button “call”
A screen shot with the peer ID

While you’re here, you may as well create the functions to display and hide various content, which you’ll use later. There are two functions you should create, showCallContent and showConnectedContent. These functions will be responsible for showing the call button and showing the hang up button and audio elements.

const audioContainer = document.querySelector('.call-container');/**
* Displays the call button and peer ID
*
@returns{void}
*/

function showCallContent() {
window.caststatus.textContent = `Your device ID is: ${peer.id}`;
callBtn.hidden = false;
audioContainer.hidden = true;
}

/**
* Displays the audio controls and correct copy
*
@returns{void}
*/

function showConnectedContent() {
window.caststatus.textContent = `You're connected`;
callBtn.hidden = true;
audioContainer.hidden = false;
}

Next, you want to ensure your users have a way of connecting their peers. In order to connect two peers, you’ll need the peer ID for one of them. You can create a variable with let then assign it in a function to be called later.

let code;
function getStreamCode() {
code = window.prompt('Please enter the sharing code');
}

A convenient way of getting the relevant peer ID is by using a window prompt, you can use this when you want to collect the peerID needed to create the connection.

Using the peerJS framework, you’ll want to connect the localPeer to the remotePeer. PeerJS gives us the connect function which takes in a peer ID to create the connection.

function connectPeers() {
conn = peer.connect(code)
}

When a connection is created, using the PeerJS framework’s on(‘connection')you should set the remote peer’s ID and the open connection. The function for this listener accepts a connection object which is an instance of the DataConnection object (which is a wrapper around WebRTC’s DataChannel), so within this function you’ll want to assign it to a variable. Again you’ll want to create the variable outside of the function so that you can assign it later.

let conn;
peer.on('connection', function(connection){
conn = connection;
});

Now you’ll want to give your users the ability to create calls. First get the call button that’s defined in the HTML:

const callBtn = document.querySelector(‘.call-btn’);`

When a caller clicks “call” you’ll want to ask them for peer ID of the peer they want to call (which we store in code in getStreamCode) and then you’ll want to create a connection with that code.

callBtn.addEventListener('click', function(){
getStreamCode();
connectPeers();
const call = peer.call(code, window.localStream); // A

call.on('stream', function(stream) { // B
window.remoteAudio.srcObject = stream; // C
window.remoteAudio.autoplay = true; // D
window.peerStream = stream; //E
showConnectedContent(); //F
});
})

A. const call = peer.call(code, window.localStream): this will create a call with the code and window.localStream we’ve previously assigned. Note: the localStream will be the user’s localStream. So for caller A it’ll be their stream & for B their own stream.

B. call.on('stream', function(stream) { : peerJS gives us a stream event which you can use on the call that you’ve created. When a call starts streaming, you need to ensure that the remote stream coming from the call is assigned to the correct HTML elements and window, this is where you’ll need to do that.

C. This takes anonymous function takes a MediaStream object as an argument which you then have to set to your window’s HTML like you’ve done before. So you get your remote audio element and assign the src attribute to be the stream passed to the function.

D. Ensure the element’s autoplay attribute is also set to true.

E. Ensure that the window’s peerStream is set to the stream passed to the function.

F. Finally you want to show the correct content, so call your showConnectedContent function that was created earlier.

To test things open two browser windows and click call. You should see this

Two browsers side by side, one with a prompt asking for the code
Two browsers side by side, one with a prompt asking for the code

If you submit the other peer’s ID, the call will be connected but we need to give the other browser the chance to answer or decline the call.

The peerJS framework makes the .on('call') event available to use so let’s use it here.

peer.on('call', function(call) {
const answerCall = confirm("Do you want to answer?") // A

if(answerCall){
call.answer(window.localStream) // B
showConnectedContent(); // C
call.on('stream', function(stream) { // D
window.remoteAudio.srcObject = stream;
window.remoteAudio.autoplay = true;
window.peerStream = stream;
});
} else {
console.log("call denied"); // E
}
});
A browser prompt asking if the user would like to answer the call
A browser prompt asking if the user would like to answer the call

A. const answerCall = confirm("Do you want to answer"): First, let’s prompt the user to answer with a confirm prompt. This will show a window on the screen (as shown in the image) which the user can select “ok” or “cancel”, which maps to a boolean value which is returned.

B. call.answer(window.localStream): if the answerCall is true, then you want to call peerJS’s answer function on the call to create an answer, passing it the local stream.

C. showCallContent: similarly to what you did in the call button event listener, you want to ensure the person being called sees the correct HTML content.

D. Everything in the call.on('stream', function(){...} block is exactly the same as it is in call button’s event listener. The reason you need to add it here too is so that the browser is also updated for the person answering the call.

E. If the person denies the call, we’re just going to log a message to the console.

We’re almost at the finish line. The code you have now is enough for you to create a call and answer it. Refresh your browsers and test it out. You’ll want to make sure that both browsers have the console open or else you won’t get the prompt to answer the call. Click call, submit the peer ID for the other browser and then answer the call. The final page should look like this:

Two browsers side by side, both connected on the call. Each page shows two audio elements and a button that says “hang up”
Two browsers side by side, both connected on the call

The last thing you want to do, is ensure your callers have a way of ending the call. The most graceful way of doing this is closing the connection using the close , which you can do in an event listener for the hang up button.

const hangUpBtn = document.querySelector('.hangup-btn');
hangUpBtn
.addEventListener('click', function (){
conn.close();
showCallContent();
})

When the connection has been close, you also want to display the correct HTML content, so you can just call your showCallContent function. Within the call event, you also want to ensure the remote browser is also updated so you can add another event listener within the peer.on('call', function(stream){...} event listener, within the conditional block.

conn.on('close', function (){
showCallContent();
})

If the person who initiated the call clicks ‘hang up’ first, then both browsers will be updated.

And voila, you’ve got yourself an internet connected phone. 📞

Next Steps

Deployment

The easiest place to deploy this app would be Glitch, since you don’t have to fiddle with configuring the port for the peer server.

Making this a PWA

At Samsung Internet, we’re big on progressive web apps so the next phase of this will be to add a manifest.json and serviceworker.js to make this a PWA.

Gotchas

  • If you’ve done some sleuthing online, you may have come across navigator.getUserMedia and assumed you can use that instead of navigator.MediaDevices.getUserMedia. You’d be wrong. The former is a deprecated method which requires callbacks as well as constraints as arguments. The latter uses a promise so you don’t need to use callbacks.
  • Since we’re using a confirm prompt to ask the user if they want to answer the call, it’s important that the browser and tab that’s being called be “active” that means the window shouldn’t be minimised and the tab should be on screen and have the mouse’s focus somewhere in the tab. Ideally, you’d create your own modal in HTML which wouldn’t have these limitations.
  • The way we’ve currently coded things means that when a connection is closed, both browsers will be updated only if the person who started the call presses ‘hang up’ first. If the person who answered the call clicks ‘hang up’ first, the other caller will also have to click ‘hang up’ to see the correct HTML.
  • The on('close') event that is called on the conn variable isn’t available in Firefox yet, this just means in Firefox each caller will have to hang up individually.

Further Reading

--

--

lola odelola
Samsung Internet Developers

@blackgirltech’s mum, published poet, coder, wanderer, wonderer & anti-cheesecake activist.