spotube/jam-server/README.md
Kingkor Roy Tirtho 0a0b0c9374 feat(jam): replace MQTT with a Durable Object room server and synced playback
- Add jam-server/: a Cloudflare Worker with one Durable Object per room code.
  It tracks membership, host handoff and kicks/bans, keeps the queue for late
  joiners, routes guest commands to the host and serves the room clock.
- Sync playback: devices estimate the server clock, the host sends position
  anchors in server time, guests follow by nudging playback rate (seeking only
  when far off) and stream the host's audio source when they share a plugin.
- Seek, play/pause, skip and jump apply to the whole room.
- Recover from network loss instead of crashing: socket failures reconnect
  with backoff, silent sockets are detected, and an expired room is recreated
  by its host.
- Keep cost and battery low: anchors only on change (plus host drift and a
  60 s heartbeat), kept in memory rather than storage, free runtime keep-alives
  once a minute, rare clock re-syncs, and no polling while paused or buffering.
- Remove the MQTT broker settings and libraries.
- Add sync unit tests, client/session integration tests (JAM_TEST_URL) and a
  network-loss test, plus a deploy workflow for the server.
2026-10-31 16:26:41 +06:00

2.9 KiB

Spotube Jam server

The room server behind Group Jam: a Cloudflare Worker with one Durable Object per room code.

Each room relays a few small JSON frames between the host and its guests. Nobody streams audio through it (every device fetches its own), so a room costs a few KB per minute.

Rooms are billed per incoming message (20 count as one request) and per storage write, so steady playback sends almost nothing:

  • The host sends a playback anchor only when playback changes (play/pause, seek, skip, speed), when its player drifts from its last anchor, and once a minute as a safety net. Guests extrapolate anchors with the shared clock in between.
  • Anchors are kept in memory, not storage. A room that hibernated wakes for a join, and the host answers the join with a fresh anchor. Only queue changes and membership bookkeeping are written.
  • Keep-alives ("ping" → "pong") are answered by the runtime without waking the room. Clock pings go out on join and every 10 minutes.

A four-person room sends roughly 200 messages an hour (about 10 billed requests).

What a room does

  • Membership: tracks who is connected, who hosts, and who is banned. If the host drops, it keeps hosting for 30 s (so it can reconnect, e.g. after switching networks), then the longest-connected guest takes over.
  • Retained state: keeps the host's latest queue (and, while awake, its playback anchor) so late joiners start in sync. An empty room keeps them for 10 minutes, then deletes itself.
  • Routing: guest commands (play/pause, skip, seek, suggestions) go to the host. Kicks and bans are enforced by the server.
  • Clock: answers pings with its own time. Every device syncs playback to this one clock (see JamClock in the app).

The wire protocol is in src/protocol.ts. The app side is composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/.

Develop

npm install
npm run dev          # http://127.0.0.1:8787
npm test             # smoke test against the dev server (another terminal)
npm run typecheck

The app's end-to-end tests run against it too:

JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest --tests 'org.spotube.spotube.core.jam.*'

Deploy

CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... npx wrangler deploy

The token needs the "Edit Cloudflare Workers" permissions. Durable Objects on the SQLite backend work on the free plan. .github/workflows/jam-server-deploy.yml deploys on pushes to master that touch this directory (secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID; optional variable JAM_SERVER_URL runs the smoke test after).

Self-host

Deploy to your own Cloudflare account as above, then enter the Worker's URL in Spotube → Settings → Jam → Server URL.

Limits

  • 32 participants per room, 512 KB per frame.
  • 60 room connections per minute per IP (CONNECT_LIMITER in wrangler.jsonc).