examples/live-notes Public
Serve each note from its own Durable Object
The Worker routes /notes/:id to a NoteRoom picked by idFromName, so all editors of a note meet in one object. NoteRoom keeps the note and its op log in SQLite and accepts editors' sockets with the Hibernation API, so an idle note is evicted without dropping anyone.
Changed files
6 files changed166 additions1 deletionagainst 2c15282
Changedpackage.json+2−1
| @@ -10,7 +10,8 @@ | ||
| }, | ||
| "scripts": { | ||
| "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"", | ||
| - "typecheck": "tsc" | ||
| + "typecheck": "tsc", | ||
| + "dev": "npx wrangler dev" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/workers-types": "^5.20261016.1", | ||
Addedsrc/index.ts+30−0
| @@ -0,0 +1,30 @@ | ||
| +// The live-notes Worker. It routes each note to its own NoteRoom by name, so every editor of | ||
| +// "groceries" reaches the same object wherever they are. | ||
| +// | ||
| +// GET /notes/:id the note as JSON: { id, text, version } | ||
| +// GET /notes/:id/ws upgrade to a WebSocket and edit the note (protocol.ts) | ||
| + | ||
| +import type { NoteRoom } from "./note-room.ts"; | ||
| +import { route } from "./routes.ts"; | ||
| + | ||
| +export { NoteRoom } from "./note-room.ts"; | ||
| + | ||
| +export interface Env { | ||
| + NOTES: DurableObjectNamespace<NoteRoom>; | ||
| +} | ||
| + | ||
| +export default { | ||
| + async fetch(request, env): Promise<Response> { | ||
| + const r = route(new URL(request.url).pathname); | ||
| + if (!r) return new Response("not found", { status: 404 }); | ||
| + if (request.method !== "GET") return new Response("method not allowed", { status: 405, headers: { allow: "GET" } }); | ||
| + | ||
| + const room = env.NOTES.get(env.NOTES.idFromName(r.id)); | ||
| + if (r.kind === "socket") { | ||
| + if (request.headers.get("Upgrade") !== "websocket") return new Response("expected a WebSocket upgrade", { status: 426 }); | ||
| + return room.fetch(request); | ||
| + } | ||
| + const note = await room.read(); | ||
| + return Response.json({ id: r.id, text: note.text, version: note.version }); | ||
| + }, | ||
| +} satisfies ExportedHandler<Env>; | ||
Addedsrc/note-room.ts+96−0
| @@ -0,0 +1,96 @@ | ||
| +// NoteRoom: the Durable Object behind one note. It owns the note's storage (SQLite) and its | ||
| +// editors' WebSockets, and hands every edit to a Room (room.ts), which decides what happens. | ||
| +// | ||
| +// Sockets are accepted with the Hibernation API, so an idle note costs nothing: the runtime | ||
| +// keeps the connections open while the object is evicted, and wakes it with webSocketMessage | ||
| +// when a frame arrives. Everything the room needs lives in SQLite, so the constructor below is | ||
| +// also the wake-up path. | ||
| + | ||
| +import { DurableObject } from "cloudflare:workers"; | ||
| +import type { Op } from "./ops.ts"; | ||
| +import { LOG_SIZE, Room, type Note, type NoteStore } from "./room.ts"; | ||
| + | ||
| +export class NoteRoom extends DurableObject { | ||
| + #room: Room; | ||
| + | ||
| + constructor(ctx: DurableObjectState, env: Cloudflare.Env) { | ||
| + super(ctx, env); | ||
| + this.#room = new Room(new SqlNoteStore(ctx.storage)); | ||
| + } | ||
| + | ||
| + // GET /notes/:id, over RPC from the Worker. | ||
| + read(): Note { | ||
| + return this.#room.note; | ||
| + } | ||
| + | ||
| + // GET /notes/:id/ws: the Worker forwards the upgrade request here. | ||
| + async fetch(request: Request): Promise<Response> { | ||
| + if (request.headers.get("Upgrade") !== "websocket") return new Response("expected a WebSocket upgrade", { status: 426 }); | ||
| + const { 0: client, 1: server } = new WebSocketPair(); | ||
| + this.ctx.acceptWebSocket(server); | ||
| + server.send(JSON.stringify(this.#room.snapshot())); | ||
| + return new Response(null, { status: 101, webSocket: client }); | ||
| + } | ||
| + | ||
| + async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void> { | ||
| + if (typeof data !== "string") { | ||
| + ws.send(JSON.stringify({ type: "error", message: "frames are JSON text" })); | ||
| + return; | ||
| + } | ||
| + const out = this.#room.receive(data); | ||
| + ws.send(JSON.stringify(out.reply)); | ||
| + if (!out.broadcast) return; | ||
| + const frame = JSON.stringify(out.broadcast); | ||
| + for (const other of this.ctx.getWebSockets()) { | ||
| + if (other === ws) continue; | ||
| + try { | ||
| + other.send(frame); | ||
| + } catch { | ||
| + // The socket is closing; webSocketClose will follow. | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + async webSocketClose(ws: WebSocket, code: number): Promise<void> { | ||
| + // 1005 and 1006 report that no close code was sent; they can't be sent back. | ||
| + ws.close(code === 1005 || code === 1006 ? 1000 : code); | ||
| + } | ||
| +} | ||
| + | ||
| +// The note's row and its op log in the object's SQLite database. | ||
| +class SqlNoteStore implements NoteStore { | ||
| + #storage: DurableObjectStorage; | ||
| + | ||
| + constructor(storage: DurableObjectStorage) { | ||
| + this.#storage = storage; | ||
| + storage.sql.exec(` | ||
| + CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY CHECK (id = 1), text TEXT NOT NULL, version INTEGER NOT NULL); | ||
| + CREATE TABLE IF NOT EXISTS ops (version INTEGER PRIMARY KEY, op TEXT NOT NULL); | ||
| + `); | ||
| + } | ||
| + | ||
| + load(): Note { | ||
| + const row = this.#storage.sql.exec<{ text: string; version: number }>("SELECT text, version FROM note WHERE id = 1").toArray()[0]; | ||
| + return row ? { text: row.text, version: row.version } : { text: "", version: 0 }; | ||
| + } | ||
| + | ||
| + opsSince(version: number): Op[] { | ||
| + return this.#storage.sql | ||
| + .exec<{ op: string }>("SELECT op FROM ops WHERE version > ? ORDER BY version", version) | ||
| + .toArray() | ||
| + .map((r) => JSON.parse(r.op) as Op); | ||
| + } | ||
| + | ||
| + commit(note: Note, op: Op): void { | ||
| + this.#storage.transactionSync(() => { | ||
| + const sql = this.#storage.sql; | ||
| + sql.exec( | ||
| + "INSERT INTO note (id, text, version) VALUES (1, ?, ?) ON CONFLICT (id) DO UPDATE SET text = excluded.text, version = excluded.version", | ||
| + note.text, | ||
| + note.version, | ||
| + ); | ||
| + sql.exec("INSERT INTO ops (version, op) VALUES (?, ?)", note.version, JSON.stringify(op)); | ||
| + sql.exec("DELETE FROM ops WHERE version <= ?", note.version - LOG_SIZE); | ||
| + }); | ||
| + } | ||
| +} | ||
Addedsrc/routes.ts+12−0
| @@ -0,0 +1,12 @@ | ||
| +// The Worker's routes, kept apart from index.ts so they can be tested without the runtime. | ||
| + | ||
| +export type Route = { kind: "note"; id: string } | { kind: "socket"; id: string } | null; | ||
| + | ||
| +// Note ids are what people put in a link: letters, digits, dashes and underscores. | ||
| +const NOTE = /^\/notes\/([A-Za-z0-9_-]{1,64})(\/ws)?$/; | ||
| + | ||
| +export function route(pathname: string): Route { | ||
| + const m = NOTE.exec(pathname); | ||
| + if (!m) return null; | ||
| + return m[2] ? { kind: "socket", id: m[1] } : { kind: "note", id: m[1] }; | ||
| +} | ||
Addedtest/routes.test.ts+14−0
| @@ -0,0 +1,14 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { test } from "node:test"; | ||
| +import { route } from "../src/routes.ts"; | ||
| + | ||
| +test("routes a note and its socket", () => { | ||
| + assert.deepEqual(route("/notes/groceries"), { kind: "note", id: "groceries" }); | ||
| + assert.deepEqual(route("/notes/team_standup-42/ws"), { kind: "socket", id: "team_standup-42" }); | ||
| +}); | ||
| + | ||
| +test("refuses other paths and odd ids", () => { | ||
| + for (const p of ["/", "/notes", "/notes/", "/notes/a/b", "/notes/a%20b", "/notes/../x", `/notes/${"a".repeat(65)}`, "/notes/a/ws/x"]) { | ||
| + assert.equal(route(p), null, p); | ||
| + } | ||
| +}); | ||
Addedwrangler.jsonc+12−0
| @@ -0,0 +1,12 @@ | ||
| +// live-notes: one Worker, and one NoteRoom Durable Object per note. | ||
| +{ | ||
| + "$schema": "node_modules/wrangler/config-schema.json", | ||
| + "name": "live-notes", | ||
| + "main": "src/index.ts", | ||
| + "compatibility_date": "2026-09-01", | ||
| + "observability": { "enabled": true }, | ||
| + "durable_objects": { | ||
| + "bindings": [{ "name": "NOTES", "class_name": "NoteRoom" }] | ||
| + }, | ||
| + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["NoteRoom"] }] | ||
| +} | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.