WebSockets and Fly

One of the regular questions we get at Fly is "Do you support WebSocket connections?". The answer is "Yes", but before you head off let us tell you a bit more and show you an example.

WebSockets are powerful things for creating interactive applications. Example Zero for WebSocket examples is the chat application. This leverages WebSockets' ability to keep a connection alive over a long period of time while bidirectionally passing messages over it that ideally should be something conversational.

If you haven't got an example app like that, we've got one here for you - flychat-ws in fly-examples. It's been put together to use only raw WebSockets (and express for serving up pages) and no other libraries. (A shoutout to other libraries that build on WebSockets like socket.io).

Let's get this application up on Fly first. Clone the repository and run:

npm install

to fill out the node_modules directory.

Assuming you have installed flyctl and signed up with Fly (head to the hands-on if you haven't), the next step is to create an application:

flyctl apps create

Hit return to autogenerate a name and accept all the defaults. Now run:

flyctl deploy

And watch your new chat app deploy onto the Fly platform. When it's done run:

flyctl open

And your browser will open with your new chat window. You'll notice we did no special configuration or changes to the application to make it deploy. So let's dive in and see what's in there.

Down the WebSocket

The source for this application isn't extraordinary. The only thing that should stand out is the fly.toml file which was created when we ran fly apps create:

Dockerfile    fly.toml             public
LICENSE       node_modules         server.js
README.md     package-lock.json    package.json

The fly.toml file contains all the configuration information about how this app should be deployed; it looks like this:

app = "flychatting-ws"

[[services]]
  internal_port = 8080
  protocol = "tcp"

  [services.concurrency]
    hard_limit = 25
    soft_limit = 20

  [[services.ports]]
    handlers = ["http"]
    port = "80"

  [[services.ports]]
    handlers = ["tls", "http"]
    port = "443"

  [[services.tcp_checks]]
    interval = 10000
    timeout = 2000

And to paraphrase the contents, it says

  • The application will take tcp connections on port 8080.
  • An http service will be available to the outside would on port 80 (which will be sent to port 8080).
  • An https service will also be available to the world on port 443 (and again traffic will be sent to port 8080).
  • There's a 25 connection hard limit before a new instance of the application is spun up.
  • The 8080 internal port will be checked every 10 seconds (with a 2 second time out) for connectivity - if the app doesn't respond it'll be restarted.

This is all out-of-the-box Fly configuration.

Into the Server

The server.js file is a whole 17 lines long but it does plenty in 17 lines:

const { createServer }  = require('http');
const express = require('express');
const WebSocket=require('ws');

First it pulls in the packages needed, express and ws the WebSockets library.

Then it configures express to serve static files from the public directory:

const app=express();
app.use(express.json({ extended: false}));
app.use(express.static('public'));

The public directory contains the web page and JavaScript for the chat application. Now we move on to starting up the servers. There are two to start up: the WebSocket Server and the Express server. But they need to know where to listen, so we'll grab the port from the environment - or default to port 3000:

var port = process.env.PORT || 3000;

Now we can start the servers:

const server=new WebSocket.Server({ server:app.listen(port) });

Reading from the inside out, it starts the Express server with it listening on our selected port and then hands that server over to create a new WebSocket.Server.

Now all we have to do is tell the code what to do with incoming connections:

server.on('connection', (socket) => {
  socket.on('message', (msg) => {
    server.clients.forEach( client => {
      client.send(msg);
    })
  });
});

When a client connects, it'll generate a connection event on the server. We grab the socket that connection came in and add an event handler for incoming messages to it. This handler takes any incoming message and sends it out to any connected client. We don't even have to track which clients are connected in our simple chat. The WebSocket server maintains a list of connected clients so we can walk through that list.

And that's the end of the server. Yes, there isn't a lot there but it all works. It would be remiss of us at this point not to mention that we use a Dockerfile to assemble the image that's run on Fly; here it is:

FROM node:current-alpine

WORKDIR /app

COPY package.json .
COPY package-lock.json .

RUN npm install --production

COPY . .

ENV PORT=8080

CMD [ "npm","start" ]

I say remiss because this is where we set the port number in the environment to match up with the port in the fly.toml file from earlier. Oh, and we use npm start as the command to start the server up because in package.json we've made sure we remembered to set a script up:

"scripts": {
    "start": "node server.js"
  }

So, you can run npm start to run the server locally (by default on port 3000), or you can build and run it locally using Docker:

docker build -t test-chat .
docker run -p 8080:8080 test-chat

Of course, in this case you've already deployed it to Fly with a single command. Let's move on to the user-facing side of things.

Now for the Client

The client code is all in the public directory. One HTML file lays out a simple form and calls some JavaScript on loading. That JavaScript is all in client.js and that's what we are going to look at now:

'use strict'
var socket = null;

function connect() {
  var serverUrl;
  var scheme = 'ws';
  var location = document.location;

  if (location.protocol === 'https:') {
    scheme += 's';
  }

  serverUrl = `${scheme}://${location.hostname}:${location.port}`;

There's a global socket because we only need one to connect to the server. This gets initialised in our connect call, and it's here that the code touches on the fact it'll be running on Fly. It looks up the URL it has been served from, and the port, and if served from an https: URL uses secure WebSockets (wss:). If not, it'll use ordinary WebSockets (ws:).

Fly's default configuration is to serve up internal port 8080 on external port 80 unsecured and 443 with TLS. That TLS traffic is terminated at the network edge so from the application's point of view, it's all traffic on one port and no need to do anything special to handle TLS. Pow, less code to write and manage. All you have to do is make sure you don't try to do anything special for these connections.

Once we have the URL, we open the WebSocket saying we want to work with a "json" protocol. With the socket opened, let's wire it up to receive messages:

  socket.onmessage = event => {
    const msg = JSON.parse(event.data)
    $('#messages').append($('<li>').text(msg.name + ':' + msg.message))
    window.scrollTo(0, document.body.scrollHeight);
  }

This simply decodes the JSON into a message and pops it into our chat display. Most of the code is about doing the page manipulation. The last part of the connect process wires up the submit on a form where you type messages:

   $('form').submit(sendMessage);
}

The last part of this is that sendMessage function:

function sendMessage() {
  name = $('#n').val();
  if (name == '') {
    return;
  }
  $('#n').prop('disabled', true);
  $('#n').css('background', 'grey');
  $('#n').css('color', 'white');
  const msg = { type: 'message', name: name, message: $('#m').val() };
  socket.send(JSON.stringify(msg));
  $('#m').val('');
  return false;
}

Which is mostly CSS manipulation and reading the name and message fields, and who wants to spend time on that? The important part for the sockets side of things is these two lines:

  const msg = { type: 'message', name: name, message: $('#m').val() }
  socket.send(JSON.stringify(msg))

Where a message is composed as a JSON object and then that JSON object is turned into a string and sent. The message will return soon enough as our server broadcasts to every client, including the one that originated the message. That means there's really no need to update our messages view when we send. Score one for lazy coding.

Ready to Fly

So what's this walk through the code shown us? Obviously that it's incredibly easy to deploy an app to Fly, but also that Fly takes care of TLS connections so there's less code for you to make. That it's simple to make an Express app that also services sockets. That you can quickly test locally both as a native app and as a docker image. And to go remote it takes just one command to move it all onto Fly's global infrastructure. That WebSockets simply work on Fly is just part of what Fly brings to the developers' table.

Want to learn more about Fly? Head over to our Fly Docs for lots more, including a Hands On where you can get a free account and deploy your first app today.