proto-retro

System Architecture

A free, no-signup team retro board — real-time card sync over Server-Sent Events and Redis pub/sub. No WebSockets, no third-party realtime vendor, no accounts.

board defaults — api/_lib/board.js

board id
→ adjective-noun-### slug
TTL
→ 4h inactivity, refreshed per write
columns
→ went-well, to-improve, action-items
dev port
→ :3211 (vercel 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.

pr-board

everything else

Owns the EventSource connection, participant strip, the 3 fixed columns, add-card forms, card lists, delete affordance, and the reveal control — one component by design.

No framework, no build step — plain .js loaded as native ES modules. 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 / SSE
L2 · DEPLOYMENT

Vercel

Local

vercel dev · :3211

pnpm run dev:up starts local Redis (:6379) + vercel dev together, idempotent by port. Port 3211 (not 3210) so it can run alongside a local proto-poker dev server.

Production

Vercel

Deployed on Vercel. Pushes to main auto-deploy via the connected GitHub repo.

Same handlers either way — deployment only changes which Redis instance REDIS_URL points at. Production shares its Upstash Redis instance with proto-poker; keys are namespaced (board:* vs room:*) so the two apps never collide.

routes
L3 · API

/api/boards — plain Vercel Functions, Node.js runtime

Board lifecycle

Create & join

POST /api/boardsGET /api/boards/:idPOST /api/boards/:id/join

Cards & reveal

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

Realtime

Push — one per open browser

GET /api/boards/:id/stream

Discovery — for LLMs & agents, not the app itself

/llms.txt/manifest.json/architecture

stream.js is the one route pinned to the Node.js runtime (not Edge) — holding a live Redis SUBSCRIBE needs a raw TCP socket, which Edge can't do. The discovery files are static (no function behind them) — the SPA's raw HTML is nearly empty until JS runs, so they exist to give something with actual content to fetch.

cards/[cardId].js uses DELETE, not POST — the one deliberate divergence from an all-POST mutation convention, since a card (unlike any poker action) has real per-entity identity you're addressing by id.

Every mutation route (add card, delete card, reveal, plus join) carries participantId and is rate-limited on two keys — participantId+board and source IP+board — failing closed with 429 + Retry-After if either is over. The IP layer exists because participantId is self-issued and never authenticated: a script can mint a fresh one per request for free, so it alone isn't a real defense.

calls
L4 · CORE

api/_lib/ — board.js, redis.js & rateLimit.js

Board mutations

  • createBoard()
  • joinBoard()
  • addCard()
  • deleteCard()
  • revealBoard()

Concurrency safety

  • mutateBoard() retry loop
  • casSet — Lua compare-and-swap
  • jittered backoff on conflict

Redis connection roles

  • getClient()
  • getPublisher()
  • createSubscriber()

Rate limiting

  • enforceRateLimits() — fails closed on first key over
  • checkRateLimit() — fixed window, INCR+EXPIRE
  • getClientIp()

Every mutation goes through mutateBoard() — the only place that also publishes, and the only place that retries a lost compare-and-swap. Skip it and clients silently desync or lose a card under contention. cardId is generated outside the retried callback (like poker's participantId) so it stays identical across every retry attempt instead of regenerating and orphaning a card on a contended write. deleteCard() collapses "no such card" and "wrong author" into one generic rejection.

reads / writes
L5 · DATA

Upstash Redis

board:{id} — state (JSON blob)

idslug
columnsfixed, 3
cardsid → card
revealedboolean
participantsname only
updatedAtms timestamp
PUBLISH
on every write

board:{id}:events — channel

SUBSCRIBE×N open browsers

Board keys carry a 4h TTL, refreshed on every write — no accounts means no owner to explicitly delete a board, so inactivity is what cleans things up. 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.