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-cf
A free, no-signup planning poker room on Cloudflare — one Durable Object per room 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.
room defaults — src/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 live connection — WebSocket, falling back to SSE if the first attempt fails (transports.js) — with its own reconnect/backoff, plus topic input, participant list, card deck, controls, stats, 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 from the Vercel version. Shared styling is CSS custom properties on :root, consumed through each component's Shadow DOM.
Everything except /api/*. Unknown paths (room links) fall back to index.html; /architecture resolves to this page.
Routes, validates bodies, checks the creator key, asks the Limiter, then calls the Room 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.
Room lifecycle
Create & join
Voting
Realtime
Push — one per open browser
Discovery — for LLMs & agents, not the app itself
Every mutation route is rate-limited on two keys — participantId+room (counted inside the Room object) and source IP+room (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-room requests are also capped per IP across every room route.
Room — one per room id
create() / join()vote() / reveal()newRound() / setTopic()stats() — counts for the admin dashboard, read onlyfetch() — 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 room is createdlist() / remove()AdminApi over a service binding, with no public routeEvery state change goes through #commit() — the only place that persists the room and sends the new full state to every open WebSocket and SSE stream. A mutation that changes nothing (e.g. re-casting the same vote) skips it entirely.
"room" — state (one value per Room)
ctx.getWebSockets() + this.streams
The alarm closes the room 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.