Coding ยท ยท

WebSocket servers

Previously you've created HTTP clients and servers, working with requests and their responses. You've also written WebSocket clients which exchange messages with a server. But so far, that server hasn't been under your control. Now you're going to add WebSocket support to your HTTP server.

The TCP/IP stack defines how devices communicate on the Internet. At the top, the application layer includes protocols like HTTP, which specify how clients and servers exchange requests and responses. Below that, TCP at the transport layer ensures reliable, ordered delivery of data. HTTP runs over TCP, traditionally with one TCP connection per request, while WebSockets start with an HTTP handshake and then uses a single TCP connection for continuous, 2-way messaging. When we use WebSockets we get a 2-way channel with minimal overhead.

1. Your first WebSockets server

Your HTTP server in Bun will currently look something like this:

Bun.serve({
  routes: {
    '/hello': () => new Response(`Hello`),
    // ... other routes
  }
})

Remember that HTTP requests and responses include a header (and a body, which is not the same as an HTML <body> element) which includes such fields as content types and status codes (like 404). WebSocket connections start with a handshake over HTTP which makes use of those headers. When a client initiates a WebSocket connection with new WebSocket('...') it makes an HTTP request to the server with additional headers connection: 'Upgrade' and upgrade: 'websocket'. To set up our Bun server to complete the WebSockets upgrade handshake we need to call server.upgrade(req) in one or more of our route handlers. I often do this by making the route /ws handle WebSocket connections:

Bun.serve({
  routes: {
    '/ws': (req) => server.upgrade(req, { data: {} }),
    // ... other routes
  }
})

You'll notice we provide { data: {} } as a 2nd argument to server.upgrade(). This provides us with a place to store data per client, which will come up later.

Next, we need to define methods that will run when a new WebSocket connection is opened, and when a new message is received. Add these within the object passed to Bun.serve(), outside/after the routes object:

websocket: {
  open (ws) {
    console.log('a client connected')
  },
  message (ws, message) {
    console.log('message received:', message)
  }
}

All together:

let server = Bun.serve({
  routes: {
    '/': () => new Response('hello'),
    '/ws': (req) => server.upgrade(req, { data: {} }), // WebSocket upgrade
    // ... other routes
  },
  websocket: {
    open (ws) {
      console.log('a client connected')
    },
    message (ws, message) {
      console.log('message received:', message)
    }
  }
})

console.log(`Server listening on ${server.url}`)

Run the above server with Bun in one terminal, then open another terminal and run the following client script (also with Bun):

let ws = new WebSocket('ws://localhost:3000/ws')

ws.onopen = () => {
  ws.send('hello world')
  setInterval(() => ws.send('another message'), 1_000)
}

ws.onmessage = (msg) => {
  console.log(msg.data)
}

In the server's terminal, you'll see a client connected then every second message received: another message. No messages will appear in the client because the server isn't sending it any messages yet.

Exit both scripts with CTRL + C in their terminals.

2. Sending messages from the server

Your simple first WebSockets server receives messages from clients but doesn't send any messages to clients. In this section, we'll send messages back to individual clients, then in a later section below we'll learn how to send messages to lots of clients at once.

Send messages from the server with ws.send(msg), exactly the same as we do from a client. Here we've modified our server code to send a message back to the client when it opens a WebSocket connection, and again any time a message is received.

websocket: {
  open (ws) {
    console.log('a client connected')
    ws.send('thanks for connecting!')
  },
  message (ws, message) {
    console.log('message received:', message)
    ws.send('thanks for your message!')
  }
}

If you run both server and client scripts again, you'll see lots of thanks for your message! in the client.

3. Dealing with structured JSON messages

Remember that clients and servers exchange messages, which are strings. But by using JSON, we can turn a structured JavaScript object into a string representation of itself. We use JSON.stringify(obj) to go from object to string, and JSON.parse(str) the other way. I highly recommend using JSON for all the messages you exchange between WebSocket clients and servers.

Here's how we can adapt our server to handle both JSON and non-JSON messages it might receive:

// inside Bun.serve({ websocket: { ...here... } })
message (ws, message) {
  try {
    let data = JSON.parse(message)
    console.log('JSON received:', data)
  } catch {
    console.log('Non-JSON received:', message)
  }
}

We use try/catch blocks so that calling JSON.parse(message) doesn't crash the server when it receives a non-JSON message. Even if you're only sending JSON messages from clients you should still do this, or anyone could send a non-JSON message to your server and crash it!

Run your server again in one terminal and this client in another:

let ws = new WebSocket('ws://localhost:3000/ws')

ws.onopen = () => {
  ws.send('just some text')
  ws.send(JSON.stringify({ x: 5, y: 10 }))
}

... you should see the server handle the non-JSON and JSON message by logging them to the console.

3. Format JSON messages as { type, data }

While optional, I find giving each message a type and separate data incredibly useful. I'll often have different types of messages I want to send. For example, one type of message might be to update the position of a player in a game, while another is to register a shot or a kill or to update a player's score. Here's how I do that.

Say I want to send the x and y coordinates of a player in a message (either from client or server):

// instead of
ws.send(JSON.stringify({ x: 5, y: 10 }))

// try
ws.send(JSON.stringify({ type: 'coordinates', data: { x: 5, y: 10 } }))

To make it DRYer, I usually use a function:

function send (ws, type, data) {
  ws.send(JSON.stringify({ type, data }))
}

// so now we can do
send(ws, 'coordinates', { x: 5, y: 10 })

When we receive such messages, we can use if / else if blocks to process messages of different 'type'.

For clients that receive such messages via the ws.onmessage event handler:

ws.onmessage = (msg) => {
  try {
    let { type, data } = JSON.parse(msg.data)
    console.log(`Received JSON message of type "${ type }"`, data)
    if (type === 'coordinates') // ...
    else if (type === 'shot') // ...
    // ...
  } catch {
    console.log('Non-JSON received:', message)
  }
}

And similar code for receiving such messages on the server via the message method:

message (ws, message) {
  try {
    let { type, data } = JSON.parse(message)
    console.log(`Received JSON message of type "${ type }"`, data)
    if (type === 'coordinates') // ...
    else if (type === 'shot') // ...
    // ...
  } catch {
    console.log('Non-JSON received:', message)
  }
}

... as earlier, note the important try {} catch {} blocks as we're JSON.parse()'ing messages that aren't guaranteed to be valid JSON.

4. Creating a simple WebSockets web app

Let's put everything together to make a simple web app, keeping our code nice and DRY. Here's what we want:

Now... everything above could of course just be done via client-side JavaScript. But our goal here is to see how easy it is to exchange messages with our server, and by doing so in the future the server could be doing something else with the data, such as forwarding it on to other servers, or saving it to a database.

We'll start with the full code for the app, then I'll explain how various parts of it work. To try this out, make a new folder and put the client-side code into a file called colours.html and the server-side code into server.js.

Here's the code for the client (colours.html):

<!doctype html>
<title>Colours</title>
<button id="clear">Clear interval</button>
<button id="random">Random</button>
<button id="green">Green</button>
<input type="color" id="choice">
<script>
let ws = new WebSocket('/ws')

function send (type, data) {
  ws.send(JSON.stringify({ type, data }))
}

ws.onmessage = (msg) => {
  try {
    let { type, data } = JSON.parse(msg.data)
    messageHandlers[type](data, ws)
  } catch {
    console.log('Non-JSON or no handler found for type:', type)
  }
}

// functions to handle messages we receive
let messageHandlers = {
  newColour: (data) => {
    document.body.style.backgroundColor = data
  }
}

random.onclick = () => send('changeColour')
green.onclick = () => send('changeColour', 'green')
clear.onclick = () => send('clear')
choice.oninput = () => send('changeColour', choice.value)
</script>

And for the server (server.js):

import coloursPage from './colours.html'

let server = Bun.serve({
  routes: {
    '/': coloursPage,
    '/ws': (req) => server.upgrade(req, { data: {} })
  },
  websocket: {
    open (ws) {
      console.log('a client connected')
      send(ws, 'newColour', randomColour())
      ws.data.interval = setInterval(() => send(ws, 'newColour', randomColour()), 1_000)
    },
    message (ws, message) {
      try {
        let { type, data } = JSON.parse(message)
        messageHandlers[type](data, ws)
      } catch {
        console.log('Non-JSON or no handler found for type:', type)
      }
    }
  }
})

console.log(`Server listening on ${server.url}`)

function send (ws, type, data) {
  ws.send(JSON.stringify({ type, data }))
}

function randomColour () {
  return `hsl(${Math.floor(Math.random() * 360)} 50 50)`
}

// functions to handle messages we receive
let messageHandlers = {
  changeColour: (data, ws) => {
    if (!data) data = randomColour()
    send(ws, 'newColour', data)
  },
  clear: (data, ws) => clearInterval(ws.data.interval)
}

Run the code with bun server.js, open it in a browser and try it out.

Let's have a look at the client-side code first.

<button id="clear">Clear interval</button>
<button id="random">Random</button>
<button id="green">Green</button>
<input type="color" id="choice">

... only surprise above might be the <input type="color" id="choice">, which allows a web browser to show its own colour selector.

let ws = new WebSocket('/ws')

... because we're connecting to the same host as the web page we're currently on, we don't need the full URL http://localhost:3000/ws and indeed if we did that, we'd have to use a different address for when the website is live on our compsci.me domain. So it's crucial to just use /ws.

function send (type, data) {
  ws.send(JSON.stringify({ type, data }))
}

... I'm not bothering with a parameter for ws as we only have one WebSocket connection for this client, which we store as a global via let ws = new WebSocket('/ws'). So we only need the 2 parameters for type and data. Every message we send will go through this function, so will be JSON with type and data.

ws.onmessage = (msg) => {
  try {
    let { type, data } = JSON.parse(msg.data)
    messageHandlers[type](data, ws)
  } catch {
    console.log('Non-JSON or no handler found for type:', type)
  }
}

... this is a bit different from in the last section. Instead of lots of if / else if blocks in here, we're going to define our message handlers separately, which is a better way to structure our code. The line messageHandlers[type](data, ws) will try to run a message handler for a particular type of message received.

let messageHandlers = {
  newColour: (data) => {
    document.body.style.backgroundColor = data
  }
}

... currently, we only have one type of message that the client is expecting to receive from the server, of type newColour. The handler for that type of message receives the data of the message as its first parameter and simply sets the HTML <body> element's background colour to the value of that data.

random.onclick = () => send('changeColour')
green.onclick = () => send('changeColour', 'green')
clear.onclick = () => send('clear')
choice.oninput = () => send('changeColour', choice.value)

... the above are 3 simple button onclick handlers and an oninput handler for the <input type="color">. Each of them uses the send() function to send a JSON message to the server.

Now to look at the server code.

import coloursPage from './colours.html'

let server = Bun.serve({
  routes: {
      '/': coloursPage
      // ...
  }
})

... well, this is new! You're used to importing .js files, but here we're importing HTML instead! This is a really handy feature of Bun, but we're not going to go into any detail about it now other than to say it will serve your HTML page at / with full hot reloading support.

// ... in the open handler for a websocket
send(ws, 'newColour', randomColour())

... unlike on the client, on the server we do need to send ws as an argument to send() because the server may be handling many clients at once, and we need to ensure the message goes back to the client we've just opened.

// ... also in the open handler for a websocket
ws.data.interval = setInterval(() => send(ws, 'newColour', randomColour()), 1_000)

... the use of setInterval() here shouldn't be new, and neither should the use of send(). When we call setInterval() it returns an interval id which we can later send to clearInterval() to cancel the interval. We don't want to cancel the interval until the client clicks the 'Clear interval' button, but we do need to store the interval id for now, so that we can cancel it later. We should't store that in a global variable because our server can deal with more than one client: if we did, then one client might cancel a different client's interval. When we opened our WebSocket connection with server.upgrade(req, { data: {} }) we initialised an empty object into which we can store per-client data. Here, we're adding an interval property to that object.

function randomColour () {
  return `hsl(${Math.floor(Math.random() * 360)} 50 50)`
}

... this is choosing a random colour by specifying values for hue, saturation and lightness. The colour returned will have a fixed saturation and lightness of 50% but with a random hue.

let messageHandlers = {
  changeColour: (data, ws) => {
    if (!data) data = randomColour()
    send(ws, 'newColour', data)
  },
  clear: (data, ws) => clearInterval(ws.data.interval)
}

... the client had just one message handler function, while the server has two. colourChange either receives a colour from the client or selects a random one if none is provided, then sends it back to the client. The clear function clears the setInterval() for the correct client.

I want to foucs on a few lines of code from both the client and server combined now:

// on the client
send('changeColour', 'green')

// on the server
changeColour: (data, ws) => {
  if (!data) data = randomColour()
  send(ws, 'newColour', data)
}

// on the client
newColour: (data) => {
  document.body.style.backgroundColor = data
}

Are you seeing what I'm seeing?! We're using the type as though its the name of a procedure on the 'other side' of the client-server divide. When we call send('changeColour', 'green') from the client we're sending a message to the server which is running a procedure called changeColour and providing it with 'green' as an argument. This feels kind of magical. Note I am using the word procedure rather than function here, as what we're not getting is a direct return value.

Once you understand the code fully, try changing it to do something cool.

5. Message publishing and subscribing ('pub/sub')

So far, we've learnt how to send and receive messages between a server and its clients. Now, we're going to look at how your server-side code can subscribe clients to receive messages published to different channels (aka rooms):

let server = Bun.serve({
  routes: {
    '/': () => new Response('hello'),
    '/ws': (req) => server.upgrade(req, { data: {} }),
    // ... other routes
  },
  websocket: {
    open (ws) {
      ws.subscribe('everyone')
    },
    message (ws, message) {
      ws.publish('everyone', message)
    }
  }
})

setInterval(() => server.publish('everyone', Date.now()), 5_000)

console.log(`Server listening on ${server.url}`)

Let's connect to that with a simple script that creates 3 concurrent WebSocket clients:

for (let i = 1; i <= 3; i++) {
  let ws = new WebSocket('ws://localhost:3000/ws')
  ws.onmessage = (msg) => console.log(`Message received by client ${i}: "${msg.data}"`)
  setTimeout(() => ws.send(`Hello from client ${i}`), 1_000)
}

You should see that each client received the message from each other client, and every 5s they all receive the time from the server too.

There are 3 methods here which we need to know:

To clarify, ws.publish() won't send a message back to the current client, while server.publish() will.

As earlier, I recommend sending all messages as JSON, with type and data properties.

6. Setting and using per-client data

When we upgrade an HTTP connection to use WebSockets with server.upgrade(req, { data: {} }), we are creating an object ws.data in which we can store data just for that client.

Here's how we can use that (like I use that on api.compsci.me) to connect clients into different pub/sub rooms:

let server = Bun.serve({
  routes: {
    '/room/:room': (req) => server.upgrade(req, {
      data: { room: req.params.room }
    }),
    // ... other routes
  },
  websocket: {
    open (ws) {
      ws.subscribe(ws.data.room)
      ws.subscribe('everyone')
    },
    message (ws, message) {
      ws.publish(ws.data.room, message)
    }
  }
})

setInterval(() => server.publish('everyone', Date.now()), 5_000)

console.log(`Server listening on ${server.url}`)

And a script which creates clients in different rooms to try it out:

for (let room of ['cats', 'dogs']) {
  for (let i = 1; i <= 3; i++) {
    let ws = new WebSocket(`ws://localhost:3000/room/${room}`)
    ws.onmessage = (msg) => console.log(`Message received by client ${room}-${i}: "${msg.data}"`)
    setTimeout(() => ws.send(`Hello from client ${room}-${i}`), 1_000)
  }
}

You may also want to use ws.data for example to track a player object, which might include eg coordinates, a colour and a unique id:

let server = Bun.serve({
  routes: {
    '/ws': (req) => server.upgrade(req, { data: {
      // initial state for the player
      id: Math.floor(Math.random() * 100_000),
      colour: `hsl(${Math.floor(Math.random() * 360)} 50 50)`,
      coords: { x: 200, y: 200 }
    } }),
    // ... other routes
  },
  websocket: {
    open (ws) {
      ws.send(JSON.stringify(ws.data))
      ws.subscribe('everyone')
    },
    message (ws, message) {
      try {
        let { type, data } = JSON.parse(message)
        if (type === 'coords') {
          ws.data.coords = data
          ws.publish('everyone', JSON.stringify({
            type: 'playerMoved',
            data: { playerId: ws.data.id, coords: ws.data.coords }
          }))
        }
      } catch {}
    }
  }
})

console.log(`Server listening on ${server.url}`)

And some client code to try it out:

for (let i = 1; i <= 3; i++) {
  let ws = new WebSocket('ws://localhost:3000/ws')
  ws.onmessage = (msg) => console.log(`Message received by client ${i}: "${msg.data}"`)
  if (i === 1) setTimeout(() => ws.send(JSON.stringify({ type: 'coords', data: { x: 50, y: 50 } })), 2_000)
}

7. Play, play, play!

I can't stress this enough - play around with the code, try stuff out... and try to create something cool!

8. Remote procedures and functions

You may be interested in using a library which I made which abstracts away the concept of messages and lets you simply run remote functions and procedures over a WebSocket connection.

If you use it (see usage guide), you can either treat it like a black-box or you can view the <100 lines of source code on GitHub to try to figure out how it works.