DEV Community

Eduard
Eduard

Posted on

Making a real-time chatroom app with cloudflare workers

Serverless Chatroom

In this tutorial, we will build a basic chatroom using Hono, Cloudflare Workers, HTMX, and Durable Objects.

For the full working example checkout the github repository.

Introduction

If you are not familiar with Cloudflare Workers, it is a platform that allows us to build serverless applications that are globally available, while being really cheap and easy to deploy.

Cloudflare Workers also allow us to use websockets in a serverless fashion. https://github.com/cloudflare/workers-chat-demo/blob/master/src/chat.mjs that showcases this features, but it is somewhat outdated.

In this post I want to show how to make a modern Cloudflare Worker that uses WebSockets and Durable Objects for a simple Chatroom web app.

Notes: You don't need a Cloudflare account to follow the tutorial locally, but if you want to deploy the project you will need a Paid plan to access the Durable Objects bindings.

Getting Started

We will use Hono as our web framework, Hono is a modern alternative to frameworks like Express or Koa that supports Cloudflare Workers directly.

$ npm create hono my-app
# make sure to select Cloudflare Worker as template
$ cd my-app && npm install
Enter fullscreen mode Exit fullscreen mode

Our app now looks like this:

import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Hono!'))

export default app
Enter fullscreen mode Exit fullscreen mode

we can add endpoints just like with Express, and c is the request context that includes everything from Request parameters to response helpers such as the c.text method.

Use npm run dev to start the app and visit the link in your browser, you should see a response.

At this point you could also do npm run deploy to deploy the app to Cloudflare, you will have to authenticate with your Cloudflare account.

Making a basic frontend

For the actual web site, we will use HTMX with the WebSocket extension. This will allow us to extremely easy connect to our backend using a WebSocket, and sending messages and rendering new content in real time.

We can use the c.html helper from Hono to render our HTML:

import { html } from "hono/html";
// ...
app.get("/", (c) => {
    return c.html(html`
      <!doctype html>
      <html lang="en">
        <head>
          <title>chatroom</title>

          <script
            src="https://unpkg.com/htmx.org@1.9.9"
            integrity="sha384-QFjmbokDn2DjBjq+fM+8LUIVrAgqcNW2s0PjAxHETgRn9l4fvX31ZxDxvwQnyMOX"
            crossorigin="anonymous"
          ></script>
          <script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
        </head>
        <body>
          <main hx-ext="ws" ws-connect="/connect">
            <h1>chatroom</h1>
            <ul id="messages"></ul>
            <form ws-send>
              <input type="text" name="message" />
              <button>send</button>
            </form>
          </main>
        </body>
      </html>
    `);
  });
Enter fullscreen mode Exit fullscreen mode

This is what's going on in the html:

  1. We are including htmx library together with WebSocket extension in the document head.
  2. In <main>, we are using the htmx attributes hx-ext and ws-connect to tell htmx to connect to a WebSocket endpoint at /connect
  3. The <ul> will be used to render received messages, more on that later.
  4. The <form> uses the ws-send attribute to tell htmx that submissions of this form, should be sent as messages inside the WebSocket connection from before.

You can check the full documentation for the WebSocket extension.

And that's it! With this little html the frontend can be fully functional, now let's go into the backend.

Understanding Durable Objects and WebSockets in Workers

We still need to define that /connect endpoint in the server, so first, lets understand how Cloudflare Workers allow us to handle WebSocket connections.

A Durable Object is an instance of a javascript class that we can define, that will persist and allow us to coordinate state between multiple requests, or in our case, multiple WebSocket clients.

Durable Objects provide a set of WebSocket APIs that make it possible to answer to incoming messages in a serverless way, so that connections that don't send messages can stay open without incurring any charges at all.

The basic architecture of our Durable Object will be the following:

  1. Accept new WebSocket connections.
  2. Receive all incoming messages, and echo the message to all connected clients.

Creating a Durable Object

To define our object, all we need to do is export a class from our Worker, and specify it in our wrangler.toml worker configuration file:

export class Chatroom {
  constructor (state: any, env: any) {}
}
Enter fullscreen mode Exit fullscreen mode
# add this to your wrangler.toml
[[durable_objects.bindings]]
# This is the name of the binding that will be availble in our worker
name = "CHATROOM"
class_name = "Chatroom"

[[migrations]]
tag = "v1"
new_classes = ["Chatroom"]
Enter fullscreen mode Exit fullscreen mode

to understand more about this configuration options, you can check the Durable Object's documentation.

Accepting WebSocket connections

Now for the good part. We will define a new /connect endpoint that will proxy the request to our Durable Object. The Durable Object will then accept the WebSocket connection.

import { HTTPException } from "hono/http-exception";
// ... adding the endpoint
app.get("/connect", async (c) => {
  if (c.req.header("upgrade") !== "websocket") {
    throw new HTTPException(402);
  }
  // Notice how this name <CHATROOM> matches the binding name in wrangler.toml
  const id = c.env.CHATROOM.idFromName("0");
 const chatroom = c.env.CHATROOM.get(id);
  return await chatroom.fetch(c.req.raw);
});
Enter fullscreen mode Exit fullscreen mode

so what is this doing?

  1. We access the binding through the env property that Hono provides us.
  2. We need to use an ID to tell the Durable Object which instance of the Chatroom we want to use. In this example all connections will use chatroom 0 but we could add private rooms functionality as well.
  3. Finally, we use the chatroom.fetch API to talk to the Durable Object. Objects expose functionality using the fetch API just like our worker does.

Finally, we just need to define the functionality of the Durable Object, so lets make some modifications to the Chatroom class:

export class Chatroom {
  state: any;

  constructor(state: any, env: any) {
    /// The state object contains all of the Durable Object APIs 
    this.state = state;
  }

  // This is the main 'entry point' of our object
  async fetch(request: Request) {
    const pair = new WebSocketPair();
    this.state.acceptWebSocket(pair[1]);
    return new Response(null, { status: 101, webSocket: pair[0] });
  }

  /* WEBSOCKET EVENT HANDLERS */

  async webSocketMessage(ws: WebSocket, data: string) {
    const { message } = JSON.parse(data);
    this.state.getWebSockets().forEach((ws: WebSocket) => {
      ws.send(
        html` <ul id="messages" hx-swap-oob="beforeend">
          <li>${message}</li>
        </ul>` as string,
      );
    });
  }


  async webSocketClose(
    ws: WebSocket,
    code: number,
    reason: string,
    wasClean: boolean,
  ) {
    console.log("CLOSED", { ws, code, reason, wasClean });
  }

  async webSocketError(ws: WebSocket, error: any) {
    console.error("ERROR", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

now lets explain how this works:

  1. The constructor receives a state object. This object holds all of the APIs Durable Objects can use.
  2. The fetch method is what will receive and process the request we send in the /connect endpoint.

In the fetch handler, we are creating a new WebSocket pair and accepting the connection using the Durable Object API for WebSockets. This is what enables us to handler connections with event handlers in a serverless fashion.

We can then define three handlers: webSocketMessage, webSocketClose and webSocketError.

For this app, we only focus on the message handler. All we need to do is parse the message field that we are submitting, and then we can get a list of all connected clients and send the new message to them.

<ul id="messages" hx-swap-oob="beforeend">
  <li>${message}</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

the htmx WebSocket extension then processes the HTML response and uses out-of-band swaps to append the message to the list of messages.

Try opening the app in multiple tabs and send messages from them. You should see all messages appear in every tab.

Going further

You may notice that an unused connection might close automatically around ~2 minutes or so. Durable Objects can also set a socket auto response to enable a heartbeat that does not incurr charges.

Just add this to the Object class:

// somewhere inside the constructor
this.state.setWebSocketAutoResponse(
  new WebSocketRequestResponsePair("ping", "pong"),
);
Enter fullscreen mode Exit fullscreen mode

And then update the client as well:

          <script
            src="https://unpkg.com/htmx.org@1.9.9"
            integrity="sha384-QFjmbokDn2DjBjq+fM+8LUIVrAgqcNW2s0PjAxHETgRn9l4fvX31ZxDxvwQnyMOX"
            crossorigin="anonymous"
          ></script>
          <script>
            htmx.createWebSocket = function (src) {
              const ws = new WebSocket(src);
              setInterval(function () {
                if (ws.readyState === 1) {
                  ws.send("ping");
                }
              }, 20000);
              return ws;
            };
          </script>
          <script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
Enter fullscreen mode Exit fullscreen mode

This will make sure all clients send a ping message every 20 seconds, to which the server will reply with pong to keep the connection alive.

Conclusions

And that's everything! We built a serverless application that allows user to send and receive messages in real time using Durable Objects with WebSockets!

You can try adding more features, like private chatrooms, user identifiers, etc. You will notice that a new user won't see old messages, so you can also experiment with storing messages using a datastore like Cloudflare D1.

Be creative!

Top comments (0)