Public: anyone can read this; the crew can change it.
Who else is on this board: presence beside the stream, and the name behind a move #74
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/18-who-else-is-on-this-board-presence-besid → main 5 commits fork 7 threads · 7 unresolved
README.md +24
| @@ -79,6 +79,30 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 79 | 79 | below — arriving as a whole-board reconcile rather than one placed |
| 80 | 80 | card, since the poller that notices it only knows the board changed, |
| 81 | 81 | not what changed. |
| 82 | - **Presence**: the header shows who *else* has this board's `/events` | |
| 83 | stream open right now — a forge username for a signed-in visitor (once, | |
| 84 | even if they have it open in two tabs), "Guest" for one anonymous tab, | |
| 85 | "N guests" for several. It means exactly that: this process's other open | |
| 86 | connections to this board, right now, nothing more. It isn't who has | |
| 87 | ever opened the board, isn't a cursor or an edit lock, and a guest tab | |
| 88 | counts as one whether or not it's the same person as another guest tab — | |
| 89 | there's no identity to tell them apart with, the way there is for a | |
| 90 | signed-in name. It updates within a second of someone arriving or | |
| 91 | leaving (`src/presence.ts`, fed only by the stream's own subscribe/ | |
| 92 | unsubscribe — never polled, and coalesced to at most one push per board | |
| 93 | every 250ms so a burst of arrivals or departures can't be used to flood | |
| 94 | every other open tab with a frame per event). A tab that closes | |
| 95 | properly is gone as soon as it does; a tab that vanishes without closing | |
| 96 | (a killed browser, a dead network) is a weaker guarantee — it's gone once | |
| 97 | the underlying connection actually times out, not a bound this app | |
| 98 | enforces itself, and the 25s heartbeat is a write from the server, which | |
| 99 | can keep that connection looking alive well past a minute even after the | |
| 100 | browser is gone. A card changed by someone else is also marked "by | |
| 101 | \<name\>" for a few seconds (a guest's changes carry no name, so they | |
| 102 | aren't marked) and announced once for a screen reader; a change you made | |
| 103 | yourself is never marked. Presence is web-only: it comes from | |
| 104 | `/events` subscribers, so a person working the same board from the | |
| 105 | terminal (`src/tty.ts`) neither appears in the list nor sees it. | |
| 82 | 106 | - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0 |
| 83 | 107 | (`initialize`, `tools/list`, `tools/call`) with five tools — |
| 84 | 108 | `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` — |
src/bus.test.ts +17
| @@ -44,3 +44,20 @@ test("unsubscribing leaves no listener behind", () => { | ||
| 44 | 44 | stop(); |
| 45 | 45 | expect(subscriberCount("board-e")).toBe(0); |
| 46 | 46 | }); |
| 47 | ||
| 48 | // Without a guard on the returned unsubscribe, a stale second call — after | |
| 49 | // the board's listener set has already been emptied and a new one created | |
| 50 | // for a later subscriber — deletes that later subscriber's set out from | |
| 51 | // under it too, since Map#delete only looks at the key, not which Set | |
| 52 | // object currently lives there. | |
| 53 | test("a stale second unsubscribe never wipes a listener set created after it left", () => { | |
| 54 | const heard: unknown[] = []; | |
| 55 | const stopStale = subscribe("board-f", () => heard.push("stale")); | |
| 56 | stopStale(); | |
| 57 | const stopLive = subscribe("board-f", (e) => heard.push(["live", e])); | |
| 58 | stopStale(); | |
| 59 | expect(subscriberCount("board-f")).toBe(1); | |
| 60 | publish("board-f", event("board-f")); | |
| 61 | expect(heard).toEqual([["live", event("board-f")]]); | |
| 62 | stopLive(); | |
| 63 | }); |
src/bus.ts +3
| @@ -39,7 +39,10 @@ export function subscribe(boardId: string, fn: (event: BoardEvent) => void): () | ||
| 39 | 39 | let listeners = boards.get(boardId); |
| 40 | 40 | if (!listeners) boards.set(boardId, (listeners = new Set())); |
| 41 | 41 | listeners.add(fn); |
| 42 | let left = false; | |
| 42 | 43 | return () => { |
| 44 | if (left) return; | |
| 45 | left = true; | |
| 43 | 46 | listeners.delete(fn); |
| 44 | 47 | if (listeners.size === 0) boards.delete(boardId); |
| 45 | 48 | }; |
src/live-refresh.test.ts +50
| @@ -60,9 +60,18 @@ class FakeEventSource { | ||
| 60 | 60 | onopen: (() => void) | null = null; |
| 61 | 61 | onerror: (() => void) | null = null; |
| 62 | 62 | readyState = FakeEventSource.CONNECTING; |
| 63 | = new Map<string, Set<(message: { data: string }) => void>>(); | |
| 63 | 64 | constructor(_url: string) { |
| 64 | 65 | latestSource = this; |
| 65 | 66 | } |
| 67 | addEventListener(type: string, fn: (message: { data: string }) => void): void { | |
| 68 | let heard = this..get(type); | |
| 69 | if (!heard) this..set(type, (heard = new Set())); | |
| 70 | heard.add(fn); | |
| 71 | } | |
| 72 | dispatch(type: string, data: unknown): void { | |
| 73 | for (const fn of this..get(type) ?? []) fn({ data: JSON.stringify(data) }); | |
| 74 | } | |
| 66 | 75 | } |
| 67 | 76 | let latestSource: FakeEventSource | undefined; |
| 68 | 77 | |
| @@ -108,6 +117,9 @@ async function run(boardId: string, onFetch?: (path: string, init: RequestInit) | ||
| 108 | 117 | function deliver(event: unknown): void { |
| 109 | 118 | latestSource!.onmessage!({ data: JSON.stringify(event) }); |
| 110 | 119 | } |
| 120 | function deliverPresence(people: (string | null)[]): void { | |
| 121 | latestSource!.dispatch("presence", { people }); | |
| 122 | } | |
| 111 | 123 | |
| 112 | 124 | test("a card added elsewhere appears right away and does not disturb an in-progress add-card title", async () => { |
| 113 | 125 | const boardId = await newBoard(); |
| @@ -207,6 +219,44 @@ test("this tab's own write is not re-applied when its echo comes back over the s | ||
| 207 | 219 | await window.close(); |
| 208 | 220 | }); |
| 209 | 221 | |
| 222 | test("a presence frame renders who is here, aggregating guests into a count", async () => { | |
| 223 | const boardId = await newBoard(); | |
| 224 | const { window, document } = await run(boardId); | |
| 225 | ||
| 226 | deliverPresence(["alice", null, null]); | |
| 227 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 228 | expect(document.getElementById("presence")!.textContent).toBe("alice, 2 guests"); | |
| 229 | ||
| 230 | // The same signed-in name in two tabs is one person, not two. | |
| 231 | deliverPresence(["alice", "alice"]); | |
| 232 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 233 | expect(document.getElementById("presence")!.textContent).toBe("alice"); | |
| 234 | ||
| 235 | deliverPresence([]); | |
| 236 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 237 | expect(document.getElementById("presence")!.textContent).toBe(""); | |
| 238 | ||
| 239 | await window.close(); | |
| 240 | }); | |
| 241 | ||
| 242 | test("a card moved by someone else is marked with their name, and announced for a screen reader", async () => { | |
| 243 | const boardId = await newBoard(); | |
| 244 | const columnId = (await (await fetch(`${base}/b/${boardId}`)).text()).match(/data-column-id="([a-z0-9]{12})"/)![1]; | |
| 245 | const created = await post(`/b/${boardId}/columns/${columnId}/cards`, { title: "Moved by Alice" }); | |
| 246 | const { id: cardId } = (await created.json()) as { id: string }; | |
| 247 | const { window, document } = await run(boardId); | |
| 248 | ||
| 249 | deliver({ kind: "card.moved", boardId, by: "alice", client: "someone-else", ids: { cardId, columnId } }); | |
| 250 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 251 | ||
| 252 | const card = document.querySelector(`[data-card-id="${cardId}"]`)!; | |
| 253 | expect(card.querySelector(".by")!.textContent).toBe("by alice"); | |
| 254 | expect(card.querySelector(".by")!.classList.contains("show")).toBe(true); | |
| 255 | expect(document.getElementById("live-attribution")!.textContent).toContain("alice"); | |
| 256 | ||
| 257 | await window.close(); | |
| 258 | }); | |
| 259 | ||
| 210 | 260 | test("a column added or deleted elsewhere updates every card's Move to column dropdown, not just column.renamed", async () => { |
| 211 | 261 | const boardId = await newBoard(); |
| 212 | 262 | const columnId = (await (await fetch(`${base}/b/${boardId}`)).text()).match(/data-column-id="([a-z0-9]{12})"/)![1]; |
src/pages/board.test.ts +22
| @@ -25,3 +25,25 @@ test("boardPage's refusal message is escaped like every other piece of user-faci | ||
| 25 | 25 | expect(shown).not.toContain("<script>bad</script>"); |
| 26 | 26 | expect(shown).toContain("<script>bad</script>"); |
| 27 | 27 | }); |
| 28 | ||
| 29 | test("a presence name is escaped, and a guest reads as a count rather than a list of identical words", () => { | |
| 30 | const board = { id: "abcd1234", name: "Board", owner_id: null, owner_name: null, created_at: "" }; | |
| 31 | const shown = boardPage({ | |
| 32 | board, | |
| 33 | columns: [], | |
| 34 | cards: [], | |
| 35 | query: "", | |
| 36 | who: ANON, | |
| 37 | presence: ["<script>bad</script>", null, null], | |
| 38 | }); | |
| 39 | expect(shown).not.toContain("<script>bad</script>"); | |
| 40 | expect(shown).toContain("<script>bad</script>"); | |
| 41 | expect(shown).toContain('id="presence"'); | |
| 42 | expect(shown).toContain("2 guests"); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the same signed-in name open in two tabs reads once, not twice", () => { | |
| 46 | const board = { id: "abcd1234", name: "Board", owner_id: null, owner_name: null, created_at: "" }; | |
| 47 | const shown = boardPage({ board, columns: [], cards: [], query: "", who: ANON, presence: ["alice", "alice"] }); | |
| 48 | expect(shown).toContain('id="presence" title="Who has this board open right now">alice<'); | |
| 49 | }); |
src/pages/board.ts +34 −3
| @@ -31,6 +31,10 @@ const styles = ` | ||
| 31 | 31 | .door { margin-left: auto; display: flex; align-items: center; gap: .5rem; font-size: .85rem; white-space: nowrap; } |
| 32 | 32 | .who { opacity: .8; display: inline-flex; align-items: baseline; } |
| 33 | 33 | .who .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(9rem, 32vw); } |
| 34 | .presence { display: inline-block; min-height: 1.2em; font-size: .8rem; opacity: .7; overflow: hidden; text-overflow: ellipsis; | |
| 35 | white-space: nowrap; max-width: 16rem; } | |
| 36 | .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; | |
| 37 | clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } | |
| 34 | 38 | a.ghost { text-decoration: none; display: inline-block; } |
| 35 | 39 | .keep { font-size: .8rem; opacity: .85; display: flex; align-items: center; gap: .4rem; } |
| 36 | 40 | .boards { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; } |
| @@ -63,6 +67,8 @@ const styles = ` | ||
| 63 | 67 | .card:focus-visible { outline: 2px solid var(--accent); } |
| 64 | 68 | .card.dragging { opacity: .4; } |
| 65 | 69 | .card .edit input { width: 100%; border: 0; background: transparent; padding: .2rem; } |
| 70 | .card .by { display: block; min-height: 1em; margin-top: .2rem; font-size: .7rem; opacity: 0; transition: opacity .8s ease; } | |
| 71 | .card .by.show { opacity: .65; transition: none; } | |
| 66 | 72 | .card .row { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .3rem; align-items: center; } |
| 67 | 73 | .card .row select { flex: 1; font-size: .8rem; padding: .4rem; min-height: 32px; } |
| 68 | 74 | .card .row button { padding: .4rem .6rem; font-size: .8rem; min-height: 32px; min-width: 32px; } |
| @@ -119,6 +125,7 @@ const shell = (title: string, body: string, shortcuts: [string, string][] = [], | ||
| 119 | 125 | ${body} |
| 120 | 126 | ${shortcutsDialog(shortcuts)} |
| 121 | 127 | <div id="toast" role="status" aria-live="polite"${error ? ' class="show"' : ""}>${error ? escape(error) : ""}</div> |
| 128 | <div id="live-attribution" class="sr-only" aria-live="polite"></div> | |
| 122 | 129 | <script src="/static/${THEME_JS}" defer></script> |
| 123 | 130 | `; |
| 124 | 131 | |
| @@ -168,7 +175,29 @@ ${list} | ||
| 168 | 175 | ); |
| 169 | 176 | } |
| 170 | 177 | |
| 171 | export type BoardData = { board: Board; columns: Column[]; cards: Card[]; query: string; who: Who; error?: string }; | |
| 178 | export type BoardData = { | |
| 179 | board: Board; | |
| 180 | columns: Column[]; | |
| 181 | cards: Card[]; | |
| 182 | query: string; | |
| 183 | who: Who; | |
| 184 | error?: string; | |
| 185 | presence?: readonly (string | null)[]; | |
| 186 | }; | |
| 187 | ||
| 188 | // A signed-in visitor is named (already escaped) once, even open in two | |
| 189 | // tabs — a guest has no name to dedupe by, so a guest tab only ever adds | |
| 190 | // to the count, and several read as that count rather than a list of | |
| 191 | // identical words. Empty when no one (else) has the live stream open yet | |
outdated
LOW — once the recipient is excluded (see the routes.ts note), the same signed-in person in two tabs still renders "alice, alice" to everyone else. Either dedupe identical names here and in |
||
| 192 | // — a fixed-height slot (see .presence) keeps that from shifting anything | |
| 193 | // once the first arrival fills it in. | |
| 194 | function presenceText(people: readonly (string | null)[]): string { | |
| 195 | const named = people.filter((p): p is string => p !== null); | |
| 196 | const names = [...new Set(named)].map(escape); | |
| 197 | const guests = people.length - named.length; | |
| 198 | const guestLabel = guests === 0 ? "" : guests === 1 ? "Guest" : `${guests} guests`; | |
| 199 | return [...names, guestLabel].filter(Boolean).join(", "); | |
| 200 | } | |
| 172 | 201 | |
| 173 | 202 | // A guest's board belongs to no one until someone signed in keeps it |
| 174 | 203 | // (POST /b/:id/claim — see routes.ts and db.ts's claimBoard). |
| @@ -178,7 +207,7 @@ function keepHtml(board: Board, who: Who): string { | ||
| 178 | 207 | return `<form class="keep" method="post" action="/b/${board.id}/claim"><button class="ghost">Keep this board</button></form>`; |
| 179 | 208 | } |
| 180 | 209 | |
| 181 | export function boardPage({ board, columns, cards, query, who, error }: BoardData): string { | |
| 210 | export function boardPage({ board, columns, cards, query, who, error, presence = [] }: BoardData): string { | |
| 182 | 211 | const q = query.trim().toLowerCase(); |
| 183 | 212 | const matches = (c: Card) => q === "" || c.title.toLowerCase().includes(q); |
| 184 | 213 | const cardsFor = (columnId: string) => cards.filter((c) => c.column_id === columnId); |
| @@ -206,6 +235,7 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat | ||
| 206 | 235 | <button aria-label="Delete card">✕</button> |
| 207 | 236 | </form> |
| 208 | 237 | </div> |
| 238 | <p class="by" aria-hidden="true"></p> | |
| 209 | 239 | </li>`; |
| 210 | 240 | |
| 211 | 241 | const columnHtml = (col: Column) => { |
| @@ -246,6 +276,7 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat | ||
| 246 | 276 | <input name="name" value="${escape(board.name)}" maxlength="100" aria-label="Board name"> |
| 247 | 277 | <button class="ghost">Rename</button> |
| 248 | 278 | </form> |
| 279 | <span class="presence" id="presence" title="Who has this board open right now">${presenceText(presence)}</span> | |
| 249 | 280 | <span class="live" id="live-status" data-state="off"> |
| 250 | 281 | <span class="dot" aria-hidden="true"></span> |
| 251 | 282 | <span id="live-text" aria-live="polite">Not live</span> |
| @@ -289,6 +320,6 @@ const boardShortcuts: [string, string][] = [ | ||
| 289 | 320 | |
| 290 | 321 | // Named by content — a change to static/board.js gets a new hash here, |
| 291 | 322 | // so the year-long cache on /static/ never serves a stale script. |
| 292 | export const BOARD_JS = "board.351899687.js"; | |
| 323 | export const BOARD_JS = "board.232693062.js"; | |
| 293 | 324 | export const THEME_JS = "theme.1025564933.js"; |
| 294 | 325 | export const BOARDS_JS = "boards.608180649.js"; |
src/presence.test.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { join, list, onChange } from "./presence.ts"; | |
| 3 | ||
| 4 | test("two subscribers on one board list two people, and one leaving drops to one", () => { | |
| 5 | const alice = join("board-a", { id: "u1", name: "alice" }); | |
| 6 | const bob = join("board-a", { id: "u2", name: "bob" }); | |
| 7 | expect(list("board-a")).toEqual(["alice", "bob"]); | |
| 8 | alice.leave(); | |
| 9 | expect(list("board-a")).toEqual(["bob"]); | |
| 10 | bob.leave(); | |
| 11 | expect(list("board-a")).toEqual([]); | |
| 12 | }); | |
| 13 | ||
| 14 | test("a subscriber on another board is not listed", () => { | |
| 15 | const alice = join("board-b", { id: "u1", name: "alice" }); | |
| 16 | expect(list("board-c")).toEqual([]); | |
| 17 | alice.leave(); | |
| 18 | }); | |
| 19 | ||
| 20 | test("a guest joins as null, never carrying an id", () => { | |
| 21 | const guest = join("board-guest", null); | |
| 22 | expect(list("board-guest")).toEqual([null]); | |
| 23 | guest.leave(); | |
| 24 | }); | |
| 25 | ||
| 26 | test("unsubscribing twice is harmless", () => { | |
| 27 | const alice = join("board-d", { id: "u1", name: "alice" }); | |
| 28 | expect(list("board-d")).toEqual(["alice"]); | |
| 29 | alice.leave(); | |
| 30 | alice.leave(); | |
| 31 | expect(list("board-d")).toEqual([]); | |
| 32 | }); | |
| 33 | ||
| 34 | // The frame a connection sends about itself must agree with what a fresh | |
| 35 | // page load (which isn't a member yet) would render — see routes.ts. | |
| 36 | test("list() with a token excludes that one connection from its own view", () => { | |
| 37 | const alice = join("board-x", { id: "u1", name: "alice" }); | |
| 38 | expect(list("board-x", alice.self)).toEqual([]); | |
| 39 | const bob = join("board-x", { id: "u2", name: "bob" }); | |
| 40 | expect(list("board-x", alice.self)).toEqual(["bob"]); | |
| 41 | expect(list("board-x", bob.self)).toEqual(["alice"]); | |
| 42 | expect(list("board-x")).toEqual(["alice", "bob"]); | |
| 43 | alice.leave(); | |
| 44 | bob.leave(); | |
| 45 | }); | |
| 46 | ||
| 47 | test("onChange fires when someone joins or leaves, and stops once unsubscribed", () => { | |
| 48 | const seen: number[] = []; | |
| 49 | const stop = onChange("board-e", () => seen.push(list("board-e").length)); | |
| 50 | const alice = join("board-e", { id: "u1", name: "alice" }); | |
| 51 | expect(seen).toEqual([1]); | |
| 52 | alice.leave(); | |
| 53 | expect(seen).toEqual([1, 0]); | |
| 54 | stop(); | |
| 55 | join("board-e", { id: "u2", name: "bob" }).leave(); | |
| 56 | expect(seen).toEqual([1, 0]); | |
| 57 | }); | |
| 58 | ||
| 59 | // Without a guard, a stale second call to a changeListeners unsubscribe — | |
| 60 | // after the board's listener set was emptied and a new one created for a | |
| 61 | // later onChange — would delete that later listener's set out from under | |
| 62 | // it (Map#delete only looks at the key), silently freezing presence for | |
| 63 | // whoever still holds it. | |
| 64 | test("a stale second onChange unsubscribe never wipes a listener registered after it left", () => { | |
| 65 | const seenStale: number[] = []; | |
| 66 | const stopStale = onChange("board-f", () => seenStale.push(1)); | |
| 67 | stopStale(); | |
| 68 | const seenLive: number[] = []; | |
| 69 | const stopLive = onChange("board-f", () => seenLive.push(1)); | |
| 70 | stopStale(); | |
| 71 | const alice = join("board-f", { id: "u1", name: "alice" }); | |
| 72 | expect(seenLive).toEqual([1]); | |
| 73 | expect(seenStale).toEqual([]); | |
| 74 | alice.leave(); | |
| 75 | stopLive(); | |
| 76 | }); |
src/presence.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * Who is on a board right now. Fed only by the event stream's own | |
| 3 | * subscribe and unsubscribe (see routes.ts's eventStream): a tab joins | |
| 4 | * when it opens `/b/:id/events` and leaves when that connection closes, so | |
| 5 | * a person who closes the tab is gone as soon as it does, and a connection | |
| 6 | * that dies without closing is gone within one heartbeat. Nothing here | |
| 7 | * knows an id — only a name, or null for a guest — so nothing here can leak | |
| 8 | * one. | |
| 9 | */ | |
| 10 | ||
| 11 | const boards = new Map<string, Map<symbol, string | null>>(); | |
| 12 | const changeListeners = new Map<string, Set<() => void>>(); | |
| 13 | ||
| 14 | function notify(boardId: string): void { | |
| 15 | for (const fn of changeListeners.get(boardId) ?? []) fn(); | |
Read at head *Fix first — the actions take the guest id*, and the id is the thing you publish.** The fix: take the key, never the id. Second — Blocker to landing: head Notes, not blockers. (1) The Everything else — the merge being idempotent, the ladder excluding guests and tie-breaking on the earliest round, the handle shape no forge username can wear, the sweep sparing anyone with a score — checks out and is tested. Correction first: the comment left on this pull a few minutes ago ( Reviewed at What I verified working, not assumed
The one thing to fix: the presence fan-out is an unmetered push path.
Concrete fix, small and local, and still well inside "within a second": coalesce in Two small things while you are in there
Not blocking, worth knowing
Everything else — the escaping, the id staying off the wire, the self-exclusion, the bus unsubscribe guard and its test, the attribution mark and its probe2 |
||
| 16 | } | |
| 17 | ||
| 18 | export type Membership = { | |
| 19 | /** Leaves the board, once — a second call is harmless. */ | |
| 20 | leave: () => void; | |
outdated
p |
||
| 21 | /** This connection's own token, to exclude itself from what list() shows it. */ | |
| 22 | self: symbol; | |
| 23 | }; | |
| 24 | ||
| 25 | /** A tab opening the live stream joins a board; the returned membership leaves it, once. */ | |
| 26 | export function join(boardId: string, who: { id: string; name: string } | null): Membership { | |
| 27 | let people = boards.get(boardId); | |
| 28 | if (!people) boards.set(boardId, (people = new Map())); | |
| 29 | const self = Symbol("presence"); | |
| 30 | people.set(self, who ? who.name : null); | |
| 31 | notify(boardId); | |
| 32 | let left = false; | |
| 33 | const leave = () => { | |
| 34 | if (left) return; | |
| 35 | left = true; | |
| 36 | people.delete(self); | |
| 37 | if (people.size === 0) boards.delete(boardId); | |
| 38 | notify(boardId); | |
| 39 | }; | |
| 40 | return { leave, self }; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Who is on a board right now, as plain data — a forge username, or null | |
| 45 | * for a guest. `except`, when given, leaves that one connection's own | |
| 46 | * token out, so a lone visitor sees no one (see routes.ts: the frame sent | |
| 47 | * to a connection must agree with what a fresh page load would show). | |
outdated
MEDIUM — this unsubscribe has no |
||
| 48 | */ | |
| 49 | export function list(boardId: string, except?: symbol): readonly (string | null)[] { | |
| 50 | const people = boards.get(boardId); | |
| 51 | if (!people) return []; | |
| 52 | const out: (string | null)[] = []; | |
| 53 | for (const [token, name] of people) { | |
| 54 | if (token !== except) out.push(name); | |
| 55 | } | |
| 56 | return out; | |
| 57 | } | |
| 58 | ||
| 59 | /** Calls `fn` whenever a board's presence changes; read the fresh list back with list(). */ | |
| 60 | export function onChange(boardId: string, fn: () => void): () => void { | |
| 61 | let heard = changeListeners.get(boardId); | |
| 62 | if (!heard) changeListeners.set(boardId, (heard = new Set())); | |
| 63 | heard.add(fn); | |
| 64 | let left = false; | |
| 65 | return () => { | |
| 66 | if (left) return; | |
| 67 | left = true; | |
| 68 | heard.delete(fn); | |
| 69 | if (heard.size === 0) changeListeners.delete(boardId); | |
| 70 | }; | |
| 71 | } |
src/routes.ts +33 −13
| @@ -10,6 +10,7 @@ import * as db from "./db.ts"; | ||
| 10 | 10 | import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts"; |
| 11 | 11 | import { rpc } from "./mcp.ts"; |
| 12 | 12 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | import * as presence from "./presence.ts"; | |
| 13 | 14 | import { cleanText } from "./text.ts"; |
| 14 | 15 | import { noteRevision } from "./watch.ts"; |
| 15 | 16 | import type { Who } from "./who.ts"; |
| @@ -113,7 +114,15 @@ function boardBad( | ||
| 113 | 114 | const board = db.getBoard(boardId); |
| 114 | 115 | if (!board) return new Response(message, { status, headers: extraHeaders }); |
| 115 | 116 | return html( |
| 116 | boardPage({ board, columns: db.listColumns(boardId), cards: db.listCards(boardId), query: "", who, error: message }), | |
| 117 | boardPage({ | |
| 118 | board, | |
| 119 | columns: db.listColumns(boardId), | |
| 120 | cards: db.listCards(boardId), | |
| 121 | query: "", | |
| 122 | who, | |
| 123 | error: message, | |
| 124 | presence: presence.list(boardId), | |
| 125 | }), | |
| 117 | 126 | status, |
| 118 | 127 | extraHeaders, |
| 119 | 128 | ); |
| @@ -147,8 +156,8 @@ async function form(request: Request): Promise<FormData> { | ||
| 147 | 156 | // keeps every open tab well inside that. |
| 148 | 157 | const HEARTBEAT_MS = 25_000; |
outdated
HIGH — fix this first. This frame is per-board, so it counts the recipient's own connection; the first paint at line 250 does not. On the preview, one Fix: make the frame per-recipient, not per-board. Have |
||
| 149 | 158 | |
| 150 | /** `GET /b/:id/events`: one board's changes, as they are published. */ | |
| 151 | function eventStream(boardId: string, request: Request): Response { | |
| 159 | /** `GET /b/:id/events`: one board's changes, as they are published, plus who is on it. */ | |
| 160 | function eventStream(boardId: string, request: Request, who: Who): Response { | |
| 152 | 161 | if (bus.subscriberCount(boardId) >= MAX_SUBSCRIBERS_PER_BOARD) return new Response("too many open tabs on this board", { status: 503 }); |
| 153 | 162 | const encoder = new TextEncoder(); |
| 154 | 163 | let stop = (): void => {}; |
| @@ -161,17 +170,28 @@ function eventStream(boardId: string, request: Request): Response { | ||
| 161 | 170 | stop(); |
| 162 | 171 | } |
| 163 | 172 | }; |
| 164 | // `by` (a signed-in editor's forge id and display name) is for a | |
| 165 | // future "last edited by" on the page — not for the wire. Nothing | |
| 166 | // in static/board.js reads it today, so sending it would let anyone | |
| 167 | // holding a board's link sit on this stream and harvest the id and | |
| 168 | // name of every signed-in person who touches it. | |
| 169 | const send = ({ by: _by, ...wire }: bus.BoardEvent): void => write(`data: ${JSON.stringify(wire)}\n\n`); | |
| 173 | // `by` carries a signed-in editor's forge id and display name; only | |
| 174 | // the name reaches the wire, so a card can say who moved it (issue | |
| 175 | // #18) without letting anyone holding a board's link harvest the id | |
| 176 | // behind it too. | |
| 177 | const send = ({ by, ...wire }: bus.BoardEvent): void => write(`data: ${JSON.stringify({ ...wire, by: by?.name ?? null })}\n\n`); | |
| 170 | 178 | const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS); |
| 171 | const unsubscribe = bus.subscribe(boardId, send); | |
| 179 | const unsubscribeBus = bus.subscribe(boardId, send); | |
| 180 | const { leave: leavePresence, self } = presence.join(boardId, who.role === "anon" ? null : { id: who.id, name: who.name }); | |
| 181 | // A board's presence is a plain, named event so it can't be mistaken | |
| 182 | // for a card or column change by a listener that only knows `apply`. | |
| 183 | // Excludes this connection's own token — the same set a fresh page | |
| 184 | // load (not a member yet) would render, so a lone visitor's slot | |
| 185 | // reads empty here too instead of showing their own arrival back to | |
| 186 | // themselves. | |
| 187 | const sendPresence = (): void => write(`event: presence\ndata: ${JSON.stringify({ people: presence.list(boardId, self) })}\n\n`); | |
| 188 | const unsubscribePresence = presence.onChange(boardId, sendPresence); | |
| 189 | sendPresence(); // this connection's own view as of joining, not just future changes | |
| 172 | 190 | stop = () => { |
| 173 | 191 | clearInterval(heartbeat); |
| 174 | unsubscribe(); | |
| 192 | unsubscribeBus(); | |
| 193 | unsubscribePresence(); | |
| 194 | leavePresence(); | |
| 175 | 195 | try { |
| 176 | 196 | controller.close(); |
| 177 | 197 | } catch {} |
| @@ -256,12 +276,12 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s | ||
| 256 | 276 | const columns = db.listColumns(boardId); |
| 257 | 277 | const cards = db.listCards(boardId); |
| 258 | 278 | const query = url.searchParams.get("q") ?? ""; |
| 259 | return html(boardPage({ board, columns, cards, query: query.slice(0, 200), who })); | |
| 279 | return html(boardPage({ board, columns, cards, query: query.slice(0, 200), who, presence: presence.list(boardId) })); | |
| 260 | 280 | } |
| 261 | 281 | |
| 262 | 282 | if (method === "GET" && parts.length === 3 && parts[2] === "events") { |
| 263 | 283 | if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 }); |
| 264 | return eventStream(boardId, request); | |
| 284 | return eventStream(boardId, request, who); | |
| 265 | 285 | } |
| 266 | 286 | |
| 267 | 287 | if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 }); |
src/server.test.ts +64 −6
| @@ -387,6 +387,7 @@ test("streams a live event over /events when a card is moved by another request" | ||
| 387 | 387 | let event: { kind: string; boardId: string; ids: Record<string, string> } | undefined; |
| 388 | 388 | for (let i = 0; i < 5 && !event; i++) { |
| 389 | 389 | const frame = await nextFrame(); |
| 390 | if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one | |
| 390 | 391 | const data = frame.match(/^data: (.*)$/m)?.[1]; |
| 391 | 392 | if (data) event = JSON.parse(data); |
| 392 | 393 | } |
| @@ -396,7 +397,11 @@ test("streams a live event over /events when a card is moved by another request" | ||
| 396 | 397 | await reader.cancel(); |
| 397 | 398 | }); |
| 398 | 399 | |
| 399 | test("a signed-in editor's forge id and name never reach the stream, only the change itself", async () => { | |
| 400 | // A signed-in editor's forge id never reaches the stream — presence and | |
| 401 | // attribution (issue #18) only ever need a name, never an id, so the id | |
| 402 | // stays off the wire while the name (needed for both) now legitimately | |
| 403 | // reaches it. | |
| 404 | test("a signed-in editor's forge id never reaches the stream, but their name does, for attribution", async () => { | |
| 400 | 405 | const id = await newBoard(); |
| 401 | 406 | const boardUrl = `/b/${id}`; |
| 402 | 407 | const page = await (await fetch(base + boardUrl)).text(); |
| @@ -420,14 +425,61 @@ test("a signed-in editor's forge id and name never reach the stream, only the ch | ||
| 420 | 425 | |
| 421 | 426 | await post(`${boardUrl}/columns/${columnId}/cards`, { title: "Alice's card" }, { ...j, ...identityHeaders("secret-id", "alice") }); |
| 422 | 427 | let frame = ""; |
| 423 | for (let i = 0; i < 5 && !frame.includes("data:"); i++) frame = await nextFrame(); | |
| 424 | expect(frame).toContain("data:"); | |
| 428 | for (let i = 0; i < 5 && !frame.includes('"kind"'); i++) frame = await nextFrame(); | |
| 429 | expect(frame).toContain('"kind"'); | |
| 425 | 430 | expect(frame).not.toContain("secret-id"); |
| 426 | expect(frame).not.toContain("alice"); | |
| 427 | expect(frame).not.toContain('"by"'); | |
| 431 | expect(frame).toContain('"by":"alice"'); | |
| 428 | 432 | await reader.cancel(); |
| 429 | 433 | }); |
| 430 | 434 | |
| 435 | test("the presence frame reaches a connected client, and updates as a second one joins and leaves", async () => { | |
| 436 | const id = await newBoard(); | |
| 437 | const boardUrl = `/b/${id}`; | |
| 438 | ||
| 439 | function presenceReaderFor(stream: Response): { next: () => Promise<{ people: (string | null)[] }>; cancel: () => Promise<void> } { | |
| 440 | const reader = stream.body!.getReader(); | |
| 441 | const decoder = new TextDecoder(); | |
| 442 | let buffer = ""; | |
| 443 | return { | |
| 444 | next: async () => { | |
| 445 | for (let i = 0; i < 10; i++) { | |
| 446 | while (!buffer.includes("\n\n")) { | |
| 447 | const { value, done } = await reader.read(); | |
| 448 | if (done) throw new Error("stream ended"); | |
| 449 | buffer += decoder.decode(value, { stream: true }); | |
| 450 | } | |
| 451 | const at = buffer.indexOf("\n\n"); | |
| 452 | const frame = buffer.slice(0, at); | |
| 453 | buffer = buffer.slice(at + 2); | |
| 454 | if (frame.startsWith("event: presence")) return JSON.parse(frame.match(/^data: (.*)$/m)![1]); | |
| 455 | } | |
| 456 | throw new Error("no presence frame arrived"); | |
| 457 | }, | |
| 458 | cancel: () => reader.cancel(), | |
| 459 | }; | |
| 460 | } | |
| 461 | ||
| 462 | const stream = await fetch(base + `${boardUrl}/events`); | |
| 463 | const first = presenceReaderFor(stream); | |
| 464 | ||
| 465 | // Alone on the board, this connection's own frame excludes itself — the | |
| 466 | // same empty slot a fresh (non-streaming) page load would render. | |
| 467 | expect((await first.next()).people).toEqual([]); | |
| 468 | ||
| 469 | const second = new AbortController(); | |
| 470 | const secondStream = await fetch(base + `${boardUrl}/events`, { signal: second.signal, headers: identityHeaders("u1", "alice") }); | |
| 471 | expect(secondStream.status).toBe(200); | |
| 472 | const other = presenceReaderFor(secondStream); | |
| 473 | ||
| 474 | // Each of the two sees exactly the other, never itself. | |
| 475 | expect((await first.next()).people).toEqual(["alice"]); | |
| 476 | expect((await other.next()).people).toEqual([null]); | |
| 477 | ||
| 478 | second.abort(); | |
| 479 | expect((await first.next()).people).toEqual([]); | |
| 480 | await first.cancel(); | |
| 481 | }); | |
| 482 | ||
| 431 | 483 | test("a closed client leaves no subscriber behind", async () => { |
| 432 | 484 | const id = await newBoard(); |
| 433 | 485 | expect(subscriberCount(id)).toBe(0); |
| @@ -510,6 +562,7 @@ test("an add_card call over MCP publishes a card.added frame, same as the page's | ||
| 510 | 562 | let event: { kind: string; boardId: string; ids: Record<string, string> } | undefined; |
| 511 | 563 | for (let i = 0; i < 5 && !event; i++) { |
| 512 | 564 | const frame = await nextFrame(); |
| 565 | if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one | |
| 513 | 566 | const data = frame.match(/^data: (.*)$/m)?.[1]; |
| 514 | 567 | if (data) event = JSON.parse(data); |
| 515 | 568 | } |
| @@ -892,6 +945,10 @@ test("list_boards over MCP only ever shows the caller's own boards", async () => | ||
| 892 | 945 | // open /events stream hears about a move it could never have heard about |
| 893 | 946 | // any other way. |
| 894 | 947 | test("a card moved by a second process (standing in for the ssh terminal) reaches an open stream", async () => { |
| 948 | // This file alone creates enough boards that, this late in it, the | |
| 949 | // process-wide creation bucket (src/limits.ts) can already be spent by | |
| 950 | // whichever test ran before it — irrelevant to what this test checks. | |
| 951 | boardCreationBucket.reset(); | |
| 895 | 952 | const id = await newBoard(); |
| 896 | 953 | const page = await (await fetch(base + `/b/${id}`)).text(); |
| 897 | 954 | const [todoId, doingId] = [...page.matchAll(/data-column-id="([a-z0-9]{12})"/g)].map((m) => m[1]); |
| @@ -937,11 +994,12 @@ test("a card moved by a second process (standing in for the ssh terminal) reache | ||
| 937 | 994 | let event: { kind: string; boardId: string; by: unknown } | undefined; |
| 938 | 995 | for (let i = 0; i < 10 && !event; i++) { |
| 939 | 996 | const frame = await nextFrame(); |
| 997 | if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one | |
| 940 | 998 | const data = frame.match(/^data: (.*)$/m)?.[1]; |
| 941 | 999 | if (data) event = JSON.parse(data); |
| 942 | 1000 | } |
| 943 | 1001 | expect(event?.kind).toBe("external"); |
| 944 | 1002 | expect(event?.boardId).toBe(id); |
| 945 | expect(event).not.toHaveProperty("by"); | |
| 1003 | expect(event?.by).toBeNull(); // no signed-in editor made this move — it came from the terminal's own process | |
| 946 | 1004 | await reader.cancel(); |
| 947 | 1005 | }, 10000); |
static/board.351899687.js → static/board.232693062.js renamed +44 −1
| @@ -387,7 +387,10 @@ | ||
| 387 | 387 | deleteForm.appendChild(delBtn); |
| 388 | 388 | |
| 389 | 389 | row.append(moveForm, deleteForm); |
| 390 | li.append(editForm, row); | |
| 390 | const by = document.createElement("p"); | |
| 391 | by.className = "by"; | |
| 392 | by.setAttribute("aria-hidden", "true"); | |
| 393 | li.append(editForm, row, by); | |
| 391 | 394 | return li; |
| 392 | 395 | } |
| 393 | 396 | |
| @@ -529,6 +532,7 @@ | ||
| 529 | 532 | targetColumn.querySelector(".cards").insertBefore(document.importNode(freshCard, true), liveSiblings[at] || null); |
| 530 | 533 | updateCounts(targetColumn, previousColumn); |
| 531 | 534 | if (filterInput) applyFilter(); |
| 535 | markAttribution(ids.cardId, event.by); | |
| 532 | 536 | return; |
| 533 | 537 | } |
| 534 | 538 | |
| @@ -540,6 +544,7 @@ | ||
| 540 | 544 | const input = card.querySelector(".edit input"); |
| 541 | 545 | if (input) input.value = freshCard.dataset.title; |
| 542 | 546 | if (filterInput) applyFilter(); |
| 547 | markAttribution(ids.cardId, event.by); | |
| 543 | 548 | return; |
| 544 | 549 | } |
| 545 | 550 | |
| @@ -625,12 +630,50 @@ | ||
| 625 | 630 | if (reconnectBtn) reconnectBtn.hidden = false; |
| 626 | 631 | } |
| 627 | 632 | |
| 633 | // ---- presence: who else has this board open, and who moved a card ---- | |
| 634 | // A named SSE event, not a bus event kind — presence isn't a change to | |
| 635 | // the board, so it never runs through apply()/applyNow() above. | |
| 636 | const presenceEl = document.getElementById("presence"); | |
| 637 | function renderPresence(people) { | |
| 638 | if (!presenceEl) return; | |
| 639 | // The same signed-in name in two tabs is one person, not two — a | |
| 640 | // guest tab has no name to dedupe by, so it only ever adds to the count. | |
| 641 | const names = [...new Set(people.filter((p) => p !== null))]; | |
| 642 | const guestCount = people.length - people.filter((p) => p !== null).length; | |
| 643 | const parts = names.slice(); | |
| 644 | if (guestCount === 1) parts.push("Guest"); | |
| 645 | else if (guestCount > 1) parts.push(`${guestCount} guests`); | |
| 646 | presenceEl.textContent = parts.join(", "); // textContent — never innerHTML — so a name needs no escaping here either | |
| 647 | } | |
| 648 | ||
| 649 | // A card changed by someone else (never by this tab — its own writes | |
| 650 | // never reach apply(), see the client-id check above) is briefly | |
| 651 | // marked with their name, and announced once for a screen reader. | |
| 652 | const attributionLive = document.getElementById("live-attribution"); | |
| 653 | function markAttribution(cardId, name) { | |
| 654 | if (!name) return; | |
| 655 | const card = cardEl(cardId); | |
| 656 | const tag = card && card.querySelector(".by"); | |
| 657 | if (!tag) return; | |
| 658 | tag.textContent = `by ${name}`; | |
| 659 | // Restart the fade even if it's already mid-fade from an earlier | |
| 660 | // change: drop to the transition-less "just shown" state on one | |
| 661 | // frame, then let the .show removal (next frame) animate back down. | |
| 662 | tag.classList.remove("show"); | |
| 663 | void tag.offsetWidth; | |
| 664 | tag.classList.add("show"); | |
| 665 | clearTimeout(tag.fadeTimer); | |
| 666 | tag.fadeTimer = setTimeout(() => tag.classList.remove("show"), 4000); | |
| 667 | if (attributionLive) attributionLive.textContent = `${name} changed "${card.dataset.title}"`; | |
| 668 | } | |
| 669 | ||
| 628 | 670 | function wireMessages(es) { |
| 629 | 671 | es.onmessage = (message) => { |
| 630 | 672 | const event = JSON.parse(message.data); |
| 631 | 673 | if (event.client && event.client === clientId) return; // this tab already applied it optimistically |
| 632 | 674 | apply(event); |
| 633 | 675 | }; |
| 676 | es.addEventListener("presence", (message) => renderPresence(JSON.parse(message.data).people)); | |
| 634 | 677 | } |
| 635 | 678 | |
| 636 | 679 | // A dropped connection reconnects on its own; the reconnect itself |
LOW — optimistic. The heartbeat is 25s, but reaping depends on the enqueue actually failing, and a black-holed socket waits out the 60s idle timeout in server.ts. "within a minute" is the honest number.