proto-poker-cf

System Architecture

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

room id
→ adjective-noun-xxxxxxxx (random tail)
closes
→ 2h after creation
deck
→ 1,2,3,5,8,13,21,♠,?
dev port
→ :8787 (wrangler dev)
L1 · CLIENT

Web Components — public/components/

pp-app

router

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

pp-landing

create & join

Posts to /api/rooms or /api/rooms/:id/join, caches identity in localStorage keyed per room.

pp-room

everything else

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.

POST / WebSocket or SSE
L2 · EDGE

Cloudflare Workers — wrangler.jsonc

Static assets

public/ · no Worker invocation

Everything except /api/*. Unknown paths (room links) fall back to index.html; /architecture resolves to this page.

Worker

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

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

Local

wrangler dev · :8787

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

routes
L3 · API

/api/rooms — the Vercel surface, plus /ws

Room lifecycle

Create & join

POST /api/roomsGET /api/rooms/:idPOST /api/rooms/:id/join

Voting

POST .../votePOST .../revealPOST .../new-roundPOST .../topic

Realtime

Push — one per open browser

GET /api/rooms/:id/wsGET /api/rooms/:id/stream

Discovery — for LLMs & agents, not the app itself

/llms.txt/manifest.json/architecture

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.

RPC / fetch
L4 · CORE

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

Room — one per room id

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

Concurrency & fan-out

  • #commit() — persist + broadcast
  • single-threaded per room: 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 room 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 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.

reads / writes
L5 · DATA

Durable Object storage (SQLite-backed)

"room" — state (one value per Room)

idslug
topiceditable string
deckcard values
revealedboolean
participantsname + vote
updatedAtms timestamp
→ send to every
socket & stream

ctx.getWebSockets() + this.streams

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

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.