CodeTechSphere

Creating a video chat app with WebRTC in 2024

Cover Image for Creating a video chat app with WebRTC in 2024
Muhammad Khawaja
Muhammad Khawaja

If you’ve invested some time into web development as a professional looking to enhance their skills or even a casual programmer - chances are you haven’t heard of WebRTC. WebRTC is an open-source project that lets modern browsers (“peers”) communicate with each other in real-time, which includes sending messages to other users and video calling directly. It does not require streaming to a server; only exchanging some initial information between the clients, before establishing the connection between the two.

Installation

WebRTC does not require any external library as it works in modern web browsers. It does not provide functionality for signaling, i.e. a way for two browsers to determine how to establish a connection to each other. For this, we will simply use a node.js library called socket.io, which allows for real time communication between a client and the server.

We are going to use express for our web server, which will allow for two browsers (specifically, their sockets) to exchange information between each other, before establishing the connection.

The first thing we need is Node.js installed (which automatically comes with npm, or node package manager). It is a server-based runtime environment where we will program in Typescript, which is a superset of Javascript and provides types to the language, eliminating many type-related errors.

Important: Many browsers and mobile devices will complain if you try and request media access on an insecure connection. To resolve this, I recommend using ngrok, a service which will create our local application accessible via a secure tunnel URL. You can also use a hosting service online, such as glitch.com, in which your web application will be served over https (a secure connection).

Let’s create a directory (or folder) and have it opened inside of an IDE (I’m using Visual Studio Code.) Create a terminal, and let’s install the necessary modules:

npm install –save express socket.io

Next, let’s install typescript. Note the save-dev flag in the command. This is because in production, or if you were to deploy your app to a host, typescript is not a necessary module. In reality, browsers only understand javascript, which typescript will eventually compile to.

npm install –save-dev typescript @types/express @types/node ts-node @types/socket.io

Let’s code

Okay, time to implement the server aspect of our web application. Let’s create a src directory, and create a server.ts file in that directory, and place the following code:

import express, { Express, Request, Response } from "express";
import { Server as SocketIOServer } from "socket.io";
import path from "path";
var http = require('http');
var fs = require('fs');
require("dotenv").config();

export class Server {
    private app: Express;
    private server;
    private io: SocketIOServer;
    private activeSockets: string[] = [];

    constructor() {
        this.app = express();
        this.configureApp();
        this.server = http.createServer(this.app);
        this.io = new SocketIOServer(this.server);

        this.handleSocketConnection();
    }

    private configureApp(): void {
        this.app.use(express.static(path.join(__dirname, "../public")));
    }

    private handleSocketConnection(): void {
        this.io.on("connection", (socket: any) => {
            console.log("Socket connected.");
        });

        this.io.on("connection", socket => {
            const existingSocket = this.activeSockets.find(connectedSocket => connectedSocket === socket.id);

            if (!existingSocket) {
                this.activeSockets.push(socket.id);
	    
                socket.emit("update-user-list", {
                    users: this.activeSockets.filter(connectedSocket => connectedSocket !== socket.id)
                });
                socket.broadcast.emit("update-user-list", {
                    users: [socket.id]
                });
                socket.on("disconnect", () => {
                    this.activeSockets = this.activeSockets.filter(connectedSocket => connectedSocket !== socket.id);
                    socket.broadcast.emit("remove-user", {
                        socketId: socket.id
                    });
                });
                socket.on("call-user", data => {
                    socket.to(data.to).emit("call-made", {
                        offer: data.offer,
                        socket: socket.id
                    });
                });
                socket.on("make-answer", data => {
                    socket.to(data.to).emit("answer-made", {
                        socket: socket.id,
                        answer: data.answer
                    });
                });
            }
        });
    }

    public listen(cb: (port: any) => void): void {
        this.server.listen(process.env.port || 3000, () => {
            cb(process.env.port || 3000);
        });
    }
}

In our server’s constructor, we initialize the express app as well as the socket.io server.
When someone connects we give them a list of connected users, as well as broadcast to the rest of the room of this new person joining; likewise, with the person disconnecting, we broadcast that someone has left.

Time for javascript

In the public directory, create an index.html file, and we will place some basic HTML:

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>WebRTC Demo</title>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
 </head>
 <body>
   <div class="container">
     <div class="content-container">
       <div id="active-user-container">
         <h3 class="panel-title">Currently connected users:</h3>
       </div>
       <div class="video-chat-container">
         <h2 class="talk-info" id="talking-with-info"> 
           Select a user to talk to.
         </h2>
         <div class="video-container">
           <video autoplay class="remote-video" id="remote-video"></video>
           <video autoplay muted class="local-video" id="local-video"></video>
         </div>
       </div>
     </div>
   </div>
   <script src="./scripts/index.js"></script>
 </body>
</html>

Take note that we import the socket.io library in the browser using a script tag, and the two video elements that exist: one for the local video, and one for the remote video.

Let’s create the javascript in public/scripts/index.js:

const socket = io.connect();
const peerConnection = new RTCPeerConnection();
var alreadyInCall = false;

async function getMedia() {
  var stream = null;

  try {
    const constraints = {
        video: true,
        audio: true
    };
    stream = await navigator.mediaDevices.getUserMedia(constraints);
    const localVideo = document.getElementById("local-video");
    if (localVideo) localVideo.srcObject = stream;
    
    stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream));

  } catch(err) {
    alert(err);
  }
}

getMedia();

socket.on("update-user-list", ({ users }) => {
  const activeUserContainer = document.getElementById("active-user-container");

  users.forEach((socketId) => {
    const userExists = document.getElementById(socketId);
    if (!userExists) {
      const userContainerElement = createUserItemContainer(socketId);
      activeUserContainer.appendChild(userContainerElement);
    }
  });
});

socket.on("remove-user", ({ socketId }) => {
  const userExists = document.getElementById(socketId);

  if (userExists) {
    userExists.remove();
  }
});

socket.on("call-made", async (data) => {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));
  const answer = await peerConnection.createAnswer();
  await peerConnection.setLocalDescription(new RTCSessionDescription(answer));

  socket.emit("make-answer", {
    answer,
    to: data.socket,
  });
});

socket.on("answer-made", async (data) => {
  await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));

  if (!alreadyInCall) {
    callUser(data.socket);
    alreadyInCall = true;
  }
});

peerConnection.ontrack = function ({ streams: [stream] }) {
  const remoteVideo = document.getElementById("remote-video");
  if (remoteVideo) remoteVideo.srcObject = stream;
};

function createUserItemContainer(socketId) {
  const userContainerElement = document.createElement("div");

  const usernameElement = document.createElement("p");

  userContainerElement.setAttribute("class", "active-user");
  userContainerElement.setAttribute("id", socketId);
  usernameElement.setAttribute("class", "username");
  usernameElement.innerHTML = `socket id: ${socketId}`;

  userContainerElement.appendChild(usernameElement);

  userContainerElement.addEventListener("click", () => {
    userContainerElement.setAttribute("class", "active-user active-user--selected");
    const talkingWithInfo = document.getElementById("talking-with-info");
    talkingWithInfo.innerHTML = `talking to socket id: ${socketId}`;
    callUser(socketId);
  });
  return userContainerElement;
}


async function callUser(socketId) {
  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(new RTCSessionDescription(offer));

  socket.emit("call-user", {
    offer,
    to: socketId,
  });
}

When a client requests to call a specific user, we forward that call to that user with an SDP offer, who will set the necessary remote and local RTCSessionDescription and return the SDP answer. The original person has now received that answer and the connection is automatically established through the browser; no need for us to handle anything else. The rest of the code will deal with getting the video stream from the user’s camera and adding the stream’s tracks to the peer connection that’s been established.

By using an array of peer connections and some changes to the code, we can easily have a group video call instead. The only problem with this is that as the number of people in the group call grows, each person’s browser must communicate with every other person’s browser, which may cause network or computing overhead.

Now let’s write code for starting the server in src/index.ts:

import { Server } from "./server";
var fs = require('fs');
const server = new Server();

server.listen((port: any) => {
 console.log(`Server is listening on http://localhost:${port}`);
});

Let’s run this index file to start the server: node index.ts

If you choosed ngrok for a secure connection, let's open another terminal and use that port number (don't include the angle brackets):
ngrok http <PORT>

If you are coding on a platform like glitch.com, you should have a secure accessible URL that you can use on your computer and/or mobile device.

Conclusion

WebRTC is an amazing tool that allows for really neat features in web development. With very little back-end or front-code, we were able to establish a fully functional video calling app between two people. With some modifications, we can also extend the functionality to multiple users, as well as a chat system.