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
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
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.
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.
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.
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.
Board lifecycle
Create & join
Cards & reveal
Realtime
Push — one per open browser
Discovery — for LLMs & agents, not the app itself
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.
Board mutations
createBoard()joinBoard()addCard()deleteCard()revealBoard()Concurrency safety
mutateBoard() retry loopcasSet — Lua compare-and-swapRedis connection roles
getClient()getPublisher()createSubscriber()Rate limiting
enforceRateLimits() — fails closed on first key overcheckRateLimit() — fixed window, INCR+EXPIREgetClientIp()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.
board:{id} — state (JSON blob)
board:{id}:events — channel
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.