Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

WebRTC For The Brave

Build 1:1 Peer-to-Peer Signaling Clients

In this part, you'll learn how to build a 1:1 peer-to-peer signaling clients to establish direct connection for video calls.

Overview

Having previously established the signaling server and successfully run it on your local computer in the preceding lesson, the next step involves crafting your own signaling clients for the creation of peer-to-peer video calls.

In the preceding lesson, we harnessed socket.io to establish the signaling server, facilitating the exchange of SDP messages. Now, on the client side, we will employ socket.io to establish communication with the server.

To start, create a new file named client.js. You can import and establish a connection with the signaling server using the provided code snippets as shown below:

jsx
            import { io } from "https://cdn.socket.io/4.4.1/socket.io.esm.min.js";

const socket = io('http://localhost:3000', {
    transports: ['websocket', 'polling', 'flashsocket'],
    cors: {
        origin: "http://localhost:3000",
        credentials: true
    },
    withCredentials: true
});
        

There are various methods to import socket.io on your client side. For detailed information, you can refer to the socket.io's Client API documentation. For the purpose of this lesson, we have imported socket.io using a CDN URL.

Create a Peer Connection

Since this involves a 1:1 peer-to-peer connection, we will establish a direct peer connection to hold the remote peer information for ongoing communication. As previously discussed in earlier lessons, setting up an RTCPeerConnection is straightforward, as demonstrated below:

jsx
            const pc_config = {
    iceServers: [
        {
            urls: "stun:stun.l.google.com:19302",
        },
    ],
};

const peerConnection = new RTCPeerConnection(pc_config);
        

Now let’s communicate with the signaling server to exchange SDP messages.

Listening to the Connection With the Signaling Server

Let's proceed to listen to socket messages from the signaling server step by step. To begin, you can capture the connection message by implementing the following method:

jsx
            socket.on('connect', () => {
    console.log('Hello, successfully connected to the signaling server!');
});

socket.on("room_users", (data) => {
    console.log("join:" + data);
});
        

If you execute the following command to run the client, you'll observe the log message in your developer console on your Chrome.

jsx
            node client
        

Communicate With The Signaling Server

Let's review the process of exchanging SDP (Session Description Protocol) messages that we discussed in Lesson 2: Create a Peer Connection (2): SDP Messages.

The SDP contains vital details necessary for establishing a peer connection, including Codec information, source address, audio and video media types, and other relevant properties. You can observe these components within the SDP message example below:

You will be able to exchange SDP messages through the following sequence:

  1. Generate an SDP Offer
  2. Generate an SDP Answer
  3. Exchange ICE Candidates
  4. Establish local and remote peer connections

Generate an SDP Offer

Let's start by crafting an offer that needs to be transmitted to the intended peer through the signaling server. You can generate an offer using the following example:

jsx
            socket.on("room_users", (data) => {
    console.log("join:" + data);
    createOffer()
});

const createOffer = () => {
    console.log("create offer");
    peerConnection
        .createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true})
        .then(sdp => {
            peerConnection.setLocalDescription(sdp);
            socket.emit("offer", sdp);
        })
        .catch(error => {
            console.log(error);
        });
};
        

Upon receiving the room_users packet from the signaling server, you will generate an SDK offer message and send it to the signaling server by using the socket.emit(offet, sdp) method. The signaling server will then facilitate the transmission of this message to the other peer.

Conversely, when the signaling server receives the offer message from a client (Client A), it will relay the message to the other client (Client B), excluding the sender (Client A). Subsequently, you should manage the received offer message from the receiving client (Client B), as demonstrated in the following example:

jsx
            socket.on("getOffer", (sdp) => {
    console.log("get offer:" + sdp);
    createAnswer(sdp);
});
        

Generate an SDP Answer

Now, the receiving client (Client B) should acknowledge the signaling server that it has successfully received the offer message. To achieve this, you need to generate an SDP Answer message and transmit it to the signaling server, as shown in the following example:

jsx
            const createAnswer = (sdp) => {
    peerConnection.setRemoteDescription(sdp).then(() => {
        console.log("answer set remote description success");
        peerConnection
            .createAnswer({
                offerToReceiveVideo: true,
                offerToReceiveAudio: true,
            })
            .then(sdp1 => {
                console.log("create answer");
                peerConnection.setLocalDescription(sdp1);
                socket.emit("answer", sdp1);
            })
            .catch(error => {
                console.log(error);
            });
    });
};
        

As depicted in the aforementioned example, the receiving client (Client B) proceeds to establish the received SDP messages as a remote description. Subsequently, it generates an SDP Answer using the socket.emit(answer, sdp1) method to transmit the response to the signaling server.

Upon receiving an SDP Answer message from the signaling client, the receiving client (Client A) is required to incorporate the SDP message as a remote description, as illustrated in the provided example:

jsx
            socket.on("getAnswer", (sdp) => {
    console.log("get answer:" + sdp);
    peerConnection.setRemoteDescription(sdp);
});
        

Exchange ICE Candidates

Finally, each peer must exchange ICE candidates through the signaling server. You can detect changes in ICE candidates using the onicecandidate method, as shown in the example below:

jsx
            peerConnection.onicecandidate = e => {
    if (e.candidate) {
        console.log("onicecandidate");
        socket.emit("candidate", e.candidate);
    }
};
peerConnection.oniceconnectionstatechange = e => {
    console.log(e);
};
        

Once the peer connection instance generates ICE candidate information, the sending client (Client A) will emit the ICE candidate to the signaling server, which will then broadcast the ICE candidate to the receiving client (Client B).

On the receiving client side (Client B), the client can observe the new ICE candidates from the signaling server by adding the following socket protocol:

jsx
            socket.on("getCandidate", (candidate) => {
    peerConnection.addIceCandidate(new RTCIceCandidate(candidate)).then(() => {
        console.log("candidate add success");
    });
});
        

You've addressed the key aspects of implementing the signaling client for SDP message exchange via the signaling server. Now, let's integrate the PeerConnection and MediaStream components to effectively display both local and remote video streams.

Combine MediaStreams and PeerConnections

Next, we are ready to implement the process of joining a room by integrating MediaStream and PeerConnections. As we've discussed in Creating a RTCPeerConnection, you should include media tracks to the PeerConnections, as shown in the example below:

jsx
            navigator.mediaDevices
    .getUserMedia({
        video: true,
        audio: true,
    })
    .then(stream => {
        if (localVideo.current) localVideo.current.srcObject = stream;

        stream.getTracks().forEach(track => {
            peerConnection.addTrack(track, stream);
        });
    })
        

Next, it's important to monitor the changes in ICE connection to detect the arrival of new ICE candidates. Upon receiving a new ICE candidate, you should ensure its transmission to the signaling server for eventual delivery to the intended destination peer.

jsx
            peerConnection.onicecandidate = e => {
    if (e.candidate) {
        console.log("onicecandidate");
        socket.emit("candidate", e.candidate);
    }
};
peerConnection.oniceconnectionstatechange = e => {
    console.log(e);
};

peerConnection.ontrack = ev => {
    console.log("add remotetrack success");
    if (remoteVideo.current)
        remoteVideo.current.srcObject = ev.streams[0];
};
        

As illustrated in the provided code snippet, you can effectively monitor the ICE candidate event of the remote peer connection using the peerConnection.onicecandidate function. To facilitate the process, you should transmit the newly generated candidate to the signaling server using the socket.emit(candidate, e.candidate); command.

Finally, to signify your intent to join, it's essential to inform the signaling server by transmitting a \'join\' message, as demonstrated in the following code snippet:

jsx
            socket.emit("join", {
    room: 1234,
    name: skydoves@getstream.io,
});
        

If you put these all steps within a single function named init, the code will resemble:

jsx
            async function init(e) {
    console.log("render videos");
    try {
        navigator.mediaDevices
            .getUserMedia({
                video: true,
                audio: true,
            })
            .then(stream => {
                if (localVideo.current) localVideo.current.srcObject = stream;

                stream.getTracks().forEach(track => {
                    peerConnection.addTrack(track, stream);
                });
                peerConnection.onicecandidate = e => {
                    if (e.candidate) {
                        console.log("onicecandidate");
                        socket.emit("candidate", e.candidate);
                    }
                };
                peerConnection.oniceconnectionstatechange = e => {
                    console.log(e);
                };

                peerConnection.ontrack = ev => {
                    console.log("add remotetrack success");
                    if (remoteVideo.current)
                        remoteVideo.current.srcObject = ev.streams[0];
                };

                socket.emit("join", {
                    room: "1234",
                    name: "skydoves@getstream.io",
                });
            })
            .catch(error => {
                console.log(`getUserMedia error: ${error}`);
            });
    } catch (e) {
        console.log(e);
    }
}

const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
document.querySelector('#join').addEventListener('click', e => init(e));
        

Conclusion

In this lesson, you've acquired the skills to construct the 1:1 peer-to-peer signaling client. Now, let's proceed to render it within a web browser and simulate its behavior using the signaling server.