examples/live-notes Public
Show who else is editing #1
Changed.rebasely/architecture/flows/edit-broadcast.yaml+2−2
| @@ -1,10 +1,10 @@ | ||
| title: An edit reaching other editors | ||
| -description: Editor A types while editor B has the same note open. The room puts the edit in order, saves it and passes it on. | ||
| +description: Editor A (Otter) types while editor B (Heron) has the same note open. The room puts the edit in order, saves it and passes it on with who made it. | ||
| actors: [editor-a, editor-b] | ||
| steps: | ||
| - { from: editor-a, to: note-room, label: "op: insert \"oat \" at 4, written against version 41" } | ||
| - { from: note-room, to: ops, label: transform the op past versions 42 and 43 from the log } | ||
| - { from: ops, to: note-room, label: "the op as it applies now, which the room saves to SQLite" } | ||
| - { from: note-room, to: editor-a, label: "ack: version 44" } | ||
| - - { from: note-room, to: editor-b, label: the transformed op at version 44 } | ||
| + - { from: note-room, to: editor-b, label: "the transformed op at version 44, marked as Otter's" } | ||
| - { from: editor-b, to: ops, label: "move the op past B's own unacknowledged edits, then apply it" } | ||
Added.rebasely/architecture/flows/leave-note.yaml+6−0
| @@ -0,0 +1,6 @@ | ||
| +title: Leaving a note | ||
| +description: An editor closes the tab. The room tells everyone still connected who is left. | ||
| +actors: [editor-a, editor-b] | ||
| +steps: | ||
| + - { from: editor-a, to: note-room, label: the socket closes with the tab } | ||
| + - { from: note-room, to: editor-b, label: "presence: only Heron is here now" } | ||
Changed.rebasely/architecture/flows/load-note.yaml+5−3
| @@ -1,9 +1,11 @@ | ||
| title: Loading a note | ||
| -description: Someone opens /#groceries. The page connects to the note's room and gets its text. | ||
| -actors: [editor-a] | ||
| +description: Someone opens /#groceries while another editor has it open. The page connects to the note's room, gets its text, and both pages learn who is here. | ||
| +actors: [editor-a, editor-b] | ||
| steps: | ||
| - { from: editor-a, to: api, label: "GET / (the editor page and its script, from static assets)" } | ||
| - { from: editor-a, to: api, label: "GET /notes/groceries/ws, Upgrade: websocket" } | ||
| - { from: api, to: note-room, label: "forward the upgrade to the room for idFromName(\"groceries\")" } | ||
| - - { from: note-room, to: editor-a, label: "101 Switching Protocols, socket accepted for hibernation" } | ||
| + - { from: note-room, to: editor-a, label: "101 Switching Protocols, with a name and color kept on the socket" } | ||
| - { from: note-room, to: editor-a, label: "snapshot: the note's text and version" } | ||
| + - { from: note-room, to: editor-a, label: "presence: you are Otter, and Heron is here" } | ||
| + - { from: note-room, to: editor-b, label: "presence: Heron and Otter are here" } | ||
Changed.rebasely/architecture/system.yaml+2−2
| @@ -7,9 +7,9 @@ | ||
| - { to: note-room, via: rpc } | ||
| note-room: | ||
| kind: durable-object | ||
| - description: One per note, picked by name. Orders every edit, keeps the note and its op log in SQLite, and holds the editors' WebSockets. | ||
| + description: One per note, picked by name. Orders every edit, keeps the note and its op log in SQLite, and holds the editors' WebSockets along with who is on each one. | ||
| binding: NOTES | ||
| - owns: [src/note-room.ts, src/room.ts] | ||
| + owns: [src/note-room.ts, src/room.ts, src/presence.ts] | ||
| talks_to: | ||
| - { to: ops, via: import } | ||
| ops: | ||
Changed.rebasely/kb/concepts/conflict-strategy.md+3−2
| @@ -2,7 +2,7 @@ | ||
| id: conflict-strategy | ||
| type: concept | ||
| owner: justin | ||
| -reviewed: 2026-10-14 | ||
| +reviewed: 2026-10-15 | ||
| component: ops | ||
| describes: | ||
| - src/ops.ts | ||
| @@ -21,7 +21,8 @@ | ||
| ([[note-room]]) is the only place ops are ordered ([[0001-single-sequencer-per-note]]). When an | ||
| op arrives written against version 41 and the room is at 43, the room takes ops 42 and 43 from | ||
| its log and moves the new op past each of them with `transform()`. It applies the result, saves | ||
| -it as version 44, acks the sender and sends the transformed op to everyone else. | ||
| +it as version 44, acks the sender and sends the transformed op to everyone else, marked with | ||
| +the id of the editor who made it. | ||
| Because the room handles one message at a time, there is never a question of which op came | ||
| first: the one with the lower version. | ||
Changed.rebasely/kb/concepts/note-room.md+6−1
| @@ -2,7 +2,7 @@ | ||
| id: note-room | ||
| type: concept | ||
| owner: justin | ||
| -reviewed: 2026-10-14 | ||
| +reviewed: 2026-10-15 | ||
| component: note-room | ||
| describes: | ||
| - src/note-room.ts | ||
| @@ -45,6 +45,11 @@ | ||
| Broadcasting uses `ctx.getWebSockets()`, which lists every accepted socket even after a wake, | ||
| so there is no connection list to rebuild. | ||
| + | ||
| +Each socket also carries its editor (an id, a name and a color) as a serialized attachment, | ||
| +set right after `acceptWebSocket`. Attachments survive hibernation, so after a wake the room | ||
| +can still say who sent a frame and who is here without reading storage. [[presence]] covers | ||
| +the rest. | ||
| ## Reads without a socket | ||
Added.rebasely/kb/concepts/presence.md+51−0
| @@ -0,0 +1,51 @@ | ||
| +--- | ||
| +id: presence | ||
| +type: concept | ||
| +owner: justin | ||
| +reviewed: 2026-10-15 | ||
| +component: note-room | ||
| +describes: | ||
| + - src/presence.ts | ||
| + - src/note-room.ts | ||
| + - client/editor.ts | ||
| +--- | ||
| +# Presence | ||
| + | ||
| +The room knows who has each note open. It gives every editor a name and a color, tells | ||
| +everyone when someone joins or leaves, and marks each op it passes on with who made it. | ||
| + | ||
| +## Who is here | ||
| + | ||
| +When a socket connects, the room lists the editors on the sockets already open and picks a | ||
| +name and a color nobody there is using (`pickEditor()`). Names come from a fixed list of | ||
| +twelve animals; past twelve editors they repeat with a number. The editor is stored on the | ||
| +socket with `serializeAttachment`, and the room sends: | ||
| + | ||
| +- to the newcomer, after the snapshot: `presence` with everyone here and `you` set to itself; | ||
| +- to everyone else: `presence` with the new list. | ||
| + | ||
| +When a socket closes or errors, the room sends the remaining list to everyone left. Every | ||
| +`presence` message carries the whole list, so an editor that misses one is corrected by the | ||
| +next, and the page never has to apply joins and leaves in order. | ||
| + | ||
| +The id is random per connection. The same person in two tabs is two editors, and a reload | ||
| +gets a new name if the old one was taken in the meantime. | ||
| + | ||
| +## Why nothing is stored | ||
| + | ||
| +The list of who is here is the list of open sockets, by definition. Writing it to SQLite would | ||
| +add a second copy that can disagree with the first after a crash or a deploy. See | ||
| +[[0003-presence-lives-on-the-sockets]]. | ||
| + | ||
| +## On the page | ||
| + | ||
| +`client/editor.ts` keeps the latest `here` list and renders it above the textarea, a colored | ||
| +dot and a name per editor, with "(you)" on its own entry. When an `op` arrives, the page | ||
| +looks `by` up in that list and shows "typing" next to the name for a second and a half. While | ||
| +the socket is down the list is cleared, since the page no longer knows. | ||
| + | ||
| +## Who made an edit | ||
| + | ||
| +`op` broadcasts carry `by`, the id of the sender, read from the sending socket's attachment. | ||
| +The room does not check `by` against | ||
| +anything: it comes from the room itself, never from the editor. | ||
Changed.rebasely/kb/concepts/wire-protocol.md+7−2
| @@ -2,7 +2,7 @@ | ||
| id: wire-protocol | ||
| type: concept | ||
| owner: justin | ||
| -reviewed: 2026-10-14 | ||
| +reviewed: 2026-10-15 | ||
| component: ops | ||
| describes: | ||
| - src/protocol.ts | ||
| @@ -25,11 +25,16 @@ | ||
| | ---------- | --------------------------- | --------------------------------------------------- | | ||
| | `snapshot` | `text`, `version`, `reason` | on connect, and when an editor's op can't be used | | ||
| | `ack` | `version` | your op was applied, or turned out to change nothing | | ||
| -| `op` | `version`, `op` | someone else's op, already transformed | | ||
| +| `op` | `version`, `op`, `by` | someone else's op, already transformed | | ||
| +| `presence` | `here`, `you` | someone joined or left; `you` only to the newcomer | | ||
| | `error` | `message` | the frame was not an op message | | ||
| `reason` is only set when the room refused an op the editor had already applied, for example | ||
| one that would take the note past 100,000 characters. The page shows it in the status line. | ||
| + | ||
| +`by` is the editor id of whoever made the op, and `here` lists editors as | ||
| +`{ "id", "name", "color" }`, oldest connection first. Editors never send presence; the room | ||
| +works it out from the sockets ([[presence]]). | ||
| `parseClientMessage()` checks every field and copies only the ones it knows, so a frame with | ||
| extra fields still works and a malformed one is answered with `error` and changes nothing. | ||
Added.rebasely/kb/decisions/0003-presence-lives-on-the-sockets.md+36−0
| @@ -0,0 +1,36 @@ | ||
| +--- | ||
| +id: 0003-presence-lives-on-the-sockets | ||
| +type: decision | ||
| +owner: justin | ||
| +reviewed: 2026-10-15 | ||
| +status: accepted | ||
| +--- | ||
| +# 0003 Presence lives on the sockets | ||
| + | ||
| +## Context | ||
| + | ||
| +The page should show who else is editing. The room needs to know, for each open socket, which | ||
| +editor it belongs to, and it needs that after waking from hibernation, when its memory is | ||
| +gone. | ||
| + | ||
| +We considered three places to keep it: | ||
| + | ||
| +1. A `presence` table in the note's SQLite database, a row per connection. | ||
| +2. A field on the `NoteRoom` instance, rebuilt as sockets send frames. | ||
| +3. The socket attachment, which the Hibernation API keeps with each accepted socket. | ||
| + | ||
| +## Decision | ||
| + | ||
| +Each editor's id, name and color are the attachment of its socket. The list of who is here is | ||
| +read from `ctx.getWebSockets()` whenever it is needed, and is never written to storage. | ||
| + | ||
| +## Consequences | ||
| + | ||
| +- There is nothing to clean up. A socket that closes takes its editor with it, including | ||
| + after a crash or a deploy, where a stored row would have stayed behind. | ||
| +- After a wake the room knows everyone immediately; an in-memory field would have been empty | ||
| + until each editor sent something. | ||
| +- Reading the list touches every socket, which is fine at the size of a note's editors and | ||
| + would need another look for rooms with thousands of connections. | ||
| +- Presence is not history: nobody can ask who edited a note yesterday. If we want that, it | ||
| + belongs with the op log, not here. See [[presence]]. | ||
Changed.rebasely/kb/index.md+2−0
| @@ -8,11 +8,13 @@ | ||
| - [[conflict-strategy]]: how two people typing at once end up with the same text. | ||
| - [[note-room]]: the Durable Object behind a note, its storage, and hibernation. | ||
| - [[wire-protocol]]: the messages on the socket. | ||
| +- [[presence]]: who else has the note open, and how the room knows. | ||
| ## Decisions | ||
| - [[0001-single-sequencer-per-note]]: the room is the only place edits are ordered. | ||
| - [[0002-drop-inserts-inside-concurrent-deletes]]: the one rule that keeps the transform small. | ||
| +- [[0003-presence-lives-on-the-sockets]]: presence is never written to storage. | ||
| ## Guides | ||
ChangedREADME.md+6−1
| @@ -17,6 +17,9 @@ | ||
| 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 page lists who else has the note open, each with an animal name and a color, and shows | ||
| +when one of them is typing. The room works this out from its open sockets and never stores it. | ||
| + | ||
| 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. | ||
| @@ -49,7 +52,8 @@ | ||
| ``` | ||
| 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). | ||
| +is in, at this version), `op` (someone else's edit, at this version, with who made it) and | ||
| +`presence` (who is here, whenever someone joins or leaves). | ||
| ## Conflicts in brief | ||
| @@ -74,6 +78,7 @@ | ||
| room.ts the sequencer, with storage behind an interface | ||
| ops.ts insert, delete, transform, compose, diff | ||
| protocol.ts the socket messages and their validation | ||
| + presence.ts names and colors for the editors on a note | ||
| client/ | ||
| session.ts one editor's state: pending ops, remote ops, caret | ||
| editor.ts the page script, compiled to public/js | ||
Changedclient/editor.ts+36−1
| @@ -2,13 +2,14 @@ | ||
| // URL fragment, so /#groceries edits the note "groceries". | ||
| import { Session, moveCaret } from "./session.ts"; | ||
| -import type { ServerMessage } from "../src/protocol.ts"; | ||
| +import type { Editor, 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")!; | ||
| +const people = document.querySelector<HTMLUListElement>("#here")!; | ||
| function noteId(): string { | ||
| const id = decodeURIComponent(location.hash.slice(1)); | ||
| @@ -18,6 +19,31 @@ | ||
| let socket: WebSocket | null = null; | ||
| let retry = 0; | ||
| +// Who is here, from the room's latest presence message, and when each was last seen typing. | ||
| +let here: Editor[] = []; | ||
| +let me: string | null = null; | ||
| +const typing = new Map<string, number>(); | ||
| +const TYPING_MS = 1500; | ||
| + | ||
| +function renderPeople(): void { | ||
| + const now = Date.now(); | ||
| + people.replaceChildren( | ||
| + ...here.map((editor) => { | ||
| + const li = document.createElement("li"); | ||
| + li.style.setProperty("--color", editor.color); | ||
| + li.textContent = editor.id === me ? `${editor.name} (you)` : editor.name; | ||
| + if ((typing.get(editor.id) ?? 0) > now) li.classList.add("typing"); | ||
| + return li; | ||
| + }), | ||
| + ); | ||
| +} | ||
| + | ||
| +function sawTyping(id: string): void { | ||
| + typing.set(id, Date.now() + TYPING_MS); | ||
| + renderPeople(); | ||
| + setTimeout(renderPeople, TYPING_MS + 50); | ||
| +} | ||
| + | ||
| function connect(): void { | ||
| const id = noteId(); | ||
| title.textContent = id; | ||
| @@ -32,6 +58,12 @@ | ||
| ws.addEventListener("message", (event) => { | ||
| const msg = JSON.parse(String(event.data)) as ServerMessage; | ||
| + if (msg.type === "presence") { | ||
| + here = msg.here; | ||
| + if (msg.you) me = msg.you.id; | ||
| + renderPeople(); | ||
| + return; | ||
| + } | ||
| const { selectionStart, selectionEnd } = area; | ||
| const applied = session.receive(msg); | ||
| if (applied === "reset") { | ||
| @@ -43,6 +75,7 @@ | ||
| } else if (applied) { | ||
| area.value = session.text; | ||
| area.setSelectionRange(moveCaret(selectionStart, applied), moveCaret(selectionEnd, applied)); | ||
| + if (msg.type === "op") sawTyping(msg.by); | ||
| } | ||
| if (msg.type !== "snapshot") status.textContent = session.pending ? "Saving" : `Version ${session.version}`; | ||
| }); | ||
| @@ -55,6 +88,8 @@ | ||
| ws.addEventListener("close", () => { | ||
| if (socket !== ws) return; | ||
| area.disabled = true; | ||
| + here = []; | ||
| + renderPeople(); | ||
| // Back off to at most 10 seconds between attempts. | ||
| const wait = Math.min(10_000, 500 * 2 ** retry++); | ||
| status.textContent = "Offline, reconnecting"; | ||
Changedclient/session.ts+1−0
| @@ -73,6 +73,7 @@ | ||
| this.text = apply(this.text, remote); | ||
| return remote; | ||
| } | ||
| + case "presence": | ||
| case "error": | ||
| return null; | ||
| } | ||
Changedpublic/index.html+6−0
| @@ -12,6 +12,11 @@ | ||
| 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; } | ||
| + #here { display: flex; flex-wrap: wrap; gap: 6px 14px; list-style: none; margin: 0 0 12px; padding: 0; min-height: 20px; font-size: 13px; } | ||
| + #here li { display: flex; align-items: center; gap: 6px; color: var(--muted); } | ||
| + #here li::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--color); } | ||
| + #here li.typing { color: var(--fg); } | ||
| + #here li.typing::after { content: "typing"; color: var(--color); } | ||
| 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; } | ||
| @@ -24,6 +29,7 @@ | ||
| <h1 id="title">scratch</h1> | ||
| <span id="status">Connecting</span> | ||
| </header> | ||
| + <ul id="here" aria-label="Who is here" aria-live="polite"></ul> | ||
| <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> | ||
Changedsrc/note-room.ts+45−12
| @@ -4,10 +4,13 @@ | ||
| // 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. | ||
| +// also the wake-up path. Who is connected lives on the sockets themselves: each one carries | ||
| +// its editor's name and color as an attachment, which hibernation keeps. | ||
| import { DurableObject } from "cloudflare:workers"; | ||
| import type { Op } from "./ops.ts"; | ||
| +import { asEditor, joinMessages, pickEditor, presence } from "./presence.ts"; | ||
| +import type { Editor } from "./protocol.ts"; | ||
| import { LOG_SIZE, Room, type Note, type NoteStore } from "./room.ts"; | ||
| export class NoteRoom extends DurableObject { | ||
| @@ -27,8 +30,15 @@ | ||
| 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(); | ||
| + const here = this.#editors(); | ||
| + const editor = pickEditor(here, crypto.randomUUID().slice(0, 8)); | ||
| this.ctx.acceptWebSocket(server); | ||
| + server.serializeAttachment(editor); | ||
| + | ||
| + const { you, others } = joinMessages(here, editor); | ||
| server.send(JSON.stringify(this.#room.snapshot())); | ||
| + server.send(JSON.stringify(you)); | ||
| + this.#broadcast(JSON.stringify(others), server); | ||
| return new Response(null, { status: 101, webSocket: client }); | ||
| } | ||
| @@ -37,23 +47,46 @@ | ||
| ws.send(JSON.stringify({ type: "error", message: "frames are JSON text" })); | ||
| return; | ||
| } | ||
| - const out = this.#room.receive(data); | ||
| + const by = asEditor(ws.deserializeAttachment())?.id ?? ""; | ||
| + const out = this.#room.receive(data, by); | ||
| 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. | ||
| - } | ||
| - } | ||
| + if (out.broadcast) this.#broadcast(JSON.stringify(out.broadcast), ws); | ||
| } | ||
| 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); | ||
| + this.#left(ws); | ||
| + } | ||
| + | ||
| + async webSocketError(ws: WebSocket): Promise<void> { | ||
| + this.#left(ws); | ||
| + } | ||
| + | ||
| + #left(ws: WebSocket): void { | ||
| + this.#broadcast(JSON.stringify(presence(this.#editors(ws))), ws); | ||
| + } | ||
| + | ||
| + // The editors on the open sockets, oldest first, leaving out `except`. | ||
| + #editors(except?: WebSocket): Editor[] { | ||
| + const here: Editor[] = []; | ||
| + for (const ws of this.ctx.getWebSockets()) { | ||
| + if (ws === except || ws.readyState !== WebSocket.OPEN) continue; | ||
| + const editor = asEditor(ws.deserializeAttachment()); | ||
| + if (editor) here.push(editor); | ||
| + } | ||
| + return here; | ||
| + } | ||
| + | ||
| + #broadcast(frame: string, except: WebSocket): void { | ||
| + for (const ws of this.ctx.getWebSockets()) { | ||
| + if (ws === except) continue; | ||
| + try { | ||
| + ws.send(frame); | ||
| + } catch { | ||
| + // The socket is closing; webSocketClose will follow. | ||
| + } | ||
| + } | ||
| } | ||
| } | ||
Addedsrc/presence.ts+39−0
| @@ -0,0 +1,39 @@ | ||
| +// Who has a note open. Each editor gets a name and a color when its socket connects; both are | ||
| +// kept on the socket itself (see NoteRoom), never in storage, so the list is always exactly the | ||
| +// open connections. See decision 0003 in the knowledge base. | ||
| + | ||
| +import type { Editor, ServerMessage } from "./protocol.ts"; | ||
| + | ||
| +export const NAMES = ["Otter", "Heron", "Lynx", "Wren", "Badger", "Puffin", "Marten", "Ibis", "Stoat", "Finch", "Hare", "Newt"]; | ||
| +// Picked to stay readable as text and as a dot on a light or a dark page. | ||
| +export const COLORS = ["#0f766e", "#b45309", "#7c3aed", "#be123c", "#1d4ed8", "#4d7c0f", "#a21caf", "#0e7490"]; | ||
| + | ||
| +// A name and a color for a new editor, preferring ones nobody here is using. Past twelve | ||
| +// editors names repeat with a number ("Otter 2"). | ||
| +export function pickEditor(here: readonly Editor[], id: string): Editor { | ||
| + const names = new Set(here.map((e) => e.name)); | ||
| + const colors = new Set(here.map((e) => e.color)); | ||
| + let name = NAMES.find((n) => !names.has(n)); | ||
| + for (let round = 2; !name; round++) name = NAMES.map((n) => `${n} ${round}`).find((n) => !names.has(n)); | ||
| + const color = COLORS.find((c) => !colors.has(c)) ?? COLORS[here.length % COLORS.length]; | ||
| + return { id, name, color }; | ||
| +} | ||
| + | ||
| +// The messages for a join: the newcomer learns who it is and who is here, and everyone else | ||
| +// gets the new list. | ||
| +export function joinMessages(here: readonly Editor[], editor: Editor): { you: ServerMessage; others: ServerMessage } { | ||
| + const all = [...here, editor]; | ||
| + return { you: { type: "presence", here: all, you: editor }, others: { type: "presence", here: all } }; | ||
| +} | ||
| + | ||
| +export function presence(here: readonly Editor[]): ServerMessage { | ||
| + return { type: "presence", here: [...here] }; | ||
| +} | ||
| + | ||
| +// Reads an editor back from a socket attachment, which survives hibernation as structured | ||
| +// clone data. Anything else (a socket accepted before presence existed) is skipped. | ||
| +export function asEditor(v: unknown): Editor | null { | ||
| + if (typeof v !== "object" || v === null) return null; | ||
| + const e = v as Record<string, unknown>; | ||
| + return typeof e.id === "string" && typeof e.name === "string" && typeof e.color === "string" ? { id: e.id, name: e.name, color: e.color } : null; | ||
| +} | ||
Changedsrc/protocol.ts+10−2
| @@ -3,6 +3,10 @@ | ||
| import type { Op } from "./ops.ts"; | ||
| +// Someone with the note open. `id` is per connection: the same person in two tabs is two | ||
| +// editors. | ||
| +export type Editor = { id: string; name: string; color: string }; | ||
| + | ||
| // An edit, written against the note as it was at version `base`. | ||
| export type ClientMessage = { type: "op"; base: number; op: Op }; | ||
| @@ -12,8 +16,12 @@ | ||
| | { type: "snapshot"; text: string; version: number; reason?: string } | ||
| // Your op was applied (or turned out to change nothing) and the note is now at `version`. | ||
| | { type: "ack"; version: number } | ||
| - // Someone else's op, already transformed, which took the note to `version`. | ||
| - | { type: "op"; version: number; op: Op } | ||
| + // Someone else's op, already transformed, which took the note to `version`. `by` is the | ||
| + // editor id of whoever made it. | ||
| + | { type: "op"; version: number; op: Op; by: string } | ||
| + // Who has the note open, sent to everyone when someone joins or leaves. `you` is only set | ||
| + // in the message to the editor who just joined. | ||
| + | { type: "presence"; here: Editor[]; you?: Editor } | ||
| | { type: "error"; message: string }; | ||
| // Parses and checks a frame from an editor. Returns null for anything malformed; the room | ||
Changedsrc/room.ts+3−2
| @@ -50,7 +50,8 @@ | ||
| return reason ? { ...msg, reason } : msg; | ||
| } | ||
| - receive(raw: string): Outcome { | ||
| + // Handles a frame from the editor with id `by`. | ||
| + receive(raw: string, by: string): Outcome { | ||
| const msg = parseClientMessage(raw); | ||
| if (!msg) return { reply: { type: "error", message: "expected an op message" } }; | ||
| const { text, version } = this.#note; | ||
| @@ -73,6 +74,6 @@ | ||
| const note = { text: next, version: version + 1 }; | ||
| this.#store.commit(note, op); | ||
| this.#note = note; | ||
| - return { reply: { type: "ack", version: note.version }, broadcast: { type: "op", version: note.version, op } }; | ||
| + return { reply: { type: "ack", version: note.version }, broadcast: { type: "op", version: note.version, op, by } }; | ||
| } | ||
| } | ||
Addedtest/presence.test.ts+59−0
| @@ -0,0 +1,59 @@ | ||
| +import assert from "node:assert/strict"; | ||
| +import { describe, test } from "node:test"; | ||
| +import { COLORS, NAMES, asEditor, joinMessages, pickEditor, presence } from "../src/presence.ts"; | ||
| +import type { Editor } from "../src/protocol.ts"; | ||
| + | ||
| +describe("pickEditor", () => { | ||
| + test("the first editor gets the first name and color", () => { | ||
| + assert.deepEqual(pickEditor([], "a1"), { id: "a1", name: "Otter", color: COLORS[0] }); | ||
| + }); | ||
| + | ||
| + test("skips names and colors already in use", () => { | ||
| + const here: Editor[] = [ | ||
| + { id: "a1", name: "Otter", color: COLORS[0] }, | ||
| + { id: "b2", name: "Lynx", color: COLORS[2] }, | ||
| + ]; | ||
| + assert.deepEqual(pickEditor(here, "c3"), { id: "c3", name: "Heron", color: COLORS[1] }); | ||
| + }); | ||
| + | ||
| + test("reuses a name once its editor has left", () => { | ||
| + const here: Editor[] = [{ id: "b2", name: "Heron", color: COLORS[1] }]; | ||
| + assert.equal(pickEditor(here, "c3").name, "Otter"); | ||
| + }); | ||
| + | ||
| + test("numbers names once all are taken, and cycles colors", () => { | ||
| + const here: Editor[] = []; | ||
| + for (let i = 0; i < NAMES.length + 2; i++) here.push(pickEditor(here, `e${i}`)); | ||
| + assert.deepEqual(here.slice(-2).map((e) => e.name), ["Otter 2", "Heron 2"]); | ||
| + assert.equal(new Set(here.map((e) => e.name)).size, here.length); | ||
| + assert.ok(here.every((e) => COLORS.includes(e.color))); | ||
| + }); | ||
| +}); | ||
| + | ||
| +describe("presence messages", () => { | ||
| + const otter: Editor = { id: "a1", name: "Otter", color: COLORS[0] }; | ||
| + const heron: Editor = { id: "b2", name: "Heron", color: COLORS[1] }; | ||
| + | ||
| + test("a join tells the newcomer who it is and everyone else who is here", () => { | ||
| + const { you, others } = joinMessages([otter], heron); | ||
| + assert.deepEqual(you, { type: "presence", here: [otter, heron], you: heron }); | ||
| + assert.deepEqual(others, { type: "presence", here: [otter, heron] }); | ||
| + }); | ||
| + | ||
| + test("a leave sends the editors still here", () => { | ||
| + assert.deepEqual(presence([heron]), { type: "presence", here: [heron] }); | ||
| + assert.deepEqual(presence([]), { type: "presence", here: [] }); | ||
| + }); | ||
| +}); | ||
| + | ||
| +describe("asEditor", () => { | ||
| + test("reads an editor back from a socket attachment", () => { | ||
| + assert.deepEqual(asEditor({ id: "a1", name: "Otter", color: "#0f766e", extra: 1 }), { id: "a1", name: "Otter", color: "#0f766e" }); | ||
| + }); | ||
| + | ||
| + test("skips sockets without one", () => { | ||
| + assert.equal(asEditor(null), null); | ||
| + assert.equal(asEditor("Otter"), null); | ||
| + assert.equal(asEditor({ id: "a1", name: "Otter" }), null); | ||
| + }); | ||
| +}); | ||
Changedtest/room.test.ts+18−18
| @@ -15,56 +15,56 @@ | ||
| test("applies an op, acks the sender and broadcasts it to the others", () => { | ||
| const store = new MemoryStore(); | ||
| const room = new Room(store); | ||
| - const out = room.receive(send(0, insert(0, "milk"))); | ||
| + const out = room.receive(send(0, insert(0, "milk")), "otter"); | ||
| assert.deepEqual(out.reply, { type: "ack", version: 1 }); | ||
| - assert.deepEqual(out.broadcast, { type: "op", version: 1, op: insert(0, "milk") }); | ||
| + assert.deepEqual(out.broadcast, { type: "op", version: 1, op: insert(0, "milk"), by: "otter" }); | ||
| assert.deepEqual(store.note, { text: "milk", version: 1 }); | ||
| }); | ||
| test("transforms an op past the ops its sender had not seen", () => { | ||
| const room = new Room(new MemoryStore({ text: "milk\n", version: 0 })); | ||
| // Two editors at version 0: one adds a line at the top, the other appends. | ||
| - room.receive(send(0, insert(0, "eggs\n"))); | ||
| - const out = room.receive(send(0, insert(5, "bread\n"))); | ||
| - assert.deepEqual(out.broadcast, { type: "op", version: 2, op: insert(10, "bread\n") }); | ||
| + room.receive(send(0, insert(0, "eggs\n")), "otter"); | ||
| + const out = room.receive(send(0, insert(5, "bread\n")), "heron"); | ||
| + assert.deepEqual(out.broadcast, { type: "op", version: 2, op: insert(10, "bread\n"), by: "heron" }); | ||
| assert.equal(room.note.text, "eggs\nmilk\nbread\n"); | ||
| }); | ||
| test("acks an op that another edit already made pointless without bumping the version", () => { | ||
| const room = new Room(new MemoryStore({ text: "milk eggs", version: 0 })); | ||
| - room.receive(send(0, del(4, 5))); | ||
| - const out = room.receive(send(0, del(5, 4))); | ||
| + room.receive(send(0, del(4, 5)), "otter"); | ||
| + const out = room.receive(send(0, del(5, 4)), "otter"); | ||
| assert.deepEqual(out, { reply: { type: "ack", version: 1 } }); | ||
| assert.equal(room.note.text, "milk"); | ||
| }); | ||
| test("sends a snapshot to an editor further behind than the log reaches", () => { | ||
| const room = new Room(new MemoryStore()); | ||
| - for (let v = 0; v < LOG_SIZE + 1; v++) room.receive(send(v, insert(v, "x"))); | ||
| - const out = room.receive(send(0, insert(0, "late"))); | ||
| + for (let v = 0; v < LOG_SIZE + 1; v++) room.receive(send(v, insert(v, "x")), "otter"); | ||
| + const out = room.receive(send(0, insert(0, "late")), "otter"); | ||
| assert.equal(out.reply.type, "snapshot"); | ||
| assert.equal(out.broadcast, undefined); | ||
| // One version less behind is still within the log. | ||
| - assert.equal(room.receive(send(1, insert(0, "y"))).reply.type, "ack"); | ||
| + assert.equal(room.receive(send(1, insert(0, "y")), "otter").reply.type, "ack"); | ||
| }); | ||
| test("sends a snapshot for a base version from the future", () => { | ||
| const room = new Room(new MemoryStore()); | ||
| - assert.deepEqual(room.receive(send(3, insert(0, "x"))).reply, { type: "snapshot", text: "", version: 0 }); | ||
| + assert.deepEqual(room.receive(send(3, insert(0, "x")), "otter").reply, { type: "snapshot", text: "", version: 0 }); | ||
| }); | ||
| test("sends a snapshot when an op does not fit the note", () => { | ||
| const store = new MemoryStore({ text: "abc", version: 4 }); | ||
| const room = new Room(store); | ||
| - assert.equal(room.receive(send(4, del(2, 5))).reply.type, "snapshot"); | ||
| + assert.equal(room.receive(send(4, del(2, 5)), "otter").reply.type, "snapshot"); | ||
| assert.deepEqual(store.note, { text: "abc", version: 4 }); | ||
| }); | ||
| test("refuses an op that would make the note too long", () => { | ||
| const store = new MemoryStore({ text: "x".repeat(MAX_LENGTH - 2), version: 7 }); | ||
| const room = new Room(store); | ||
| - assert.equal(room.receive(send(7, insert(0, "ab"))).reply.type, "ack"); | ||
| - const out = room.receive(send(8, insert(0, "c"))); | ||
| + assert.equal(room.receive(send(7, insert(0, "ab")), "otter").reply.type, "ack"); | ||
| + const out = room.receive(send(8, insert(0, "c")), "otter"); | ||
| assert.deepEqual(out.reply, { type: "snapshot", text: "ab" + "x".repeat(MAX_LENGTH - 2), version: 8, reason: "notes are limited to 100,000 characters" }); | ||
| assert.equal(out.broadcast, undefined); | ||
| assert.equal(store.note.version, 8); | ||
| @@ -72,16 +72,16 @@ | ||
| test("answers a malformed frame with an error", () => { | ||
| const room = new Room(new MemoryStore()); | ||
| - assert.equal(room.receive("{").reply.type, "error"); | ||
| - assert.equal(room.receive(JSON.stringify({ type: "op", base: 0, op: { type: "upsert", pos: 0 } })).reply.type, "error"); | ||
| + assert.equal(room.receive("{", "otter").reply.type, "error"); | ||
| + assert.equal(room.receive(JSON.stringify({ type: "op", base: 0, op: { type: "upsert", pos: 0 } }), "otter").reply.type, "error"); | ||
| }); | ||
| test("picks up where it left off from the store", () => { | ||
| const store = new MemoryStore(); | ||
| - new Room(store).receive(send(0, insert(0, "kept"))); | ||
| + new Room(store).receive(send(0, insert(0, "kept")), "otter"); | ||
| // A Durable Object woken from hibernation builds a new Room over the same storage. | ||
| const woken = new Room(store); | ||
| assert.deepEqual(woken.note, { text: "kept", version: 1 }); | ||
| - assert.deepEqual(woken.receive(send(1, insert(0, "> "))).broadcast, { type: "op", version: 2, op: insert(0, "> ") }); | ||
| + assert.deepEqual(woken.receive(send(1, insert(0, "> ")), "otter").broadcast, { type: "op", version: 2, op: insert(0, "> "), by: "otter" }); | ||
| }); | ||
| }); | ||
Changedtest/session.test.ts+1−1
| @@ -25,7 +25,7 @@ | ||
| const from = this.editors[i]; | ||
| const msg = from.up.shift(); | ||
| if (!msg) return; | ||
| - const out = this.room.receive(JSON.stringify(msg)); | ||
| + const out = this.room.receive(JSON.stringify(msg), String(i)); | ||
| from.down.push(out.reply); | ||
| if (out.broadcast) for (const e of this.editors) if (e !== from) e.down.push(out.broadcast); | ||
| } | ||
An edit reaching other editors
1 step relabelled, description changed
- Added
- Rerouted, relabelled or moved
- Removed
Editor A (Otter) types while editor B (Heron) has the same note open. The room puts the edit in order, saves it and passes it on with who made it.
The flow edit-broadcast at the head of this pull request, numbered in its order, with the steps it adds, reroutes, relabels, moves and removes. A removed step has no number.
Steps
- Step 1: editor-a → note-room op: insert "oat " at 4, written against version 41
- Step 2: note-room → ops transform the op past versions 42 and 43 from the log
- Step 3: ops → note-room the op as it applies now, which the room saves to SQLite
- Step 4: note-room → editor-a ack: version 44
- Step 5, relabelled: note-room → editor-b the transformed op at version 44, marked as Otter's Relabelled was “the transformed op at version 44”
- Step 6: editor-b → ops move the op past B's own unacknowledged edits, then apply it
The flow's description changed too.
Knowledge base
- Conflict strategy Changedconcepts/conflict-strategy.md · concept
- The note room Changedconcepts/note-room.md · concept
- Presence Addedconcepts/presence.md · concept
- Wire protocol Changedconcepts/wire-protocol.md · concept
- 0003 Presence lives on the sockets Addeddecisions/0003-presence-lives-on-the-sockets.md · decision
- live-notes Changedindex.md · index