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); | ||
| } | ||
View editing
1 changed
- Added
- Changed
- Removed
What takes part in an edit, from the browser to the room and back.
The view editing at the head of this pull request, with what it adds, changes and removes from the merge base d21dfdd. Select a component for what changed in it.
ops
LibraryClear
Insert and delete operations, their transform, and the messages on the socket. The same code runs in the room and in the browser.
Not changed by this pull request.
Takes part in An edit reaching other editors.
Changes
- Changed: ~ note-room: description; owns +src/presence.ts
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