pr-app
Reads location.pathname once on load, decides landing vs. board. Navigation is a real location.href change, not client-side routing.
proto-retro-cf
A free, no-signup team retro board on Cloudflare — one Durable Object per board owns its state and its live connections — hibernatable WebSockets by default, Server-Sent Events as a fallback. No Redis, no third-party realtime vendor, no accounts.
board defaults — src/board.js
Reads location.pathname once on load, decides landing vs. board. Navigation is a real location.href change, not client-side routing.
Posts to /api/boards or /api/boards/:id/join, caches identity in localStorage keyed per board — plus the owner token, for the browser that created the board.
Owns the live connection — WebSocket, falling back to SSE if the first attempt fails (transports.js) — with its own reconnect/backoff, plus the participant strip, the 3 fixed columns, add-card forms, card lists, delete affordance, reveal and close controls, the time left, the closing countdown and the 404 screen (not-found.js) — one component by design.
No framework, no build step — plain .js loaded as native ES modules, unchanged in shape from the Vercel version. Shared styling is CSS custom properties on :root, consumed through each component's Shadow DOM. Pre-reveal, another participant's card renders as a face-down .card-back placeholder — the DOM node exists, its text just isn't rendered.
Everything except /api/* and the discovery files. Unknown paths (board links) fall back to index.html; /architecture and /about resolve to their pages.
Routes, validates bodies, checks the creator key, asks the Limiter, then calls the Board over RPC (or forwards the WebSocket upgrade / SSE request to it).
Runs the same Worker and Durable Objects in a local workerd — no external services to start.
Board lifecycle
Create, join & close
Cards & reveal
Realtime
Push — one per open browser
Discovery — for LLMs & agents, not the app itself
DELETE .../cards/:cardId is the one non-POST mutation, as in the original — a card has real per-entity identity you're addressing by id. close is the one owner-only action: it needs the ownerToken returned on create, not a participantId.
Every mutation route is rate-limited on two keys — participantId+board (counted inside the Board object) and source IP+board (counted in the Limiter) — answering 429 + Retry-After if either is over. The IP layer exists because participantId is self-issued and never authenticated. Unknown-board requests are also capped per IP across every board route.
Board — one per board id
create() / join()addCard() / deleteCard()stats() — counts for the admin dashboard, read onlyreveal()fetch() — accepts a hibernatable WebSocket or opens an SSE streamConcurrency & fan-out
#commit() — persist + broadcastalarm() — 2-hour limitclose() — owner token onlyLimiter — one per client IP
check() — fails on first key overrecordMiss()Registry — one in total
add() — after a board is createdlist() / remove()AdminApi over a service binding, with no public routeEvery state change goes through #commit() — the only place that persists the board and sends the new full state to every open WebSocket and SSE stream. A mutation that changes nothing (revealing an already-revealed board) skips it entirely. deleteCard() collapses "no such card" and "wrong author" into one rejection.
"board" — state (one value per Board)
ctx.getWebSockets() + this.streams
The alarm closes the board 2 hours after creation, 5 seconds after telling clients to start a countdown — nothing can extend it. The creator can also close it early with the owner token returned on create (its hash lives under a separate key, never broadcast). Closing deletes storage and disconnects everyone with a reason code (4408 2-hour limit, 4410 closed by host).
Card visibility (hidden until revealed) is a client-side render rule only — every card's real text is in the broadcast state at all times; pr-board just chooses not to render it pre-reveal.