Intro to µWebSockets.js

// development

µWebSockets.js is a WebSockets library for Node.js that is written entirely in C/C++ for maximum performance by Alex Hultman. The author’s goal was to completely replace ExpressJS and Socket.io with a standards-compliant, lightweight and performant stack. Apparently, it outperforms1 a more popular library, websockets/ws, that also claims to be blazing fast.

Well, whatever. IO performance and scalability will probably be one of your lowest priorities when prototyping an idea, unless you’re trying to create the next WhatsApp. The only reason why I picked µWebSockets.js was because I didn’t want a library that tries to do too much, and it fits the bill perfectly.

In this article, I’ll be walking you through setting up a basic chat application. We’ll also be covering encrypted websocket connections and nginx configuration for reverse proxying. You’ll end up with a working chat application at the end of this tutorial. If you prefer to dive right into the code, check out the example repo.


Outline:


Compiling µWebSockets binaries

In most cases, you could just do npm install uNetworking/uWebSockets.js#v17.3.0 and get the prebuilt binaries straightaway. However, if you run into an issue where the version of µWS is not compatible with your Node.js build, you’ll have to compile the binaries on your target machine:

# Clone the repo w/ submodules
git clone --recursive https://github.com/uNetworking/uWebSockets.js.git

# Cd into the folder
cd uWebSockets.js

# build
make

If you’re deploying onto a CentOS server, you have to change the compiler from clang to gcc in build.c:

// Change from
build("clang", "clang++", "-static-libstdc++ -static-libgcc -s", OS, "x64");
// To this
build("gcc", "g++", "-static-libstdc++ -static-libgcc -s", OS, "x64");

Note that because the implementation is header-only C++17, you’ll need at least GCC 5 installed.

Server-side

Initial setup

Now that we’ve gotten that out of the way, let’s jump into the fun part. The only additional library we’ll use here is uuidv4. We’ll begin with a simple template:

// server.js
const uWS = require('./uws.js');
const { uuid } = require('uuidv4');
const port = 7777;

let SOCKETS = [];

const app = uWS.App()
  .ws('/ws', {
    // config
    compression: 0,
    maxPayloadLength: 16 * 1024 * 1024,
    idleTimeout: 60,

    open: (ws, req) => {
      // this handler is called when a client opens a ws connection with the server
    },

    message: ws => {
      // called when a client sends a message
    },

    close: (ws, code, message) => {
      // called when a ws connection is closed
    }
  }).listen(port, token => {
    token ?
    console.log(`Listening to port ${port}`) :
    console.log(`Failed to listen to port ${port}`);
  });

If you run node server.js now, you can begin receiving incoming ws connections at ws://127.0.0.1:7777/ws.

We’re skipping some handlers such as drain, ping and pong since this is a short intro to get you started. You can check out the documentation for more details, although there isn’t much more to see. The library’s surface area is pretty small.

Tracking sockets

We’ll want to keep track of socket connections so we can do things like pushing system notifications or kicking people off the chat. We’d also need to assign a random username on first connection so users can have a handle on who’s connected.

// ...truncated
// in the open handler
open: (ws, req) => {
  ws.id = uuid();
  ws.username = createName(getRandomInt());

  // global SOCKETS array created earlier
  SOCKETS.push(ws);

  let msg = {
    body: {
      id: ws.id,
      name: ws.username
    }
  }

  // send to connecting socket
  ws.send(JSON.stringify(msg));
},
// ...
// functions to help us generate random usernames
function getRandomInt() {
  return Math.floor(Math.random() * Math.floor(9999));
}

function createName(randomInt) {
  return SOCKETS.find(ws => ws.name === `user-${randomInt}`) ? createName(getRandomInt()) : `user-${randomInt}`
}

Let’s walk through the code a little. Everytime a new ws connection is opened, we want to assign it a unique ID as well as a randomly generated username. createName is a simple recursion function that will keep calling itself until it no longer finds a socket with the same username. Once these two steps are done, we’ll push it into the global SOCKETS array and inform the client of its assigned username.

Pub/Sub

Before we write the client code and hook things up, let’s do just a little bit more setup to make our lives easier down the road. We need a way to decode messages from the client. We’ll also want to inform every user on the chat app whenever someone comes online as well. There’s no better way to do this than using the pub/sub pattern.

// server.js
const decoder = new TextDecoder('utf-8');

// add an enum with Object.freeze for code safety
const MESSAGE_ENUM = Object.freeze({
  SELF_CONNECTED: "SELF_CONNECTED",
  CLIENT_CONNECTED: "CLIENT_CONNECTED",
  CLIENT_DISCONNECTED: "CLIENT_DISCONNECTED",
  CLIENT_MESSAGE: "CLIENT_MESSAGE"
})

We start by first creating an enum with Object.freeze for our topic strings, which is a good practice especially if you’re working with multiple developers in a larger codebase. You don’t want a topic to be accidentally overwritten, causing your clients to suddenly stop receiving messages.

// ...truncated
// in the open handler
open: (ws, req) => {
  ws.id = uuid();
  ws.username = createName(getRandomInt());

  // subscribe to topics
  ws.subscribe(MESSAGE_ENUM.CLIENT_CONNECTED);
  ws.subscribe(MESSAGE_ENUM.CLIENT_DISCONNECTED);
  ws.subscribe(MESSAGE_ENUM.CLIENT_MESSAGE);

  // global SOCKETS array created earlier
  SOCKETS.push(ws);

  // indicate message type so the client can filter with a switch statement later on
  let selfMsg = {
    type: MESSAGE_ENUM.SELF_CONNECTED,
    body: {
      id: ws.id,
      name: ws.username
    }
  }

  let pubMsg = {
    type: MESSAGE_ENUM.CLIENT_CONNECTED,
    body: {
      id: ws.id,
      name: ws.username
    }
  }

  // send to connecting socket only
  ws.send(JSON.stringify(selfMsg));

  // send to *all* subscribed sockets
  app.publish(MESSAGE_ENUM.CLIENT_CONNECTED, pubMsg)
},

In our open handler, we’ll subscribe to topics for messages that we want every socket to receive. Notice here that we have both selfMsg and pubMsg, which at this point are identical. We’ll separate them here just to demonstrate that sending a message to a single socket is different from publishing to every subscribed socket. This could be useful somewhere down the road, if you want to send private information to the connecting socket only.

// message handler
message: (ws, message, isBinary) => {
  // decode message from client
  let clientMsg = JSON.parse(decoder.decode(message));
  let serverMsg = {};

  switch (clientMsg.type) {
    case MESSAGE_ENUM.CLIENT_MESSAGE:
      serverMsg = {
        type: MESSAGE_ENUM.CLIENT_MESSAGE,
        sender: ws.username,
        body: clientMsg.body
      };

      app.publish(MESSAGE_ENUM.CLIENT_MESSAGE, JSON.stringify(serverMsg));
      break;
    default:
      console.log("Unknown message type.");
  }
},

In the message handler, we’ll decode the message from an ArrayBuffer into its contents, which we already know is a JSON string. Next, we simply publish the message contents to every socket subscribed to MESSAGE_ENUM.CLIENT_MESSAGE along with the sender’s username.

// close handler
close: (ws, code, message) => {
  SOCKETS.find((socket, index) => {
    if (socket && socket.id === ws.id) {
      SOCKETS.splice(index, 1);
    }
  });

  let pubMsg = {
    type: MESSAGE_ENUM.CLIENT_DISCONNECTED,
    body: {
      id: ws.id,
      name: ws.name
    }
  }

  app.publish(MESSAGE_ENUM.CLIENT_DISCONNECTED, JSON.stringify(pubMsg));
}
// ...

Finally, we want to let users know whenever someone leaves the chat. We’ll just update our global sockets array in-place, and publish the disconnected socket to every client.

Client side

Hooking up

WebSocket is practically supported everywhere now, so we don’t even need additional libraries for the browser.

// script.js for the browser
const MESSAGE_ENUM = Object.freeze({
  SELF_CONNECTED: "SELF_CONNECTED",
  CLIENT_CONNECTED: "CLIENT_CONNECTED",
  CLIENT_DISCONNECTED: "CLIENT_DISCONNECTED",
  CLIENT_MESSAGE: "CLIENT_MESSAGE"
})

ws = new WebSocket("ws://127.0.0.1:7777/ws");
ws.onopen = evt => {
  ws.onmessage = evt => {
    let msg = JSON.parse(evt.data);
    switch (msg.type) {
      case MESSAGE_ENUM.CLIENT_MESSAGE:
        console.log(`${msg.sender} says: ${msg.body}`);
        break;
      case MESSAGE_ENUM.CLIENT_CONNECTED:
        console.log(`${msg.body.name} has joined the chat.`);
        break;
      case MESSAGE_ENUM.CLIENT_DISCONNECTED:
        console.log(`${msg.body.name} has left the chat.`);
        break;
      case MESSAGE_ENUM.SELF_CONNECTED:
        console.log(`You are connected! Your username is ${msg.body.name}`);
        break;
      default:
        console.log("Unknown message type.");
    }
  }
}

We’re able to filter message types since we specified the topic for each message published.

Now run node server.js in your console as well as a simple HTTP server for the webpage. You should be able to see both messages for SELF_CONNECTED and CLIENT_CONNECTED. Once you’re satisfied, we can move on to adding the finishing touches to our chat app.

Adding functionality

The next three functions are pretty straightforward. We need a function that’s called everytime the send button is clicked to send our message, as well as functions to print messages onto the chat log when we receive them from the server.

const sendMessage = evt => {
  let msg = {
    type: MESSAGE_ENUM.CLIENT_MESSAGE,
    body: DOM_EL.chatInput.value
  }
  ws.send(JSON.stringify(msg));
  DOM_EL.chatInput.value = "";
}
const printMessage = msg => {
  let listEl = document.createElement('li');
  let usernameSpanEl = document.createElement('span');
  let textSpanEl = document.createElement('span');

  usernameSpanEl.classList.add('username');
  usernameSpanEl.innerText = msg.sender;
  textSpanEl.classList.add('text');
  textSpanEl.innerText = msg.body;

  listEl.appendChild(usernameSpanEl);
  listEl.appendChild(textSpanEl);

  DOM_EL.chatLog.appendChild(listEl);
}
const logMessage = msg => {
  let listEl = document.createElement('li');
  let usernameSpanEl = document.createElement('span');
  let textSpanEl = document.createElement('span');

  usernameSpanEl.classList.add('username');
  usernameSpanEl.innerText = "System";
  textSpanEl.classList.add('text');

  switch(msg.message_type) {
    case MESSAGE_ENUM.CLIENT_CONNECTED:
      textSpanEl.innerText = `${msg.body.name} has connected`;
      break;
    case MESSAGE_ENUM.CLIENT_DISCONNECTED:
      textSpanEl.innerText = `${msg.body.name} has disconnected`;
      break;
    default:
      console.error("Unknown message type");
  }

  listEl.appendChild(usernameSpanEl);
  listEl.appendChild(textSpanEl);

  DOM_EL.chatLog.appendChild(listEl);
}

Keep-alive

At this point you have a very basic chat app that allows you to talk to anyone in the world. Fabulous. But just before we move on to deployment, we’ll need to make sure our client sockets stay connected even when we’re not sending or receiving messages for some time. Earlier on in server.js we set idleTimeout to 60 seconds. This means that sockets automatically close after it’s inactive for that long.

let wsTimeout = null;
// ...
ws.onopen = evt =>{
    wsTimeout = setTimeout(ping, 50000);
// ...

const ping = () => {
  clearTimeout(wsTimeout);
  let msg = {
    type: MESSAGE_ENUM.PING
  }
  ws.send(JSON.stringify(msg));
}

To keep our sockets alive, all we need to do is to ping the server every 50 seconds (buffer for latency and delayed packets). Our MESSAGE_ENUM object is also updated with a new topic for PING on both server and client side code, but we’ll leave it out here for brevity.

LetsEncrypt

Everything we’ve done so far works for local development, but will break once you try to host it on a server. Most modern browsers will barf at non-encrypted websocket connections, so let’s fix that. We’ll use LetsEncrypt certs as an example since they’re free and work well.

// server.js (truncated)
// ...
const app = uWS.SSLApp({
  key_file_name: "/etc/letsencrypt/live/your-domain/privkey.pem",
  cert_file_name: "/etc/letsencrypt/live/your-domain/cert.pem"
})
  .ws('/ws',
// ...
// script.js (truncated)
// ...
ws = new WebSocket("wss://your-domain/ws");
// ...

The changes to our app code are pretty minor. Setup your server with LetsEncrypt if you haven’t already, and swap out your-domain for your registered domain.

Now we just need to fix file permissions for our Node.js server to read the certs:

# SSH into your server; following commands are for CentOS 7
# We'll create a new group called 'nodecert'
sudo groupadd nodecert
# Add both root and the user which will be running Node to the group
sudo usermod -aG nodecert root
sudo usermod -aG nodecert edison
# Make the relevant letsencrypt folders owned by our group
sudo chgrp nodecert /etc/letsencrypt/live
sudo chgrp nodecert /etc/letsencrypt/archive
# allow groups 'Execute' permissions on letsencrypt folders i.e. traverse directory
sudo chmod 710 /etc/letsencrypt/live
sudo chmod 710 /etc/letsencrypt/archive
# allow groups 'Read' + 'Execute' permissions on our cert and key files
sudo chmod 750 /etc/letsencrypt/live/your-domain/cert.pem
sudo chmod 750 /etc/letsencrypt/live/your-domain/privkey.pem
# the certs in `live` are actually symlinks to the actual files in `archive`
sudo chmod 750 /etc/letsencrypt/archive/your-domain/cert1.pem
sudo chmod 750 /etc/letsencrypt/archive/your-domain/privkey1.pem

Nginx reverse proxy

Recall that our websocket server is running on port 7777. This means we need to setup a reverse proxy for :80 and :443 connections. Add a location block for the path we set earlier in server.js with the following configuration:

# Again, this is on CentOS 7
# /etc/nginx/sites-enabled/your-domain/default.conf
location /ws/ {
  proxy_http_version 1.1;
  proxy_set_header Host $http_host;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "Upgrade";

  proxy_pass https://127.0.0.1:7777/ws;
  proxy_redirect off;
  proxy_buffering off;
}

Now, when a client tries to connect to wss://your-domain/ws, it’ll be proxied to our websocket server at the correct port. And that’s all! You now have a working real-time encrypted chat app accessible from the internet. There are a lot more features you can add to it, such as allowing users to change their nicknames and using avatars, but that’s up to you from here on. :)