Search code examples
javascriptvue.jswebrtcturnsimplewebrtc

Unable to integrate Open Relay Project TURN server into WebRTC application using Vue


So i have been trying to create a simple live-streaming application in Vue using WebRTC which streams the video frames from one side to another ( and not the other way around ) and here is the example that i have been trying so far. In this example i use firestore as the signaling server for exchanging SDP and ICE candidates between two peers. But in short there will be two views, one called "CompOffer" which represents the streamer that want to initialize an offer and stream from their webcam to another and one called "CompAnswer" which represents the side which want to answer to that offer and receive the frames.

CompOffer :

<template>
    <div>
        <button @click="offer">Offer</button>
        <button @click="connect">Connect</button>
    </div>
</template>

<script>
    // eslint-disable-next-line
    import firestore from "@/firestore";
    // eslint-disable-next-line
    import { updateDoc, getDoc, doc, setDoc, arrayUnion } from "firebase/firestore";
    export default {
        data() {
            return {
                pc: null
            };
        },
        methods: {
            async offer() {
                // Create a new peer connection
                const turn_config = {
                    iceServers: [
                        {
                            urls: "stun:relay.metered.ca:80"
                        },
                        {
                            urls: "turn:relay.metered.ca:80",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        },
                        {
                            urls: "turn:relay.metered.ca:443",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        },
                        {
                            urls: "turn:relay.metered.ca:443?transport=tcp",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        }
                    ]
                };
                this.pc = new RTCPeerConnection(turn_config);
                this.pc.oniceconnectionstatechange = () => {
                    console.log(`ICE connection state changed to ${this.pc.iceConnectionState}`);
                };
                // Getting the user media ( Webcam in this case )
                const stream = await navigator.mediaDevices.getUserMedia({ video: true });
                stream.getTracks().forEach((track) => {
                    this.pc.addTrack(track, stream);
                    // Adding the tracks to the peer to stream to the other side
                });
                // Setting the ICE candidates to firestore
                const icesref = doc(firestore, "offer", "ices");
                await setDoc(icesref, {
                    ices: []
                }); // CLearing out the prevbiously ICE candidates list
                this.pc.onicecandidate = (e) => {
                    if (e.candidate) {
                        updateDoc(icesref, {
                            ices: arrayUnion(e.candidate.toJSON())
                        }).then(() => {
                            console.log("Successfully set ICE candidate");
                        });
                    }
                };
                // Create an offer and set it in firestore
                let offer = await this.pc.createOffer();
                await setDoc(doc(firestore, "offer", "main"), offer.toJSON());
                // Setting the offer to firestore
                console.log("Successfully set offer");
                await this.pc.setLocalDescription(offer);
            },
            async connect() {
                // Extracting the answer and set to local description
                // Extract he ICE candidates also
                // Getting Answerer's SDP here
                const answer = await getDoc(doc(firestore, "answer", "main")); // Getting the answer
                const ices = await getDoc(doc(firestore, "answer", "ices")); // Get answerer ICE candidates
                let answersdp, iceslistreal;
                if (answer.exists()) answersdp = answer.data();
                if (ices.exists()) iceslistreal = ices.data();
                // Setting remote Description here
                await this.pc.setRemoteDescription(new RTCSessionDescription(answersdp));
                iceslistreal["ices"].forEach((e) => {
                    this.pc.addIceCandidate(new RTCIceCandidate(e));
                });
                console.log("Successfully set remote description");
            }
        }
    };
</script>

CompAnswer :

<template>
    <div>
        <button @click="answer">Answer</button>
        <video id="vid" playsinline=""></video>
    </div>
</template>

<script>
    import firestore from "@/firestore";
    import { setDoc, doc, arrayUnion, updateDoc, getDoc } from "@firebase/firestore";
    export default {
        data() {
            return { pc: null, stream: new MediaStream() };
        },
        methods: {
            async answer() {
                // TURN servers
                const turn_config = {
                    iceServers: [
                        {
                            urls: "stun:relay.metered.ca:80"
                        },
                        {
                            urls: "turn:relay.metered.ca:80",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        },
                        {
                            urls: "turn:relay.metered.ca:443",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        },
                        {
                            urls: "turn:relay.metered.ca:443?transport=tcp",
                            username: "7c6e2dfc7ba5dd33578fc9e1",
                            credential: "18GkZDVEKpCweYAf"
                        }
                    ]
                };
                this.pc = new RTCPeerConnection(turn_config);
                this.pc.oniceconnectionstatechange = () => {
                    console.log(`ICE connection state changed to ${this.pc.iceConnectionState}`);
                };
                // Sending the answerer's ICE candidates to the offerer
                const icesref = doc(firestore, "answer", "ices");
                await setDoc(icesref, { ices: [] });
                this.pc.onicecandidate = (e) => {
                    if (e.candidate) {
                        updateDoc(icesref, {
                            ices: arrayUnion(e.candidate.toJSON())
                        }).then(() => {
                            console.log("Successfully set ICE candidate");
                        });
                    }
                };
                // Receiving remote tracks
                this.pc.ontrack = (event) => {
                    console.log("New track recieved !!!");
                    event.streams[0].getTracks().forEach((track) => {
                        this.stream.addTrack(track);
                    });
                };
                // Set the track into the DOM
                const vid = document.getElementById("vid");
                vid.srcObject = this.stream;
                vid.play(); // Make sure to play the video, otherwise there will be no video
                // ----Extracting the offer from firestore
                const offer = await getDoc(doc(firestore, "offer", "main")); // Get the main SDP
                const iceslist = await getDoc(doc(firestore, "offer", "ices")); // Get offerer ICE candidates
                let offersdp, iceslistreal;
                if (offer.exists()) offersdp = offer.data();
                if (iceslist.exists()) iceslistreal = iceslist.data();
                // Setting remote description here
                await this.pc.setRemoteDescription(new RTCSessionDescription(offersdp));
                // Generating answer and setting the answer
                let answer = await this.pc.createAnswer();
                await setDoc(doc(firestore, "answer", "main"), answer.toJSON());
                console.log("Successfully set the answer");
                // Setting local description and offerer's ICE candidates here
                await this.pc.setLocalDescription(new RTCSessionDescription(answer));
                iceslistreal["ices"].forEach((e) => {
                    this.pc.addIceCandidate(new RTCIceCandidate(e));
                });
            }
        }
    };
</script>

The steps to initialize the connection can be viewed as follow :

  1. Streamer clicks the "Offer" button first.
  2. The viewer then clicks the "Answer" button.
  3. The stream then clicks the "Connect" button to start exchanging frames.

My example works well in local network but when i publish it to a cloud instance and try those two views in two different machines on two different networks, it doesn't seem to work anymore.

So i suspected that a TURN server is needed since those two devices are behind symmetric NAT so i tried to register for an account for OpenRelay Project to grab some free TURN servers for testing purposes and i restructured the peer configuration for both of my views as follow :

Instead of : this.pc = new RTCPeerConnection();

I tried :

const turn_config = {
    iceServers: [
        {
            urls: "stun:relay.metered.ca:80"
        },
        {
            urls: "turn:relay.metered.ca:80",
            username: "7c6e2dfc7ba5dd33578fc9e1",
            credential: "18GkZDVEKpCweYAf"
        },
        {
            urls: "turn:relay.metered.ca:443",
            username: "7c6e2dfc7ba5dd33578fc9e1",
            credential: "18GkZDVEKpCweYAf"
        },
        {
            urls: "turn:relay.metered.ca:443?transport=tcp",
            username: "7c6e2dfc7ba5dd33578fc9e1",
            credential: "18GkZDVEKpCweYAf"
        }
    ]
};
this.pc = new RTCPeerConnection(turn_config);

on both views, but when i retested it in two different networks, it is still not working, there is no frame being received on the viewer side and after waiting for a short moment, both side resulted in ICE connection state changed to disconnected.

After that, I also did try setting iceTransportPolicy : "relay" in the turn_config because i guess it wasn't requesting the candidates from the TURN servers but when i do that, it didn't request any ICE candidate at all ( onicecandidate wasn't even called once )

Here is the console log of CompOffer view and here is the console log of CompAnswer view. I am pretty sure i pasted in the correct configuration that https://dashboard.metered.ca/ gave me but it's still not working. So please help, i would really appreciate, also the credentials in the TURN server that i used is just for testing purposes, feel free to tweak or try anything necessary to diagnose this.


Solution

  • I had similar problem few days ago(honestly thought free turn server is scam)and then it started working after a day.But now its down again. From my experience they have frequent outages and very unreliable. try this site to test turn server: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/

    if its only for testing purpose then registered for free tier: https://www.twilio.com/docs/stun-turn?code-sample=code-generate-nts-token&code-language=Node.js&code-sdk-version=4.x