DEV Community

Cover image for TypeScript WebRTC. How to implement a FREE Signaling Server 🌐GameLinkSafe
PiterDev
PiterDev

Posted on

TypeScript WebRTC. How to implement a FREE Signaling Server 🌐GameLinkSafe

What is a signaling server 🤷‍♂️ ?

If we are talking about a webrtc signaling server are those servers that make posible the communication of two peers that want to share ICE candidates and other data before connecting between itselfs.

Why are soo important ❓

Signaling servers are very important if we want to ensure a good UX on our product based on WebRTC. This is because the other ways of sharing this data between peers would be dependent on the user who want to use the APP.

What types of signaling servers exist 💻 ?

Basically you can implement however you want your signaling server but the most popular ways are using:

  • REST API
  • Websockets

Personally I prefer Websockets because of the abillity of two-way communication and the maturity of the libs on the main programming languages. If we talk about two-way communication we can also have Server Sent Event (SSE) but these might be a little dificult to use/implement if you are not using languages like JavaScript or TypeScript.

Code example for a Websocket WebRTC signaling server 📘

Now we are going to check how we can make a Websocket server to do signaling using TypeScript on Deno because DenoDeploy has a very good free tier to do this type of things. Also denodeploy is very usefull due to the lots of regions that use, ensuring that you can do fast signaling in many parts of the world.

This example is a simplification of my own signaling server that I am creating for GameLinkSafe

Image description

GameLinkSafe is an Desktop APP that replace Hamachi in the Gaming enviroment and makes easy to play LAN games through the internet.

If you want to help me with the launch consider pre-registering and share the website with more people ♥

Well, this example of code is done using oak web framework and we will cover all the parts with some explanations:

First we will import oak version 16.1.0

import { Application, Router } from "https://deno.land/x/oak@v16.1.0/mod.ts";
Enter fullscreen mode Exit fullscreen mode

Next we will define our SignalMessage interface that we will share between the client/server websocket.

The discriminator field will be used to classify our signal_message if we are going to use the WS for more things than signaling.

In msg_target we will use the ID of the receiver or "*" to target all clients.

The msg_source always will be the ID of the sender.

Our signal is a simple string.

And for the last our signal_types that will be offer, answer, candidate and close. These are the posible actions that can have our signals.

interface SignalMessage {
    discriminator: "signal_message";
    signal: string;
    msg_target: string;
    msg_source: string;
    signal_type: "offer" | "answer" | "candidate" | "close";
}

function instanceOfSignalMessage(object: object): object is SignalMessage {
    return "discriminator" in object;
}

function isBroadcast(message: SignalMessage): boolean {
    return message.msg_target === "*";
}
Enter fullscreen mode Exit fullscreen mode

Now we will create an interface that extends the websocket to store there the user_id associated to the websocket.

interface SignalWebSocket extends WebSocket {
    user_id: string;
}
Enter fullscreen mode Exit fullscreen mode

Then we need a Map with the connected clients using as their key the user_id and value the SignalWebSocket that we define before.

const clientsConnected = new Map<string, SignalWebSocket>();
Enter fullscreen mode Exit fullscreen mode

Now it is the turn for our server inicialization

const app = new Application();
const port = 8080;
const router = new Router();
Enter fullscreen mode Exit fullscreen mode

In this case because of the use of Deno Deploy where we have different instances of our service working in different regions we will need a thing called BroadcastChannel this can be used to sync all the instances.

So we will create a channel called "signal_channel" and we will listen for it to search if the message is for one of our connected clients. And later when we try to send a message and we do not have that target we will post the signal data to the "signal_channel"

Note that this part is not required if you plan to host this outside of the deno deploy service.

const channel = new BroadcastChannel("signal_channel");

channel.onmessage = (e) => {
    const message = e.data;

    if (instanceOfSignalMessage(message)) {

        if (message.msg_target === "*") {
            for (const [_, socket] of clientsConnected) {
                if (socket.user_id !== message.msg_source) {
                    socket.send(JSON.stringify(message));
                }
            }
            return;
        }

        const socket = clientsConnected.get(message.msg_target);

        if (socket) {
            socket.send(JSON.stringify(message));
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

This will be the function that will try to send the message

// send a message to desired client, if cannot send to a specific client
// (e.g. not connected)
// or has to be delivered on other instance (e.g. broadcast)  return true

function try_send_signal(socket: SignalWebSocket, message: object):boolean {
    if (
        instanceOfSignalMessage(message) &&
        socket.user_id !== message.msg_source &&
        (socket.user_id === message.msg_target || isBroadcast(message))
    ) {
        socket.send(JSON.stringify(message));

        if (isBroadcast(message)) return true;

        return false;
    }

    return true;
}

Enter fullscreen mode Exit fullscreen mode

The websocket endpoint will be like this. Here we handle the user_id, the websocket lifecycle and finally we setup and start our server

router.get("/ws", (ctx) => {
    const socket = ctx.upgrade() as SignalWebSocket;

    // implement authentication here

    const user_id = ctx.request.url.searchParams.get("user_id");

    if (!user_id || !clientsConnected.has(user_id)) {
        socket.close(1008, `Id ${user_id} is already taken`);
        return;
    }

    socket.user_id = user_id;

    // when a new user logs in
    socket.onopen = () => {
        clientsConnected.set(user_id, socket);
        console.log(`New client connected: ${user_id}`);
    };

    // when a client disconnects, remove them from the connected clients list
    socket.onclose = () => {
        clientsConnected.delete(user_id);
    };

    // broadcast new message if someone sent one
    socket.onmessage = (m) => {
        const data = JSON.parse(m.data);

        const stillToBeDelivered = try_send_signal(socket, data);

        if (stillToBeDelivered) {
            channel.postMessage(data);
        }
    };
});

app.use(router.routes());
app.use(router.allowedMethods());

console.log("Listening at http://localhost:" + port);
await app.listen({ port });

Enter fullscreen mode Exit fullscreen mode

Obviously this implementation can be better in terms of optimization, specially in terms of the BroadcastChannel but can be a good base to start building your own signaling server. I hope you enjoy this little tutorial.

Before finishing this post I will say that I am looking for create a second part using Go to implement a simple client for this server using the Gorilla websockets with code examples. So if you want that second part the fastest I can, leave me comments with tons of love 😉

I always read and answer if you want to ask about something that is not very clear or you want more expanded explanations.

Top comments (0)