Status: live at radio-api.deets.solutions · Platform: Cloudflare Worker + Durable Objects + D1
Cloudflare Worker behind the DeetsRadio tab on deets.solutions — sibling to DeetsSolutions (the site), DeetsLeague (the League tab's worker), and DeetsMusic (the desktop app the radio UX is ported from). Design doc: DeetsSolutions/docs/radio.md.
One Durable Object per listening room: hibernatable WebSockets carry the
room protocol (communal queue, synced play/pause/skip/back, shared history), a
storage alarm advances tracks so an empty room keeps "playing", and
GET /room/{code}/peek powers the site's create-confirm gate. The room owns a
clock, never audio — playback happens per-listener in the browser through
Apple Music (or the 30 s preview tier). Each room has a background owner
(creator, then join-order succession; connection-scoped) whose one power is
close — sign the station off and free the code — and the same alarm slot
doubles as a 1-hour idle+empty expiry fuse, so abandoned rooms return
their names to the pool.
No dependencies, no build step, no D1, no KV. Free tier covers it: WS messages bill 20:1 under hibernation, presence broadcasts persist nothing, and each mutation is one batched storage write (room history rides the DO's private SQLite).
JSON messages over GET /room/{code}/ws. Queue verbs need the queue
capability at e, player verbs need player at e (owner always has both);
the rest are open to any joined listener except the owner-only trio and
close. A literal ping is auto-answered pong by the platform without
waking the object.
Client → server:
| Type | Payload | Does |
|---|---|---|
join |
name, token, create? |
Enter (or, with create, mint) the room; replies with snapshot |
rename |
name |
Change display name (unique per room) |
play / pause / skip / back |
— | Shared transport; starts count down LEAD ms |
add |
entry, at?, source? |
Queue a sanitized entry (cap 200); first add while idle starts play |
remove |
entryId |
Drop a queued entry |
reorder |
entryId, to |
Move a queued entry |
resolve |
entryId, youtube, source? |
Backfill a missing YouTube block (never overwrites) |
setVideo |
entryId, youtube |
Overwrite an entry's video (manual pick) |
setSong |
entryId, song |
Re-pin an entry's Apple identity |
setCap |
t, cap, level |
Owner only: grant queue/player at r|e to handle t |
kick |
t |
Owner only: disconnect a listener (no ban) |
setMode |
mode |
Owner only: open | restricted (capability defaults) |
close |
— | Owner only: sign the station off, free the code |
Server → client:
| Type | Carries | When |
|---|---|---|
snapshot |
v, serverNow, room, transport, current, queue, history, listeners, owner, you |
Reply to a successful join |
state |
v, serverNow, plus only the changed fields |
Broadcast after every mutation |
presence |
listeners, owner, you |
Join / leave / rename / cap or mode change |
error |
code: rate, name-taken, full, no-room, perm |
Refused message (join refusals also close the socket) |
kicked |
serverNow |
You were kicked (socket closes) |
closed |
serverNow |
Owner closed the room (socket closes) |
npx wrangler dev --port 8789
# then open http://localhost:8787/radio/?api=http://127.0.0.1:8789
# (the site itself runs from DeetsSolutions via python -m http.server 8787)
curl "http://127.0.0.1:8789/room/test-room/peek"
npx wrangler login # one-time
npx wrangler deploy # custom domain radio-api.deets.solutions rides wrangler.jsonc
No secrets in v0.9 (Apple search runs in the browser on the origin-locked developer token; Spotify — which would put a client-credentials secret here — is a v1.0 flag away).
MIT — see LICENSE.