Rebasely Beta: data may be purged

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.
4343feb5776d692d7534683aa2ac273505ac54c0
Parent 2c15282 Browse filesHistory

Changed files

6 files changed166 additions1 deletionagainst 2c15282

  1. Changedpackage.json+2−1
  2. Addedsrc/index.ts+30−0
  3. Addedsrc/note-room.ts+96−0
  4. Addedsrc/routes.ts+12−0
  5. Addedtest/routes.test.ts+14−0
  6. Addedwrangler.jsonc+12−0
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",
View file
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>;
View file
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);
+ });
+ }
+}
View file
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] };
+}
View file
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);
+ }
+});
View file
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"] }]
+}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.