proto-retro-cf

System Architecture

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

board id
→ adjective-noun-xxxxxxxx (random tail)
closes
→ 2h after creation
columns
→ went-well, to-improve, action-items
dev port
→ :8787 (wrangler dev)
L1 · CLIENT

Web Components — public/components/

pr-app

router

Reads location.pathname once on load, decides landing vs. board. Navigation is a real location.href change, not client-side routing.

pr-landing

create & join

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.

pr-board

everything else

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.

POST / DELETE / WebSocket or SSE
L2 · EDGE

Cloudflare Workers — wrangler.jsonc

Static assets

public/ · no Worker invocation

Everything except /api/* and the discovery files. Unknown paths (board links) fall back to index.html; /architecture and /about resolve to their pages.

Worker

src/index.js · run_worker_first: /api/*

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).

Local

wrangler dev · :8787

Runs the same Worker and Durable Objects in a local workerd — no external services to start.

routes
L3 · API

/api/boards — the Vercel surface, plus /ws and /close

Board lifecycle

Create, join & close

POST /api/boardsGET /api/boards/:idPOST /api/boards/:id/joinPOST .../close

Cards & reveal

POST .../cardsDELETE .../cards/:cardIdPOST .../reveal

Realtime

Push — one per open browser

GET /api/boards/:id/wsGET /api/boards/:id/stream

Discovery — for LLMs & agents, not the app itself

/llms.txt/manifest.json/architecture

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.

RPC / fetch
L4 · CORE

Durable Objects — src/board.js, src/limiter.js & src/registry.js

Board — one per board id

  • create() / join()
  • addCard() / deleteCard()
  • stats() — counts for the admin dashboard, read only
  • reveal()
  • fetch() — accepts a hibernatable WebSocket or opens an SSE stream

Concurrency & fan-out

  • #commit() — persist + broadcast
  • single-threaded per board: no CAS, no retries
  • alarm() — 2-hour limit
  • close() — owner token only

Limiter — one per client IP

  • check() — fails on first key over
  • recordMiss()
  • fixed windows, alarm cleans up
  • rejected requests write nothing

Registry — one in total

  • add() — after a board is created
  • list() / remove()
  • read by AdminApi over a service binding, with no public route

Every 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.

reads / writes
L5 · DATA

Durable Object storage (SQLite-backed)

"board" — state (one value per Board)

idslug
columnsfixed, 3
cardsid → card
revealedboolean
participantsname only
updatedAt+ closesAt
→ send to every
socket & stream

ctx.getWebSockets() + this.streams

sockets×N · survive hibernation
streams×N SSE · keep the board awake

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.