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
Our app now looks like this:
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
export default app
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>
`);
});
This is what's going on in the html:
- We are including htmx library together with WebSocket extension in the document head.
- In
<main>
, we are using the htmx attributeshx-ext
andws-connect
to tell htmx to connect to a WebSocket endpoint at/connect
- The
<ul>
will be used to render received messages, more on that later. - The
<form>
uses thews-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:
- Accept new WebSocket connections.
- 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) {}
}
# 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"]
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);
});
so what is this doing?
- We access the binding through the
env
property that Hono provides us. - 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.
- 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);
}
}
now lets explain how this works:
- The constructor receives a
state
object. This object holds all of the APIs Durable Objects can use. - 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>
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"),
);
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>
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)