examples/live-notes Public
live-notes
Plain-text notes that several people can edit at the same time, on Cloudflare Workers and
Durable Objects. Open /#groceries in two browsers and type in both: each sees the other's
edits as they happen, and both end up with the same text.
How it works
Every note is one Durable Object, NoteRoom, found by name. Everyone editing "groceries"
connects to the same object over a WebSocket, and that object is the only place where edits to
the note are put in order.
Editors do not send the whole text. They send operations (insert some text at an offset, or delete some characters at an offset) together with the version of the note they were looking at. If other edits landed in the meantime, the room moves the operation past them with a small transform function, applies it, saves it, and sends it on to the other editors. Each editor does the same transform on its side for edits it has not had acknowledged yet, so its caret stays where it was while others type.
The room keeps the note and its last 200 operations in the Durable Object's SQLite database and accepts sockets with the Hibernation API. A note nobody is typing in is evicted from memory without closing anyone's connection, and wakes up on the next keystroke.
Running it locally
You need Node 24.
npm install
npm test # the transform, the room and simulated editors
npm run typecheck # the Worker and the page's script
npm run dev # builds the page script, then runs wrangler dev on :8787
Then open http://localhost:8787/#groceries in two windows.
API
| Request | Response |
|---|---|
GET / |
the editor page; the part after # is the note id |
GET /notes/:id |
the note as JSON: { "id", "text", "version" } |
GET /notes/:id/ws |
a WebSocket to the note's room |
Note ids are 1 to 64 letters, digits, dashes or underscores. On the socket, the editor sends
{ "type": "op", "base": 41, "op": { "type": "insert", "pos": 4, "text": "oat " } }
and the room answers with snapshot (on connect, or when it can't use an op), ack (your op
is in, at this version) and op (someone else's edit, at this version).
Conflicts in brief
- The room is the single sequencer for its note. It handles one message at a time, so there is always an answer to "which edit came first".
- An op written against an older version is transformed past the ops it missed, in order.
- Two inserts at the same offset: the one the room saw first goes first.
- Overlapping deletes remove the text once.
- Typing inside text that someone else deleted at the same moment is deleted along with it. This keeps every transform to a single op.
- An editor more than 200 versions behind gets a fresh snapshot and drops its unsent edits.
The knowledge base in .rebasely/kb explains each of these and why we chose them.
Layout
src/
index.ts the Worker: routes /notes/:id to its NoteRoom
routes.ts path matching for the Worker
note-room.ts the Durable Object: sockets, hibernation, SQLite
room.ts the sequencer, with storage behind an interface
ops.ts insert, delete, transform, compose, diff
protocol.ts the socket messages and their validation
client/
session.ts one editor's state: pending ops, remote ops, caret
editor.ts the page script, compiled to public/js
public/
index.html the editor page
test/ node --test, no Workers runtime needed
.rebasely/ architecture, flows, knowledge base and CI
License
MIT