examples/live-notes Public
Add the editor page
A single page with a textarea, served as a static asset. Its script keeps one op in flight and queues the rest, and moves remote ops past its own pending edits the way the room does, so the caret stays put while others type. The session logic has no DOM in it; the tests run three editors against a room with random delivery and check they all end on the room's text. tsc compiles the script to public/js, and wrangler runs that build before dev.
Changed files
8 files changed347 additions2 deletionsagainst 4343feb
Changed.gitignore+1−0
| @@ -1,3 +1,4 @@ | ||
| node_modules/ | ||
| .wrangler/ | ||
| .dev.vars | ||
| +public/js/ | ||
Addedclient/editor.ts+72−0
| @@ -0,0 +1,72 @@ | ||
| +// The editor page: a textarea bound to one note over a WebSocket. The note id comes from the | ||
| +// URL fragment, so /#groceries edits the note "groceries". | ||
| + | ||
| +import { Session, moveCaret } from "./session.ts"; | ||
| +import type { ServerMessage } from "../src/protocol.ts"; | ||
| + | ||
| +const NOTE_ID = /^[A-Za-z0-9_-]{1,64}$/; | ||
| + | ||
| +const area = document.querySelector<HTMLTextAreaElement>("#note")!; | ||
| +const status = document.querySelector<HTMLElement>("#status")!; | ||
| +const title = document.querySelector<HTMLElement>("#title")!; | ||
| + | ||
| +function noteId(): string { | ||
| + const id = decodeURIComponent(location.hash.slice(1)); | ||
| + return NOTE_ID.test(id) ? id : "scratch"; | ||
| +} | ||
| + | ||
| +let socket: WebSocket | null = null; | ||
| +let retry = 0; | ||
| + | ||
| +function connect(): void { | ||
| + const id = noteId(); | ||
| + title.textContent = id; | ||
| + document.title = `${id} · live-notes`; | ||
| + area.disabled = true; | ||
| + status.textContent = "Connecting"; | ||
| + | ||
| + const scheme = location.protocol === "https:" ? "wss" : "ws"; | ||
| + const ws = new WebSocket(`${scheme}://${location.host}/notes/${encodeURIComponent(id)}/ws`); | ||
| + const session = new Session((msg) => ws.send(JSON.stringify(msg))); | ||
| + socket = ws; | ||
| + | ||
| + ws.addEventListener("message", (event) => { | ||
| + const msg = JSON.parse(String(event.data)) as ServerMessage; | ||
| + const { selectionStart, selectionEnd } = area; | ||
| + const applied = session.receive(msg); | ||
| + if (applied === "reset") { | ||
| + area.value = session.text; | ||
| + area.disabled = false; | ||
| + area.focus(); | ||
| + retry = 0; | ||
| + status.textContent = `Version ${session.version}`; | ||
| + } else if (applied) { | ||
| + area.value = session.text; | ||
| + area.setSelectionRange(moveCaret(selectionStart, applied), moveCaret(selectionEnd, applied)); | ||
| + } | ||
| + if (msg.type !== "snapshot") status.textContent = session.pending ? "Saving" : `Version ${session.version}`; | ||
| + }); | ||
| + | ||
| + area.oninput = () => { | ||
| + session.edit(area.value); | ||
| + status.textContent = "Saving"; | ||
| + }; | ||
| + | ||
| + ws.addEventListener("close", () => { | ||
| + if (socket !== ws) return; | ||
| + area.disabled = true; | ||
| + // Back off to at most 10 seconds between attempts. | ||
| + const wait = Math.min(10_000, 500 * 2 ** retry++); | ||
| + status.textContent = "Offline, reconnecting"; | ||
| + setTimeout(connect, wait); | ||
| + }); | ||
| +} | ||
| + | ||
| +window.addEventListener("hashchange", () => { | ||
| + const old = socket; | ||
| + socket = null; | ||
| + old?.close(); | ||
| + connect(); | ||
| +}); | ||
| + | ||
| +connect(); | ||
Addedclient/session.ts+91−0
| @@ -0,0 +1,91 @@ | ||
| +// One editor's side of a note, without the DOM: what it shows, which version of the room it has | ||
| +// seen, and its own edits the room has not acknowledged yet. | ||
| +// | ||
| +// The editor keeps at most one op in flight. Further edits wait in a queue until the room acks | ||
| +// the one in flight, so every op it sends is written against a version the room has. When | ||
| +// someone else's op arrives, the session moves it past the edits still pending here, and moves | ||
| +// those edits past it, the same way the room will. | ||
| + | ||
| +import { apply, diff, isNoop, transform, type Op } from "../src/ops.ts"; | ||
| +import type { ClientMessage, ServerMessage } from "../src/protocol.ts"; | ||
| + | ||
| +export class Session { | ||
| + // The room's text with this editor's pending ops applied: what the textarea shows. | ||
| + text = ""; | ||
| + // The last room version this editor has seen. | ||
| + version = 0; | ||
| + #inflight: Op | null = null; | ||
| + #queue: Op[] = []; | ||
| + #send: (msg: ClientMessage) => void; | ||
| + | ||
| + constructor(send: (msg: ClientMessage) => void) { | ||
| + this.#send = send; | ||
| + } | ||
| + | ||
| + // Edits made here that the room has not acknowledged. | ||
| + get pending(): number { | ||
| + return (this.#inflight ? 1 : 0) + this.#queue.length; | ||
| + } | ||
| + | ||
| + // The user changed the text to `next`. | ||
| + edit(next: string): void { | ||
| + for (const op of diff(this.text, next)) { | ||
| + this.text = apply(this.text, op); | ||
| + this.#queue.push(op); | ||
| + } | ||
| + this.#flush(); | ||
| + } | ||
| + | ||
| + // Handles a frame from the room. Returns the op it applied to `text` for a remote edit (so | ||
| + // the caller can keep the caret in place), "reset" when the text was replaced, else null. | ||
| + receive(msg: ServerMessage): Op | "reset" | null { | ||
| + switch (msg.type) { | ||
| + case "snapshot": | ||
| + // Pending edits were written against a text the room no longer has; they are dropped. | ||
| + this.text = msg.text; | ||
| + this.version = msg.version; | ||
| + this.#inflight = null; | ||
| + this.#queue = []; | ||
| + return "reset"; | ||
| + case "ack": | ||
| + this.version = msg.version; | ||
| + this.#inflight = null; | ||
| + this.#flush(); | ||
| + return null; | ||
| + case "op": { | ||
| + this.version = msg.version; | ||
| + let remote = msg.op; | ||
| + if (this.#inflight) { | ||
| + const mine = transform(this.#inflight, remote, true); | ||
| + remote = transform(remote, this.#inflight, false); | ||
| + this.#inflight = mine; | ||
| + } | ||
| + this.#queue = this.#queue.map((op) => { | ||
| + const mine = transform(op, remote, true); | ||
| + remote = transform(remote, op, false); | ||
| + return mine; | ||
| + }); | ||
| + this.text = apply(this.text, remote); | ||
| + return remote; | ||
| + } | ||
| + case "error": | ||
| + return null; | ||
| + } | ||
| + } | ||
| + | ||
| + #flush(): void { | ||
| + if (this.#inflight) return; | ||
| + this.#queue = this.#queue.filter((op) => !isNoop(op)); | ||
| + const next = this.#queue.shift(); | ||
| + if (!next) return; | ||
| + this.#inflight = next; | ||
| + this.#send({ type: "op", base: this.version, op: next }); | ||
| + } | ||
| +} | ||
| + | ||
| +// Where a caret at `pos` ends up after `op`. Text inserted right at the caret goes after it. | ||
| +export function moveCaret(pos: number, op: Op): number { | ||
| + if (op.type === "insert") return op.pos < pos ? pos + op.text.length : pos; | ||
| + if (pos <= op.pos) return pos; | ||
| + return Math.max(op.pos, pos - op.count); | ||
| +} | ||
Changedpackage.json+2−1
| @@ -10,7 +10,8 @@ | ||
| }, | ||
| "scripts": { | ||
| "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"", | ||
| - "typecheck": "tsc", | ||
| + "typecheck": "tsc && tsc -p tsconfig.client.json --noEmit", | ||
| + "build": "tsc -p tsconfig.client.json", | ||
| "dev": "npx wrangler dev" | ||
| }, | ||
| "devDependencies": { | ||
Addedpublic/index.html+32−0
| @@ -0,0 +1,32 @@ | ||
| +<!doctype html> | ||
| +<html lang="en"> | ||
| +<head> | ||
| +<meta charset="utf-8"> | ||
| +<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| +<title>live-notes</title> | ||
| +<style> | ||
| + :root { color-scheme: light dark; --fg: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7; --bg: #fff; } | ||
| + @media (prefers-color-scheme: dark) { :root { --fg: #f5f5f7; --muted: #a1a1a6; --line: #424245; --bg: #1c1c1e; } } | ||
| + body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; } | ||
| + main { max-width: 760px; margin: 0 auto; padding: 24px 16px; } | ||
| + header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; } | ||
| + h1 { font-size: 20px; font-weight: 600; margin: 0; } | ||
| + #status { color: var(--muted); font-size: 13px; } | ||
| + textarea { box-sizing: border-box; width: 100%; min-height: 70vh; padding: 12px; border: 1px solid var(--line); | ||
| + border-radius: 8px; background: transparent; color: inherit; font: 14px/1.6 ui-monospace, monospace; resize: vertical; } | ||
| + textarea:disabled { opacity: 0.6; } | ||
| + footer { margin-top: 12px; color: var(--muted); font-size: 13px; } | ||
| +</style> | ||
| +</head> | ||
| +<body> | ||
| +<main> | ||
| + <header> | ||
| + <h1 id="title">scratch</h1> | ||
| + <span id="status">Connecting</span> | ||
| + </header> | ||
| + <textarea id="note" spellcheck="false" disabled aria-label="Note text"></textarea> | ||
| + <footer>Change the part after # in the address to open another note. Anyone with the same address edits the same text.</footer> | ||
| +</main> | ||
| +<script type="module" src="/js/client/editor.js"></script> | ||
| +</body> | ||
| +</html> | ||
Addedtest/session.test.ts+132−0
| @@ -0,0 +1,132 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { describe, test } from "node:test"; | ||
| +import { Session, moveCaret } from "../client/session.ts"; | ||
| +import { apply, del, insert } from "../src/ops.ts"; | ||
| +import type { ClientMessage, ServerMessage } from "../src/protocol.ts"; | ||
| +import { Room } from "../src/room.ts"; | ||
| +import { MemoryStore } from "./helpers/memory-store.ts"; | ||
| +import { randomOp, rng } from "./helpers/random.ts"; | ||
| + | ||
| +// A room and some editors joined by in-order queues, delivered whenever the test says so. | ||
| +class Network { | ||
| + room = new Room(new MemoryStore()); | ||
| + editors: { session: Session; up: ClientMessage[]; down: ServerMessage[] }[] = []; | ||
| + | ||
| + join(): Session { | ||
| + const up: ClientMessage[] = []; | ||
| + const session = new Session((msg) => up.push(msg)); | ||
| + session.receive(this.room.snapshot()); | ||
| + this.editors.push({ session, up, down: [] }); | ||
| + return session; | ||
| + } | ||
| + | ||
| + // Delivers the oldest frame from editor `i` to the room. | ||
| + deliverUp(i: number): void { | ||
| + const from = this.editors[i]; | ||
| + const msg = from.up.shift(); | ||
| + if (!msg) return; | ||
| + const out = this.room.receive(JSON.stringify(msg)); | ||
| + from.down.push(out.reply); | ||
| + if (out.broadcast) for (const e of this.editors) if (e !== from) e.down.push(out.broadcast); | ||
| + } | ||
| + | ||
| + // Delivers the oldest frame from the room to editor `i`. | ||
| + deliverDown(i: number): void { | ||
| + const msg = this.editors[i].down.shift(); | ||
| + if (msg) this.editors[i].session.receive(msg); | ||
| + } | ||
| + | ||
| + drain(): void { | ||
| + while (this.editors.some((e) => e.up.length || e.down.length)) { | ||
| + for (let i = 0; i < this.editors.length; i++) { | ||
| + this.deliverUp(i); | ||
| + this.deliverDown(i); | ||
| + } | ||
| + } | ||
| + } | ||
| +} | ||
| + | ||
| +describe("Session", () => { | ||
| + test("sends one op at a time and the next after the ack", () => { | ||
| + const net = new Network(); | ||
| + const a = net.join(); | ||
| + a.edit("h"); | ||
| + a.edit("hi"); | ||
| + assert.equal(net.editors[0].up.length, 1); | ||
| + assert.equal(a.pending, 2); | ||
| + net.deliverUp(0); | ||
| + net.deliverDown(0); | ||
| + assert.deepEqual(net.editors[0].up, [{ type: "op", base: 1, op: insert(1, "i") }]); | ||
| + net.drain(); | ||
| + assert.equal(a.pending, 0); | ||
| + assert.deepEqual(net.room.note, { text: "hi", version: 2 }); | ||
| + }); | ||
| + | ||
| + test("moves a remote op past its own pending edit", () => { | ||
| + const net = new Network(); | ||
| + const a = net.join(); | ||
| + const b = net.join(); | ||
| + a.edit("milk"); | ||
| + net.drain(); | ||
| + // Both type at once: a at the start, b at the end. | ||
| + a.edit("oat milk"); | ||
| + b.edit("milk!"); | ||
| + net.deliverUp(1); | ||
| + net.deliverUp(0); | ||
| + // a learns of b's edit before its own ack. | ||
| + net.deliverDown(0); | ||
| + assert.equal(a.text, "oat milk!"); | ||
| + net.drain(); | ||
| + assert.equal(b.text, "oat milk!"); | ||
| + assert.equal(net.room.note.text, "oat milk!"); | ||
| + }); | ||
| + | ||
| + test("a snapshot replaces the text and drops pending edits", () => { | ||
| + const a = new Session(() => {}); | ||
| + a.edit("draft"); | ||
| + assert.equal(a.receive({ type: "snapshot", text: "saved", version: 9 }), "reset"); | ||
| + assert.equal(a.text, "saved"); | ||
| + assert.equal(a.version, 9); | ||
| + assert.equal(a.pending, 0); | ||
| + }); | ||
| + | ||
| + test("editors typing at random converge on the room's text", () => { | ||
| + for (let seed = 1; seed <= 40; seed++) { | ||
| + const next = rng(seed); | ||
| + const net = new Network(); | ||
| + const sessions = [net.join(), net.join(), net.join()]; | ||
| + for (let step = 0; step < 300; step++) { | ||
| + const i = Math.floor(next() * sessions.length); | ||
| + const roll = next(); | ||
| + if (roll < 0.4) sessions[i].edit(apply(sessions[i].text, randomOp(sessions[i].text, next))); | ||
| + else if (roll < 0.7) net.deliverUp(i); | ||
| + else net.deliverDown(i); | ||
| + } | ||
| + net.drain(); | ||
| + for (const s of sessions) { | ||
| + assert.equal(s.text, net.room.note.text, `seed ${seed}`); | ||
| + assert.equal(s.version, net.room.note.version, `seed ${seed}`); | ||
| + } | ||
| + } | ||
| + }); | ||
| +}); | ||
| + | ||
| +describe("moveCaret", () => { | ||
| + test("stays put for edits after it", () => { | ||
| + assert.equal(moveCaret(3, insert(5, "abc")), 3); | ||
| + assert.equal(moveCaret(3, del(3, 2)), 3); | ||
| + }); | ||
| + | ||
| + test("moves with edits before it", () => { | ||
| + assert.equal(moveCaret(5, insert(1, "ab")), 7); | ||
| + assert.equal(moveCaret(5, del(1, 2)), 3); | ||
| + }); | ||
| + | ||
| + test("lands at the start of a deleted range it was inside", () => { | ||
| + assert.equal(moveCaret(5, del(2, 6)), 2); | ||
| + }); | ||
| + | ||
| + test("text inserted at the caret goes after it", () => { | ||
| + assert.equal(moveCaret(4, insert(4, "x")), 4); | ||
| + }); | ||
| +}); | ||
Addedtsconfig.client.json+13−0
| @@ -0,0 +1,13 @@ | ||
| +{ | ||
| + // The editor page's script: compiled to public/js and served as static assets. | ||
| + "extends": "./tsconfig.json", | ||
| + "compilerOptions": { | ||
| + "lib": ["es2023", "dom", "dom.iterable"], | ||
| + "types": [], | ||
| + "noEmit": false, | ||
| + "rewriteRelativeImportExtensions": true, | ||
| + "rootDir": ".", | ||
| + "outDir": "public/js" | ||
| + }, | ||
| + "include": ["client"] | ||
| +} | ||
Changedwrangler.jsonc+4−1
| @@ -1,10 +1,13 @@ | ||
| -// live-notes: one Worker, and one NoteRoom Durable Object per note. | ||
| +// live-notes: one Worker, and one NoteRoom Durable Object per note. The editor page is a | ||
| +// static asset; `npm run build` compiles its script into public/js first. | ||
| { | ||
| "$schema": "node_modules/wrangler/config-schema.json", | ||
| "name": "live-notes", | ||
| "main": "src/index.ts", | ||
| "compatibility_date": "2026-09-01", | ||
| "observability": { "enabled": true }, | ||
| + "build": { "command": "npm run build", "watch_dir": ["client", "src"] }, | ||
| + "assets": { "directory": "public" }, | ||
| "durable_objects": { | ||
| "bindings": [{ "name": "NOTES", "class_name": "NoteRoom" }] | ||
| }, | ||
Checks
No checks reported for this commit.
Runs
No runs for this commit.