Durable Objects
Give each chat room or game its own Durable Object: one instance per name, with private storage and live WebSocket connections. Cloudflare routes every request for that name to the same instance, whose storage is strongly consistent.
- Libraries
Runtime.WorkersSupport.Workers - npm
@cloudflare/workers-types5.20260906.1 - Free plan 100,000 requests a day, 5 GB stored
Chat Room
Each room is one ChatRoom object, and its webSocketMessage handler sends every text message to all sockets in the room. Sockets accepted with acceptWebSocket, part of the Hibernation WebSocket API, stay open while Cloudflare evicts an idle room from memory. Cloudflare bills this room for duration only while a handler runs.
open Fable.Core
module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
type ChatRoom(ctx: Workers.DurableObjectState<obj>, env: obj) =
inherit Runtime.DurableObject<obj, obj>(ctx, env)
interface Runtime.DurableObject.IFetchHandler with
member _.fetch request =
if request.headers.get "Upgrade" = Some "websocket" then
let pair = Workers.Exports.WebSocketPair.Create()
let client = pair.``0``
let server = pair.``1``
ctx.acceptWebSocket server
let upgrade = Workers.ResponseInit.Create(status = 101., webSocket = client)
Workers.Exports.Response.Create(init = upgrade) |> U2.Case2
else
Workers.Exports.Response.Create("Expected a WebSocket", Workers.ResponseInit.Create(status = 426.))
|> U2.Case2
interface Runtime.DurableObject.IWebSocketMessageHandler with
member _.webSocketMessage(_, message) =
match message with
| U2.Case1 text ->
for socket in ctx.getWebSockets() do
socket.send (U3.Case1 text)
| _ -> ()
None
interface Runtime.DurableObject.IWebSocketCloseHandler with
member _.webSocketClose(socket, code, reason, _) =
socket.close(code, reason)
None
Emitted JavaScript
import { equals } from "./fable_modules/fable-library-js.5.13.0/Util.js";
import { item } from "./fable_modules/fable-library-js.5.13.0/Array.js";
import { WorkerEntrypoint, DurableObject } from "cloudflare:workers";
import { class_type, obj_type } from "./fable_modules/fable-library-js.5.13.0/Reflection.js";
import { getFetchByName } from "./fable_modules/FSharp.CloudEdge.Support.Workers.0.1.0/DurableObjects.fs.js";
export class ChatRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.ctx = ctx;
}
fetch(request) {
const _ = this;
if (equals(request.headers.get("Upgrade"), "websocket")) {
const pair = new WebSocketPair();
const client = pair["0"];
const server = pair["1"];
_.ctx.acceptWebSocket(server);
const upgrade = {
status: 101,
webSocket: client,
};
return new globalThis.Response(undefined, upgrade);
}
else {
return new globalThis.Response("Expected a WebSocket", ({
status: 426,
}));
}
}
webSocketMessage(_arg, message) {
const _ = this;
if (typeof message === "string") {
const arr = _.ctx.getWebSockets();
for (let idx = 0; idx <= (arr.length - 1); idx++) {
const socket = item(idx, arr);
socket.send(message);
}
}
return undefined;
}
webSocketClose(socket, code, reason, _arg) {
socket.close(code, reason);
return undefined;
}
}
Room Lookup
Clients connect to a room through this Worker, which uses the URL path as the room name. getFetchByName from Support.Workers returns a FetchTransport for the matching ChatRoom object, so everyone who opens /general joins the same room. Put RoomLookup in the same file as ChatRoom, and the compiled module exports both classes.
open Fable.Core.JsInterop
module Transport = FSharp.CloudEdge.Support.Workers.DurableObjects
type Env =
abstract ROOMS: Workers.DurableObjectNamespace<obj>
type RoomLookup(ctx: Workers.ExecutionContext<obj>, env: Env) =
inherit Runtime.WorkerEntrypoint<Env, obj>(ctx, env)
interface Runtime.WorkerEntrypoint.IFetchHandler with
member _.fetch request =
let room = Workers.Exports.URL(U2.Case1 request.url).pathname
(Transport.getFetchByName env.ROOMS room).fetch request |> U2.Case1
exportDefault jsConstructor<RoomLookup>
The fetch method of a WorkerEntrypoint class receives requests of the type that FetchTransport.fetch accepts, so RoomLookup forwards the WebSocket upgrade unchanged. exportDefault marks RoomLookup as the module's default export.
Needs a Durable Object binding named ROOMS for the ChatRoom class, and a migration that lists ChatRoom in new_sqlite_classes. Durable Objects on the free plan use the SQLite storage backend. Worker Upload shows how to declare the binding and the migration.
Emitted JavaScript
export class RoomLookup extends WorkerEntrypoint {
constructor(ctx, env) {
super(ctx, env);
this.env = env;
}
fetch(request) {
const _ = this;
const room = (new URL(request.url)).pathname;
return getFetchByName(_.env.ROOMS, room).fetch(request);
}
}
Invite Links
For rooms with random IDs, use this Worker instead of RoomLookup. When the URL has a room query parameter, idFromString parses it as an ID, and getFetchById returns a FetchTransport for that room. Otherwise the Worker creates an ID with newUniqueId and responds with it as JSON.
open Fable.Core
open Fable.Core.JsInterop
module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
module Transport = FSharp.CloudEdge.Support.Workers.DurableObjects
type Env =
abstract ROOMS: Workers.DurableObjectNamespace<obj>
type InviteLinks(ctx: Workers.ExecutionContext<obj>, env: Env) =
inherit Runtime.WorkerEntrypoint<Env, obj>(ctx, env)
interface Runtime.WorkerEntrypoint.IFetchHandler with
member _.fetch request =
match Workers.Exports.URL(U2.Case1 request.url).searchParams.get "room" with
| Some room ->
let id = env.ROOMS.idFromString room
(Transport.getFetchById env.ROOMS id).fetch request |> U2.Case1
| None ->
let id = env.ROOMS.newUniqueId()
Workers.Exports.Response.json {| room = id.toString() |} |> U2.Case2
exportDefault jsConstructor<InviteLinks>
Seat Booking
A SeatMap object holds the bookings for one show. storage.transaction runs the seat check and both writes as one transaction that either commits or aborts. The object responds to a second booking of a taken seat with status 409.
open Fable.Core
module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
type Booking = {| seat: string; email: string |}
type SeatMap(ctx: Workers.DurableObjectState<obj>, env: obj) =
inherit Runtime.DurableObject<obj, obj>(ctx, env)
let book (booking: Booking) (tx: Workers.DurableObjectTransaction) =
async {
let! holder = tx.get<string>("seat:" + booking.seat) |> Async.AwaitPromise
if holder.IsSome then
return false
else
let! sold = tx.get<float>("sold") |> Async.AwaitPromise
do! tx.put("seat:" + booking.seat, booking.email) |> Async.AwaitPromise
do! tx.put("sold", Option.defaultValue 0. sold + 1.) |> Async.AwaitPromise
return true
}
|> Async.StartAsPromise
interface Runtime.DurableObject.IFetchHandler with
member _.fetch request =
async {
let! booking = request.json<Booking>() |> Async.AwaitPromise
let! booked = ctx.storage.transaction(book booking) |> Async.AwaitPromise
let status = if booked then 201. else 409.
return Workers.Exports.Response.json({| seat = booking.seat; booked = booked |}, U2.Case2(Workers.ResponseInit.Create(status = status)))
}
|> Async.StartAsPromise
|> U2.Case1
Rate Limiter
A RateLimiter object allows one API key 100 requests a minute. On the first request of a window it sets an alarm for 60 seconds later, and the alarm handler deletes the count. Call getFetchByName with the caller's key to get the limiter for that key.
open Fable.Core
module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
type RateLimiter(ctx: Workers.DurableObjectState<obj>, env: obj) =
inherit Runtime.DurableObject<obj, obj>(ctx, env)
interface Runtime.DurableObject.IFetchHandler with
member _.fetch _ =
async {
let! used = ctx.storage.get<float>("used") |> Async.AwaitPromise
let used = Option.defaultValue 0. used + 1.
if used = 1. then
do! ctx.storage.setAlarm(U2.Case1(JS.Constructors.Date.now () + 60_000.)) |> Async.AwaitPromise
do! ctx.storage.put("used", used) |> Async.AwaitPromise
let status = if used > 100. then 429. else 200.
return Workers.Exports.Response.json({| used = used; limit = 100 |}, U2.Case2(Workers.ResponseInit.Create(status = status)))
}
|> Async.StartAsPromise
|> U2.Case1
interface Runtime.DurableObject.IAlarmHandler with
member _.alarm _ = Some(ctx.storage.deleteAll ())
Leaderboard
One Leaderboard object per game stores scores in its own SQLite database. The SQL API is synchronous, and toArray reads the rows of a cursor as Score records.
open Fable.Core
module Workers = FSharp.CloudEdge.Runtime.Workers
module Runtime = FSharp.CloudEdge.Runtime.Workers.Cloudflare.Workers
type Score = {| player: string; points: float |}
type Leaderboard(ctx: Workers.DurableObjectState<obj>, env: obj) =
inherit Runtime.DurableObject<obj, obj>(ctx, env)
do ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS scores (player TEXT PRIMARY KEY, points REAL)") |> ignore
interface Runtime.DurableObject.IFetchHandler with
member _.fetch request =
async {
if request.``method`` = "POST" then
let! score = request.json<Score>() |> Async.AwaitPromise
ctx.storage.sql.exec(
"INSERT INTO scores VALUES (?, ?) ON CONFLICT (player) DO UPDATE SET points = MAX(points, excluded.points)",
score.player, score.points) |> ignore
let top = ctx.storage.sql.exec<Score>("SELECT player, points FROM scores ORDER BY points DESC LIMIT 10").toArray()
return Workers.Exports.Response.json top
}
|> Async.StartAsPromise
|> U2.Case1
Needs a Durable Object binding and a new_sqlite_classes migration for each Durable Object class above, as for the chat room.
Library Table
| Library | npm package | What it covers |
|---|---|---|
Runtime.Workers |
@cloudflare/workers-types 5.20260906.1 |
Durable Object classes, storage and WebSockets |
Support.Workers |
Handwritten, no npm package | Typed fetch for Durable Object stubs |