Rebasely Beta: data may be purged

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.
dcc2f4a63191fe6d596e765d123be231d63e17c1
Parent 4343feb Browse filesHistory

Changed files

8 files changed347 additions2 deletionsagainst 4343feb

  1. Changed.gitignore+1−0
  2. Addedclient/editor.ts+72−0
  3. Addedclient/session.ts+91−0
  4. Changedpackage.json+2−1
  5. Addedpublic/index.html+32−0
  6. Addedtest/session.test.ts+132−0
  7. Addedtsconfig.client.json+13−0
  8. Changedwrangler.jsonc+4−1
Changed.gitignore+1−0
@@ -1,3 +1,4 @@
node_modules/
.wrangler/
.dev.vars
+public/js/
View file
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();
View file
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);
+}
View file
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": {
View file
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>
View file
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);
+ });
+});
View file
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"]
+}
View file
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" }]
},
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.