If you are building an application that requires some collaboration features or simply needs to feel “realtime”, you will end up having to use Websockets in some shape or form. One of the simplest ways of creating Websocket-powered services, without spinning up an “always-on” server, is using Cloudflare’s Durable Objects.
What are Durable Objects exactly?
In the land of serverless, we have long gotten used to stateless transactions: you send a request to an endpoint or a function, it processes the request and sends a response. Each time it is naïve: it has no memory of what happened before unless you store that data persistently.
However, if you’re building an application with any real-time features, like a game or a chatroom, some statefulness will be necessary. In your code you’d likely express that statefulness using some sort of class instance or object: const room = new Room()
- Durable Objects are exactly that, but offered as a hosted platform primitive.
This makes them ideal for workloads where some form of short-to-medium-term, in-memory state is necessary: collaborative features, CI/CD pipelines—and any time Websockets are in the mix. If you want to get a deeper perspective on Durable Objects, read this excellent piece from Lambros Petrou.
Similar to vanilla Cloudflare Workers, we can use the Durable Objects directly on the platform without any framework. However, if we want to build something a little more complex - we should use a routing framework. Hono, our tool of choice for today, features a robust middleware pattern that we can use to weave our Worker and Durable Object together.
Let’s get into it.
Pre-requisites
To go through this walkthrough you will need:
- A machine with
node
and your favorite JavaScript package manager installed. - If you want to ship your Durable Object-powered Worker to prod: a Cloudflare account and a paid plan.
Overview
We are going to build a simple Webhook inspection service, a similar/simplified version to webhook.site
or the one that is available in our own Fiberplane Studio. This service will:
- Allow any client to connect with it over a Websocket connection on route
/ws
- Listen for HTTP requests on route
/receiver-listen
- Any time a request is received on route
/receiver-listen
, serialize the method, header, and body data, and broadcast it over the existing pool of clients connected on/ws
In order to do that we will set up a basic Cloudflare Worker, powered by Hono, that will connect to a Durable Object instance and allow for Websocket connectivity from the client.
You will find all of the code from this article in this GitHub repository.
Walkthrough
Create a Cloudflare application
First let’s initialize a Cloudflare application using their own CLI and instruct it to use hono
as the web framework. Run the following command in your terminal:
npm create cloudflare@latest -- hooks-and-sockets --framework=hono
Name it whatever you like, but we’re calling the project hooks-and-sockets
. Follow the CLI prompts to set up your new Cloudflare application.
Project structure
Here is an overview of the files that we will be working with:
project/
├─ wrangler.toml # Cloudflare configuration
├─ src/
│ ├─ index.ts # main entrypoint
│ ├─ receiver.ts # Durable Object will be defined here
├─ package.json
├─ worker-configuration.d.ts # Cloudflare runtime bindings
├─ tsconfig.json
Initial setup
In our index.ts
file let’s set up a basic Hono project.
// src/index.ts
import { Hono } from "hono"
const app = new Hono<{ Bindings: CloudflareBindings }>();
app.get("/", (c) => c.text("Hello Cloudflare Workers!"))
export default app
Set up Durable Objects in wrangler.toml
To set up Durable Objects in wrangler.toml
, add the following configuration under the [durable_objects]
section:
# ./wrangler.toml
# ...
# add the following ↓
[durable_objects]
bindings = [
{ name = "WEBHOOK_RECEIVER", class_name = "WebhookReceiver" }
]
This tells the wrangler
runtime to link Durable Object, an infrastructure component, with a TypeScript class WebhookReceiver
.
Based on the information in wrangler.toml
Cloudflare generates the correct type bindings in a global interface CloudflareBindings
, so that you can see what methods are available to you while working in your application. To regenerate the types run:
npm run cf-typegen
And inspect the worker-configuration.d.ts
file at the root of the repo.
Creating a basic Durable Object
Durable Objects are effectively “upgraded” Workers - they still need the Worker interface to communicate to the outside world, but they offer extra features that we mentioned earlier.
Continuing our “Durable Objects are just JavaScript/TypeScript classes” theme, starting one is as simple as:
// src/receiver.ts
import { DurableObject } from "cloudflare:workers"
// 1. Make sure the class name corresponds exactly with the one
// added in wrangler.toml earlier
export class WebhookReceiver extends DurableObject {
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env)
}
// 2. This fetch method serves as a communication layer between the Worker
// and the Durable Object
async fetch(request: Request) {
return new Response("Hello world from a Durable Object");
}
}
We can then update our Hono-powered Worker to link the two together. First we need to add a line:
// src/index.ts
// ...
export { WebhookReceiver } from "./receiver";
so that our Cloudflare runtime is aware of the newly-created Durable Object.
We can then define a new route that, when ping’ed, will forward the request details to the WebhookReceiver
. Here’s the updated src/index.ts
code:
// src/index.ts
import { Hono } from "hono";
const app = new Hono<{ Bindings: CloudflareBindings }>();
app.get("/", (c) => {
return c.text("Hello Hono!");
});
app.get("/ws", async (c) => {
const id = c.env.WEBHOOK_RECEIVER.idFromName("default")
const stub = c.env.WEBHOOK_RECEIVER.get(id)
return stub.fetch(c.req.raw)
});
export { WebhookReceiver } from "./receiver";
export default app;
Notice how we’re instantiating a WebhookReceiver
”stub” inside the /ws
handler. A “stub” is effectively a client Object that our Worker will use to communicate with the WebhookReceiver
.
If you now query your /ws
endpoint you should receive:
200 OK
Hello world from a Durable Object
Adding Websockets
So far so good. However, we haven’t gone far from where we started - our /ws
route is still just a simple stateless request-response flow. Let’s upgrade it (see what I did there) to use websockets.
First, change the /ws
route and make sure it only accepts requests that ask to upgrade to use websockets. We'll also use .idFromName()
and hardcode the passed in string parameter to "default"
instead of creating a new ID each time, to ensure that all open Websocket connections are connected to the same Durable Object. In real use cases, you will probably want to segment that in some way: E.g.: Pass in the ID of connected user, so they get their own Durable Object, along with their own pool of Websocket connections.
import { Hono } from "hono";
const app = new Hono<{ Bindings: CloudflareBindings }>();
app.get("/", (c) => {
return c.text("Hello Hono!");
});
app.get("/ws", async (c) => {
// Reject requests that don't require upgrade
if (c.req.header("upgrade") !== "websocket") {
return c.text("Expected Upgrade: websocket", 426);
}
// This ensures we always get the same stub no matter how many
// times the /receiver-connect route is called
const id = c.env.WEBHOOK_RECEIVER.idFromName("default")
const stub = c.env.WEBHOOK_RECEIVER.get(id)
return stub.fetch(c.req.raw)
});
export { WebhookReceiver } from "./receiver";
export default app;
Websocket Hibernation API
Now in our WebhookReceiver
’s fetch
method let’s add some logic that will;
- Create a Websocket connection client-server pair
- Store the connection in a new Set
connections
- Tell the Durable Object to accept websocket messages
- and send the client information as a response.
Notice, however, that we’re not using the standard websocket.accept()
but Cloudflare’s acceptWebSocket()
. This method informs the client that it is ready to accept messages over the Websocket protocol while also allowing the Durable Object to “hibernate” and preserve memory when it is inactive, saving on costs.
The Hibernation API works by providing its own interface for Websocket handlers that we can use to trigger actions: webSocketMessage
, webSocketClose
, webSocketError
. Since our Durable Object will be waiting for most of the time and only taking action when a request is received on a different endpoint, we should really make use of this API.
In our application we don't need to do much here as its main use of Websocket connectivity is to send messages to the client as opposed to receiving and acting on them, however we can add some logic to clean up our connections
Set if any of our Websocket connections close or error. We also don't need to implement the standard Websocket "ping-pong" exchange as this is handled by Cloudflare.
Here's what we have in our receiver.ts
so far:
// src/receiver.ts
import { DurableObject } from "cloudflare:workers";
export class WebhookReceiver extends DurableObject<CloudflareBindings> {
connections: Set<WebSocket>;
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env);
this.connections = new Set<WebSocket>();
}
async fetch(req: Request) {
const websocketPair = new WebSocketPair();
const [client, server] = Object.values(websocketPair);
this.ctx.acceptWebSocket(server)
this.connections.add(client);
return new Response(null, {
status: 101,
webSocket: client,
})
}
webSocketError(ws: WebSocket, error: unknown) {
console.error("webSocketError", error);
this.connections.delete(ws);
}
webSocketClose(ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {
console.log("webSocketClose, connections", this.connections);
this.connections.delete(ws);
}
}
Having both Worker and Durable Object in place, you can now try running the application (wrangler dev
) and connecting to the Websocket route /ws
with a Websocket client like websocat
: websocat --verbose ws://localhost:8787/ws
You should see a response like this indicating that the connection has been established succesfully:
> websocat -v ws://localhost:8787/ws
[INFO websocat::lints] Auto-inserting the line mode
[INFO websocat::stdio_threaded_peer] get_stdio_peer (threaded)
[INFO websocat::ws_client_peer] get_ws_client_peer
[INFO websocat::net_peer] Connected to TCP [::1]:8787
[INFO websocat::ws_client_peer] Connected to ws
Adding receiver listening route
Now that we have our basic Websocket connection working, let's send some information down the wire. In our main Worker file src/index.ts
let's add another route that will be our request listener: /receiver-listener/*
. Any time a request hits this route, we want to capture its information (method, path, and body), serialize it, and send it to each connected Websocket client.
import { Hono } from "hono";
const app = new Hono<{ Bindings: CloudflareBindings }>();
app.get("/", (c) => {
return c.text("Hello Hono!");
});
app.get("/ws", async (c) => {
if (c.req.header("upgrade") !== "websocket") {
return c.text("Expected Upgrade: websocket", 426);
}
const id = c.env.WEBHOOK_RECEIVER.idFromName("default")
const stub = c.env.WEBHOOK_RECEIVER.get(id)
return stub.fetch(c.req.raw)
});
app.all("/receiver-listen/*", async (c) => {
const method = c.req.method;
const path = c.req.path;
const body = await c.req.text()
const received = {
method,
path,
body
}
const id = c.env.WEBHOOK_RECEIVER.idFromName("default")
const stub = c.env.WEBHOOK_RECEIVER.get(id)
await stub.broadcast(JSON.stringify(received));
return c.text("OK");
})
export { WebhookReceiver } from "./receiver";
export default app;
This code will work but there is one thing we can improve here. In both routes we're executing the same logic that creates the connection with the Durable Object. We can lift that into a middleware and essentially make it available to all routes at the same time. Here's the updated code for src/index.ts
.
import { Hono } from "hono";
import { WebhookReceiver } from "./receiver";
import { instrument, measure } from "@fiberplane/hono-otel";
// we define another object called Variables that we can pass to the Hono app
type Variables = {
receiver: DurableObjectStub<WebhookReceiver>;
};
const app = new Hono<{ Bindings: CloudflareBindings, Variables: Variables }>();
// we create the stub connection earlier on in the process and assign
// it to a dedicated variable
app.use("*", async (c, next) => {
const id = c.env.WEBHOOK_RECEIVER.idFromName("default");
const stub = c.env.WEBHOOK_RECEIVER.get(id);
c.set("receiver", stub);
await next();
})
app.get("/", (c) => {
return c.text("Hello Hono!");
});
app.get("/ws", async (c) => {
if (c.req.header("upgrade") !== "websocket") {
return c.text("Not a websocket request", 426);
}
const stub = c.get("receiver");
return stub.fetch(c.req.raw)
});
app.all("/receiver-listen/*", async (c) => {
const method = c.req.method;
const path = c.req.path;
const body = await c.req.text()
const received = {
method,
path,
body
}
const stub = c.get("receiver");
const measuredBroadcast = measure("broadcast", async () => await stub.broadcast(JSON.stringify(received)));
await measuredBroadcast();
return c.text("OK");
})
export { WebhookReceiver };
export default instrument(app);
Websocket Hibernation gotchas
On paper this should all work, however, there's one more gotcha here. Remember how we're using Cloudflare's Websocket Hibernation API? In practice what that means is that every time a Durable Object is "awakened" from its hibernation, its constructor
function gets called - i.e. our pool of client connections stored in the this.connections
Set effectively gets wiped clean.
Fortunately, Cloudflare's runtime provides a way to retrieve all accepted Websocket connections in a getWebSockets()
method available on the same DurableObjectState
that we called acceptWebSocket()
on. In our constructor
we can call this.ctx.getWebSockets()
and re-populate our connections
Set.
Here's our final receiver Durable Object:
export class WebhookReceiver extends DurableObject<CloudflareBindings> {
connections: Set<WebSocket>;
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env);
this.connections = new Set<WebSocket>();
const websockets = this.ctx.getWebSockets();
for (const ws of websockets) {
this.connections.add(ws);
}
}
async fetch(req: Request) {
const websocketPair = new WebSocketPair();
const [client, server] = Object.values(websocketPair);
this.ctx.acceptWebSocket(server)
this.connections.add(client);
console.log("fetch, connections", this.connections);
return new Response(null, {
status: 101,
webSocket: client,
})
}
webSocketError(ws: WebSocket, error: unknown) {
this.connections.delete(ws);
}
webSocketClose(ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {
this.connections.delete(ws);
}
async broadcast(message: string) {
for (const connection of this.connections) {
connection.send(message);
}
}
}
Recap
Whew! This was a long one so here’s a quick recap what we have achieved:
- Set up a Cloudflare application using Hono framework
- Configured and created a basic Durable Object in
wrangler.toml
, calledWebhookReceiver
and updated our Worker to link with it - Added Websocket support to the
/ws
route and implemented Websocket connection handling in the Durable Object using Cloudflare’s Websocket Hibernation API - Created a
/receiver-listen
route to capture and broadcast requests to existing Websocket connections.
If everything went well, by the end of this you should have a small service that:
- You can connect and establish a Websocket connection with on route
/ws
:
websocat -v ws://localhost:8787/ws
- Send a request against the
/receiver-listen
route and have the request details mirrored in the screen you’ve connected in the previous step:
curl localhost:8787/receiver-listen -X POST -d '{"key": "value"}'
websocat -v ws://localhost:8787/ws
[INFO websocat::lints] Auto-inserting the line mode
[INFO websocat::stdio_threaded_peer] get_stdio_peer (threaded)
[INFO websocat::ws_client_peer] get_ws_client_peer
[INFO websocat::net_peer] Connected to TCP [::1]:8787
[INFO websocat::ws_client_peer] Connected to ws
{"method":"POST","path":"/receiver-listen","body":"{\"key\": \"value\"}"}
Top comments (1)
Thanks for the very informative article.
If possible, please add the following line at your "final receiver Durable Object".
I spent about 2 hours debugging because of this missing line.
Anyone would have immediately noticed that the import statement was missing! You think?
I agree. Now.