Using WebRTC to implement P2P video streaming
Tags: node, webdev
Published: 2024-05-22 16:59:20

**Written by [Oduah Chigozie](https://blog.logrocket.com/author/oduahchigozie/)✏️**

Web Real-Time Communication (WebRTC) is an open source standard that allows real-time communication between web apps and sites without plugins or additional software installations. It’s also available as a library for iOS and Android apps that provides the same functionality as the standard. 

WebRTC works on any operating system and is available on all modern browsers, including Google Chrome, Mozilla Firefox, and Safari. A few major projects that use WebRTC include Google Meet and Hangouts, WhatsApp, Amazon Chime, Facebook Messenger, Snapchat, and Discord. 

In this article, we’ll walk through one of WebRTC’s major use cases: peer-to-peer (P2P) audio and video streaming from one system to another. This functionality is similar to that of live-streaming services, like Twitch but on a smaller and simpler scale.

## Core WebRTC concepts to know

In this section, I’ll review five essential concepts you should know to understand how a web application using WebRTC works. These concepts include peer-to-peer communication, signal servers, and the ICE protocol.

### Peer-to-peer communication

In this guide, we’ll be working with WebRTC’s `RTCPeerConnection` object which is primarily involved in connecting two applications and allowing them to communicate using a peer-to-peer protocol. 

In decentralized networks, peer-to-peer communication is a direct link between computer systems (peers) in the network without an intermediary (a server for example). While WebRTC doesn’t allow peers to directly communicate with each other in all scenarios the ICE protocol and Signal server that it uses allows similar behavior. You’ll find out more about them below.

### Signal servers

For each pair in a WebRTC application to begin communication, they must perform a “handshake,” which is done through offers and answers. One peer generates an offer and shares it with the other peer, and that other peer generates an answer and shares it with the first peer. 

For the handshake to be successful, each peer must have a method for sharing their offer or answer. This is where signal servers come in. 

A signal server’s primary goal is to initiate communication between peers. A peer uses a signal server to share its offer or answer with another peer, and another can use the signal server to share its offer or answer with the first peer.

### ICE protocol

In specific scenarios, like when all the devices involved aren’t in the same local network, WebRTC applications may struggle to make peer connections with each other. This is because direct socket connections aren’t always possible between peers unless they’re in the same local network. 

When you want to use the peer connection across different networks, you need to use the Interactive Connectivity Establishment (ICE) protocol. The ICE protocol is used to establish connections between peers over the internet. ICE servers use this protocol to establish connections and relay information between the peers. 

The ICE protocol comprises either a Session Traversal Utilities for NAT (STUN) protocol, a Traversal Using Relay around NAT (TURN) protocol, or a mix of both. 

In this tutorial, we won’t be covering the practical aspect of ICE protocols because of the complexity involved in building a server, getting it to work, and testing it. However, it’s helpful to know the limitations of WebRTC applications and where the ICE protocol comes in to solve those limitations.

## Getting started with WebRTC P2P video streaming

Now that we’ve gone through all that, it’s time to start the grunt work. In the next section, we’ll work on the video streaming project. You can see a [live demo of the project here](https://ghoulkingr.github.io/webrtc-project/) as we get started. 

Before we get into it, I have [a GitHub repo](https://github.com/GhoulKingR/webrtc-project) that you can clone to follow the article. This repo has a a `start-tutorial` folder organized in the steps you’ll take in the next section, along with a copy of the code at the end of each step. While using the repo isn’t necessary, it is helpful. 

The folder we’ll be working on in the repo is called `start-tutorial`. It contains three folders: `step-1`, `step-2`, and `step-3`. These three folders correspond to the steps in the next section.

## Working on the video streaming project

Now, let’s begin building the project. I divided this process into three steps. We’ll create a project we can run, test, and use with each step. 

These steps include [video streaming within a webpage](#video-streaming-webpage), [streaming between browser tabs and windows with BroadcastChannel](#between-browser-tabs-windows-broadcastchannel), and finally, [using a signal server to stream across different browsers on the same device](#replacing-broadcastchannel-signal-server).

### Video streaming within a webpage

In this step, we’ll only need an `index.html` file. If you’re working in the repo, you can use the `start-tutorial/step-1/index.html` file. 

Now, let’s paste this code into it:

```html
<body>
  <video id="local" autoplay muted></video>
  <video id="remote" autoplay></video>

  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>

  <script>
    // get video elements
    const local = document.querySelector("video#local");
    const remote = document.querySelector("video#remote");

    function start(e) {
      e.disabled = true;
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => {
          local.srcObject = stream;
          document.getElementById("stream").disabled = false;  // enable the stream button
        })
        .catch(() => e.disabled = false);
    }

    function stream(e) {
      // disable the stream button
      e.disabled = true;

      const config = {};
      const localPeerConnection = new RTCPeerConnection(config);  // local peer
      const remotePeerConnection = new RTCPeerConnection(config);  // remote peer

      // if an icecandidate event is triggered in a peer add the ice candidate to the other peer
      localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
      remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));

      // if the remote peer detects a track in the connection, it forwards it to the remote video element
      remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);

      // get camera and microphone source tracks and add it to the local peer
      local.srcObject.getTracks()
        .forEach(track => localPeerConnection.addTrack(track, local.srcObject));

      // Start the handshake process
      localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await localPeerConnection.setLocalDescription(offer);
          await remotePeerConnection.setRemoteDescription(offer);
          console.log("Created offer");
        })
        .then(() => remotePeerConnection.createAnswer())
        .then(async answer => {
          await remotePeerConnection.setLocalDescription(answer);
          await localPeerConnection.setRemoteDescription(answer);
          console.log("Created answer");
        });
    }
  </script>
</body>
```

It will get you something that looks like this: ![Example Of Video Streaming On A Webpage Using Webrtc With Two Video Elements And Two Button Elements Showing User Waving Hand In Video And Live Streaming To Second Video Element](https://blog.logrocket.com/wp-content/uploads/2024/05/Demo-video-streaming-webpage-WebRTC-two-video-elements-two-buttons.gif) Now, let’s take a look at what’s going on. 

To build the project, we need two video elements. We’ll use one to capture the user’s camera and microphone. After that, we’ll feed the audio and video stream from this element to the other video element using WebRTC’s `RTCPeerConnection` object:

```html
<video id="local" autoplay muted></video>
<video id="remote" autoplay></video>
```

An `RTCPeerConnection` object is the main object that establishes direct, peer-to-peer connections between web browsers or devices. 

Then we need two buttons. One is to activate the user’s webcam and microphone, and the other is to stream the content of the first video element to the second:

```html
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
```

The **start video** button runs a `start` function when clicked. The **stream video** button runs the `stream` function when clicked. 

Let’s first take a look at the `start` function:

```html
function start(e) {
  e.disabled = true;
  navigator.mediaDevices.getUserMedia({ audio: true, video: true })
   .then((stream) => {
      local.srcObject = stream;
      document.getElementById("stream").disabled = false;  // enable the stream button
    })
    .catch(() => e.disabled = false);
}
```

When the `start` function runs, it first makes the start button unclickable. Then, it requests the user’s permission to use their webcam and microphone with the `navigator.mediaDevices.getUserMedia` method. 

If the user grants permission, the `start` function sends the video and audio stream to the first video element through its `srcObject` field and enables the `stream` button. If there are issues getting permission from the user or the user rejects the permission, the function makes the `start` button clickable again. 

Now, let’s look at the `stream` function:

```html
function stream(e) {
  // disable the stream button
  e.disabled = true;

  const config = {};
  const localPeerConnection = new RTCPeerConnection(config);  // local peer
  const remotePeerConnection = new RTCPeerConnection(config);  // remote peer

  // if an icecandidate event is triggered in a peer add the ice candidate to the other peer
  localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
  remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));

  // if the remote peer receives track from the connection, it feeds them to the remote video element
  remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);

  // get camera and microphone tracks then feed them to local peer
  local.srcObject.getTracks()
    .forEach(track => localPeerConnection.addTrack(track, local.srcObject));

  // Start the handshake process
  localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
    .then(async offer => {
      await localPeerConnection.setLocalDescription(offer);
      await remotePeerConnection.setRemoteDescription(offer);
      console.log("Created offer");
    })
    .then(() => remotePeerConnection.createAnswer())
    .then(async answer => {
      await remotePeerConnection.setLocalDescription(answer);
      await localPeerConnection.setRemoteDescription(answer);
      console.log("Created answer");
    });
}
```

I’ve added comments to outline the processes in the `stream` function to help with understanding it. However, the handshake process (lines 21–32) and the ICE candidate events (lines 10 and 11) are the essential parts that we’ll discuss in more detail. 

In the handshake process, each pair sets its local and remote description with an offer and an answer depending on which one the pair creates:

*   The pair that generates an offer sets its local description to that offer and then sends a copy of the offer to the second pair to set as its remote description
*   Likewise, the pair that generates an answer sets the answer as its local description and sends a copy to the first pair to set as its remote description

After completing this process, the peers immediately start communicating with each other. 

An ICE candidate is a peer’s address (IP, ports, and other related information). `RTCPeerConnection` objects use ICE candidates to find and communicate with each other. The `icecandidate` event in an `RTCPeerConnection` object is triggered when the object generates an ICE candidate. 

Our goal with the event listeners we set up is to pass ICE candidates from one peer to another.

### Between browser tabs and windows with `BroadcastChannel`

One of the challenging things about setting up a peer-to-peer application with WebRTC is getting it to work across different application instances or websites. In this section, we will use the Broadcast Channel API to allow our project to work outside a single webpage but within the browser context.

#### Creating the necessary files

We’ll start by creating two files, `streamer.html` and `index.html`. In the repo, these files are in the `start-tutorial/step-2` folder. The `streamer.html` page allows users to create a live stream from their camera, while the `index.html` page will enable users to watch those live streams. Now, let’s paste these code blocks into the files. Then later, we’ll look deeper into them. 

First, in the `streamer.html` file, paste the following code:

```html
<body>
  <video id="local" autoplay muted></video>
  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>
  <script>
    // get video elements
    const local = document.querySelector("video#local");
    let peerConnection;
    const channel = new BroadcastChannel("stream-video");
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "answer") {
        console.log("Received answer")
        peerConnection?.setRemoteDescription(e.data);
      }
    }
    // function to ask for camera and microphone permission
    // and stream to #local video element
    function start(e) {
      e.disabled = true;
      document.getElementById("stream").disabled = false;  // enable the stream button
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => local.srcObject = stream);
    }

    function stream(e) {
      e.disabled = true;

      const config = {};
      peerConnection = new RTCPeerConnection(config);  // local peer connection

      // add ice candidate event listener
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;

        // prepare a candidate object that can be passed through browser channel
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });

      // add media tracks to the peer connection
      local.srcObject.getTracks()
        .forEach(track => peerConnection.addTrack(track, local.srcObject));

      // Create offer and send through the browser channel
      peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await peerConnection.setLocalDescription(offer);
          console.log("Created offer, sending...");
          channel.postMessage({ type: "offer", sdp: offer.sdp });
        });
    }
  </script>
</body>
```

Then, in the `index.html` file, paste the following code:

```html
<body>
  <video id="remote" controls></video>

  <script>
    // get video elements
    const remote = document.querySelector("video#remote");
    let peerConnection;

    const channel = new BroadcastChannel("stream-video");
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate)
      } else if (e.data.type === "offer") {
        console.log("Received offer")
        handleOffer(e.data)
      }
    }

    function handleOffer(offer) {
      const config = {};
      peerConnection = new RTCPeerConnection(config);
      peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          }
        }
        channel.postMessage({ type: "icecandidate", candidate })
      });
      peerConnection.setRemoteDescription(offer)
        .then(() => peerConnection.createAnswer())
        .then(async answer => {
          await peerConnection.setLocalDescription(answer);
          console.log("Created answer, sending...")
          channel.postMessage({
            type: "answer",
            sdp: answer.sdp,
          });
        });
    }
  </script>
</body>
```

In your browser, the pages would look and function like the animation below: ![Demo Of A Streamer Page Side By Side With An Index Page Demonstrating Peer To Peer Streaming With Webrtc](https://blog.logrocket.com/wp-content/uploads/2024/05/Demo-streamer-page-index-page-P2P-streaming.gif)

#### Detailed breakdown of the `streamer.html` file

Now, let’s explore these two pages in more detail. We’ll start with the `streamer.html` page. This page only needs a video and two button elements:

```html
<video id="local" autoplay muted></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
```

The **start video** button works like it did in the last step: it requests the user’s permission to use their camera and microphone and feeds the stream to the video element. Then, the **stream video** button initializes a peer connection and feeds the video stream to the peer connection. 

Since this step involves two webpages, we’re working with the Broadcast Channel API. In our `index.html` and `streamer.html` files, we have to initialize a `BroadcastChannel` object on each page with the same name to allow them to communicate. 

A `BroadcastChannel` object allows you to communicate essential information between browsing contexts (windows or tabs, for example) with the same URL origin. 

When you initialize a `BroadcastChannel` object, you have to give it a name. You can think of this name as a chat room name. If you initialize two `BroadcastChannel` objects with the same name, they can talk to each other like they’re in a chat room. But if they have different names, they can’t communicate because they’re not in the same chat room. 

I say “chat room” because you can have more than one `BroadcastChannel` objects with the same name, and they can all communicate with each other simultaneously. 

Since we’re working with two pages, each with its peer connection, we have to use the `BroadcastChannel` object to pass the offers and answers back and forth between the two pages. We also have to pass a peer connection’s ICE candidate to the other. So, let’s take a look at how it is done. 

It all starts with the `stream` function:

```html
// streamer.html -> script element

function stream(e) {
  e.disabled = true;

  const config = {};
  peerConnection = new RTCPeerConnection(config);  // local peer connection

  // add ice candidate event listener
  peerConnection.addEventListener("icecandidate", e => {
    let candidate = null;

    // prepare a candidate object that can be passed through browser channel
    if (e.candidate !== null) {
      candidate = {
        candidate: e.candidate.candidate,
        sdpMid: e.candidate.sdpMid,
        sdpMLineIndex: e.candidate.sdpMLineIndex,
      };
    }
    channel.postMessage({ type: "icecandidate", candidate });
  });

  // add media tracks to the peer connection
  local.srcObject.getTracks()
    .forEach(track => peerConnection.addTrack(track, local.srcObject));

  // Create offer and send through the browser channel
  peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
    .then(async offer => {
      await peerConnection.setLocalDescription(offer);
      console.log("Created offer, sending...");
      channel.postMessage({ type: "offer", sdp: offer.sdp });
    });
}
```

There are two areas in the function that interact with the `BrowserChannel` object. The first is the ICE candidate event listener:

```html
peerConnection.addEventListener("icecandidate", e => {
  let candidate = null;

  // prepare a candidate object that can be passed through browser channel
  if (e.candidate !== null) {
    candidate = {
      candidate: e.candidate.candidate,
      sdpMid: e.candidate.sdpMid,
      sdpMLineIndex: e.candidate.sdpMLineIndex,
    };
  }
  channel.postMessage({ type: "icecandidate", candidate });
});
```

The other is after generating an offer:

```html
peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
  .then(async offer => {
    await peerConnection.setLocalDescription(offer);
    console.log("Created offer, sending...");
    channel.postMessage({ type: "offer", sdp: offer.sdp });
  });
```

Let’s look at the ICE candidate event listener first. If you pass the `e.candidate` object directly to the `BroadcastChannel` object, you’ll get a `DataCloneError: object can not be cloned` error message in the console. 

This error happens because the `BroadcastChannel` object cannot process `e.candidate` directly. You need to create an object with the required details from `e.candidate` to send to the `BroadcastChannel` object. We had to do the same thing for sending an offer. 

You need to call the `channel.postMessage` method to send a message to the `BroadcastChannel` object. When this message is called, the `BroadcastChannel` object at the other webpage triggers its `onmessage` event listener. Have a look at this code from the `index.html` page:

```html
channel.onmessage = e => {
  if (e.data.type === "icecandidate") {
    peerConnection?.addIceCandidate(e.data.candidate)
  } else if (e.data.type === "offer") {
    console.log("Received offer")
    handleOffer(e.data)
  }
}
```

As you can see, we have conditional statements checking the type of message coming into the `BroadcastChannel` object. The message's contents can be read through `e.data`. `e.data.type` corresponds to the type field from the objects we sent through `channel.postMessage`:

```html
// from the ICE candidate event listener
channel.postMessage({ type: "icecandidate", candidate });

// from generating an offer
channel.postMessage({ type: "offer", sdp: offer.sdp });
```

Now, let's have a look at the `index.html` file that handles received offers.

#### Detailed breakdown of the `index.html` file

The `index.html` file starts with the `handleOffer` function:

```html
function handleOffer(offer) {
  const config = {};
  peerConnection = new RTCPeerConnection(config);
  peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
  peerConnection.addEventListener("icecandidate", e => {
    let candidate = null;
    if (e.candidate !== null) {
      candidate = {
        candidate: e.candidate.candidate,
        sdpMid: e.candidate.sdpMid,
        sdpMLineIndex: e.candidate.sdpMLineIndex,
      }
    }
    channel.postMessage({ type: "icecandidate", candidate })
  });
  peerConnection.setRemoteDescription(offer)
    .then(() => peerConnection.createAnswer())
    .then(async answer => {
      await peerConnection.setLocalDescription(answer);
      console.log("Created answer, sending...")
      channel.postMessage({
        type: "answer",
        sdp: answer.sdp,
      });
    });
}
```

When triggered, this method creates a peer connection and sends any ICE candidate it generates to the other peer. Then, continues with the handshake process, setting the streamer’s offer as its remote description, generating an answer, setting that answer as its local description, and sending that answer to the streamer using the `BroadcastChannel` object. 

Like with the `BroadcastChannel` object in the `index.html` file, the `BroadcastChannel` object in the `streamer.html` file needs an `onmessage` event listener to receive the ICE candidates and answer from the `index.html` file:

```html
channel.onmessage = e => {
  if (e.data.type === "icecandidate") {
    peerConnection?.addIceCandidate(e.data.candidate);
  } else if (e.data.type === "answer") {
    console.log("Received answer")
    peerConnection?.setRemoteDescription(e.data);
  }
}
```

If you’re wondering why the question mark `?` is after the `peerConnection`, it tells the JavaScript runtime not to throw an error if `peerConnection` is undefined. It is somewhat of a shorthand for this:

```html
if (peerConnection) {
  peerConnection.setRemoteDescription(e.data);
}
```

### Replacing `BroadcastChannel` with our signal server

`BroadcastChannel` is only limited to browser contexts. In this step, we’ll overcome that limit by using a simple signal server, which we’ll build with Node.js. Like in the previous steps, I’ll first give you the code to paste and then explain what’s going on in them. 

So, let’s begin. This step requires four files: `index.html`, `streamer.html`, `signalserverclass.js`, and `server/index.js`. 

We’ll start with the `signalserverclass.js` file:

```javascript
class SignalServer {
  constructor(channel) {    
    this.socket = new WebSocket("ws://localhost:80");
    this.socket.addEventListener("open", () => {
      this.postMessage({ type: "join-channel", channel });
    });
    this.socket.addEventListener("message", (e) => {
      const object = JSON.parse(e.data);
      if (object.type === "connection-established") console.log("connection established");
      else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
      else this.onmessage({ data: object });
    });
  }

  onmessage(e) {}
  postMessage(data) {
    this.socket.send( JSON.stringify(data) );
  }
}
```

Next, let’s update the `index.html` and `streamer.html` files. The only changes to these files are where we initialized the `BroadcastChannel` object and the script tag importing the `signalserverclass.js` script. Here’s the updated `index.html` file:

```html
<body>
  <video id="remote" controls></video>

  <script src="signalserverclass.js"></script>          <!-- new change -->
  <script>
    const remote = document.querySelector("video#remote");
    let peerConnection;
    const channel = new SignalServer("stream-video");     // <- new change
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "offer") {
        console.log("Received offer");
        handleOffer(e.data);
      }
    }
    function handleOffer(offer) {
      const config = {};
      peerConnection = new RTCPeerConnection(config);
      peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });
      peerConnection.setRemoteDescription(offer)
        .then(() => peerConnection.createAnswer())
        .then(async answer => {
          await peerConnection.setLocalDescription(answer);
          console.log("Created answer, sending...");
          channel.postMessage({
            type: "answer",
            sdp: answer.sdp,
          });
        });
    }
  </script>
</body>
```

Here’s the updated `streamer.html` file:

```html
<body>
  <video id="local" autoplay muted></video>
  <button onclick="start(this)">start video</button>
  <button id="stream" onclick="stream(this)" disabled>stream video</button>
  <script src="signalserverclass.js"></script>         <!-- new change -->
  <script>
    const local = document.querySelector("video#local");
    let peerConnection;

    const channel = new SignalServer("stream-video");     // <- new change
    channel.onmessage = e => {
      if (e.data.type === "icecandidate") {
        peerConnection?.addIceCandidate(e.data.candidate);
      } else if (e.data.type === "answer") {
        console.log("Received answer");
        peerConnection?.setRemoteDescription(e.data);
      }
    }

    // function to ask for camera and microphone permission
    // and stream to #local video element
    function start(e) {
      e.disabled = true;
      document.getElementById("stream").disabled = false;  // enable the stream button
      navigator.mediaDevices.getUserMedia({ audio: true, video: true })
        .then((stream) => local.srcObject = stream);
    }

    function stream(e) {
      e.disabled = true;

      const config = {};
      peerConnection = new RTCPeerConnection(config);  // local peer connection
      peerConnection.addEventListener("icecandidate", e => {
        let candidate = null;
        if (e.candidate !== null) {
          candidate = {
            candidate: e.candidate.candidate,
            sdpMid: e.candidate.sdpMid,
            sdpMLineIndex: e.candidate.sdpMLineIndex,
          };
        }
        channel.postMessage({ type: "icecandidate", candidate });
      });
      local.srcObject.getTracks()
        .forEach(track => peerConnection.addTrack(track, local.srcObject));

      peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
        .then(async offer => {
          await peerConnection.setLocalDescription(offer);
          console.log("Created offer, sending...");
          channel.postMessage({ type: "offer", sdp: offer.sdp });
        });
    }
  </script>
</body>
```

Finally, here are the contents of the `server/index.js` file:

```javascript
const { WebSocketServer } = require("ws");

const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);

function handleConnection(ws) {
  console.log('New connection');
  ws.send( JSON.stringify({ type: 'connection-established' }) );

  let id;
  let channel = "";
  ws.on("error", () => console.log('websocket error'));
  ws.on('message', message => {
    const object = JSON.parse(message);

    if (object.type === "join-channel") {
      channel = object.channel;
      if (channels[channel] === undefined) channels[channel] = [];
      id = channels[channel].length || 0;
      channels[channel].push(ws);
      ws.send(JSON.stringify({type: 'joined-channel', channel}));
    } else {
      // forward the message to other channel memebers
      channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
        member.send(message.toString());
      });
    }
  });
  ws.on('close', () => {
    console.log('Client has disconnected!');
    if (channel !== "") {
      channels[channel] = channels[channel].filter((_, i) => i !== id);
    }
  });
}
```

In the browser, they should look and run like this: ![Demo Of A Streamer Page Side By Side With An Index Page Demonstrating Peer To Peer Streaming With Webrtc](https://blog.logrocket.com/wp-content/uploads/2024/05/Demo-streamer-page-index-page-P2P-streaming.gif) To get the server running, you need to open the `server` folder in the terminal, initialize the folder as a Node project, install the `ws` package, and then run the `index.js` file. These steps can be done with these commands:

```bash
# initialize the project directory
npm init --y

# install the `ws` package
npm install ws

# run the `index.js` file
node index.js
```

Now, let’s look into the files. To reduce the need for editing our code after swapping the `BroadcastChannel` object constructor with the `SignalServer` constructor, I try to make the `SignalServer` class imitate the calls and things you do with the `BroadcastChannel` — at least for our use case:

```javascript
class SignalServer {
  constructor(channel) {    
    // what the constructor does
  }

  onmessage(e) {}
  postMessage(data) {
    // what postMessage does
  }
}
```

This class has a constructor that joins a channel when it is initialized. It also has a `postMessage` function to allow sending messages and an `onmessage` method that gets called when a message is received from another `SignalServer` object. 

Another aim of the `SignalServer` class is to abstract our backend processes. Our signal server is a WebSocket server because it allows us to make event-based bidirectional communication between the server and the client, which makes it the go-to choice for building our signal server. 

The `SignalServer` class starts its operations from its constructor function:

```javascript
constructor(channel) {    
  this.socket = new WebSocket("ws://localhost:80");
  this.socket.addEventListener("open", () => {
    this.postMessage({ type: "join-channel", channel });
  });
  this.socket.addEventListener("message", (e) => {
    const object = JSON.parse(e.data);
    if (object.type === "connection-established") console.log("connection established");
    else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
    else this.onmessage({ data: object });
  });
}
```

It starts by initializing a connection to the backend. When the connection becomes active, it sends an object that we’re using as our `join-channel` request to the server:

```javascript
this.socket.addEventListener("open", () => {
  this.postMessage({ type: "join-channel", channel });
});
```

Now, let’s take a look at our WebSocket server:

```javascript
const { WebSocketServer } = require("ws");

const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);

function handleConnection(ws) {
  // I cut out the details because it's not in focus right now
}
```

This is a pretty standard WebSocket server. We have our server initialization and event listener for when a new client connects to the server. The only thing new is the `channels` variable, which we’re using to store the channels that every `SignalServer` object joins. 

If a channel doesn’t exist and an object wants to join that channel, we want the server to create an empty array with the WebSocket connection as the first element. Then, we store that array as a field with the channel’s name in the `channels` object. 

You can see this in the `message` event listener below. The code looks a little complex, but the explanation above is a general overview of what the code does:

```javascript
// ... first rest of the code
ws.on('message', message => {
  const object = JSON.parse(message);

  if (object.type === "join-channel") {
    channel = object.channel;
    if (channels[channel] === undefined) channels[channel] = [];
    id = channels[channel].length || 0;
    channels[channel].push(ws);
    ws.send(JSON.stringify({type: 'joined-channel', channel}));
// ... other rest of the code
```

Afterwards, the event listener sends a `joined-channel` message to the `SignalServer` object, telling it that its request to join a channel was successful. 

As for the rest of the event listener, it sends whatever message that is not of type `join-channel` to other `SignalServer` objects in the channel:

```javascript
// rest of the event listener
  } else {
    // forward the message to other channel memebers
    channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
      member.send(message.toString());
    });
  }
});
```

In the `handleConnection` function, the `id` and `channel` variables store the position of the `SignalServer object` WebSocket connection in the channel and the name of the channel that the `SignalServer object` WebSocket connection is stored in, respectively:

```javascript
let id;
let channel = "";
```

These variables are set when the `SignalServer` object joins a channel. They’re helpful for passing messages from one `SignalServer` object to others in the channel, as you can see in the `else` block. They’re also helpful for removing the `SignalServer` object from the channel when they get disconnected for whatever reason:

```javascript
ws.on('close', () => {
  console.log('Client has disconnected!');
  if (channel !== "") {
    channels[channel] = channels[channel].filter((_, i) => i !== id);
  }
});
```

Finally, back to the `SignalServer` class in the `signalserverclass.js` file. Let’s look at the section that receives messages from the WebSocket server:

```javascript
this.socket.addEventListener("message", (e) => {
  const object = JSON.parse(e.data);
  if (object.type === "connection-established") console.log("connection established");
  else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
  else this.onmessage({ data: object });
});
```

If you look at the WebSocket server’s `handleConnection` function, there are two message types that the server sends directly to the `SignalServer` object: `joined-channel` and `connection-established`. These two message types are handled directly by this event listener. 

Other message types are forwarded to the `onmessage` event listener for our frontend app.

## Conclusion

In this article, we went through how we can build a P2P video streaming application with WebRTC — one of its primary use cases. 

We started with creating peer connections within a single pages in order to get a simple look at how WebRTC applications work without the need to worry about signaling. Then, we touched on signaling with the Broadcast Channel API. Finally, we built our own signal server. 

If you want to learn more about WebRTC, including a deep dive into some of its other use cases, feel free to check out these LogRocket articles:

*   [Creating a chat application with WebRTC](https://blog.logrocket.com/creating-chat-application-with-webrtc/)
*   [Creating an RN video calling app with react-native-webrtc](https://blog.logrocket.com/creating-rn-video-calling-app-react-native-webrtc/)
*   [WebRTC signaling with WebSocket and Node.js](https://blog.logrocket.com/webrtc-signaling-websocket-node-js/)

I hope this article has been helpful and easy to understand. If you'd like to refer to the source code for our WebRTC video streaming project, you can [check it out in this GitHub repo](https://github.com/GhoulKingR/webrtc-project). You can also [view the live demo here](https://ghoulkingr.github.io/webrtc-project/).

---

##200s only✓ Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, [try LogRocket](https://lp.logrocket.com/blg/node-signup).

[![LogRocket Signup](https://blog.logrocket.com/wp-content/uploads/2019/12/network-request-filter-2-1.png)](https://lp.logrocket.com/blg/node-signup)

[LogRocket](https://lp.logrocket.com/blg/node-signup) is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. [Start monitoring for free](https://lp.logrocket.com/blg/node-signup).