pp-app
Reads location.pathname once on load, decides landing vs. room. Navigation is a real location.href change, not client-side routing.
proto-poker
A free, no-signup planning poker room — real-time voting synced over Server-Sent Events and Redis pub/sub. No WebSockets, no third-party realtime vendor, no accounts.
room defaults — api/_lib/room.js
Reads location.pathname once on load, decides landing vs. room. Navigation is a real location.href change, not client-side routing.
Posts to /api/rooms or /api/rooms/:id/join, caches identity in localStorage keyed per room.
Owns the EventSource connection, topic input, participant list, card deck, controls, and stats — 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.
pnpm run dev:up starts local Redis (:6379) + vercel dev together, idempotent by port.
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.
Room lifecycle
Create & join
Voting
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.
Every mutation route (vote, reveal, new-round, topic, plus join) carries participantId and is rate-limited on two keys — participantId+room and source IP+room — 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.
Room mutations
createRoom()joinRoom()castVote()revealVotes() / newRound()setTopic()Concurrency safety
mutateRoom() 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 mutateRoom() — 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 vote under contention. A mutation callback can also return NO_CHANGE (e.g. re-casting the same vote) to skip the write and PUBLISH without signaling an error.
room:{id} — state (JSON blob)
room:{id}:events — channel
Room keys carry a 4h TTL, refreshed on every write — no accounts means no owner to explicitly delete a room, so inactivity is what cleans things up.