Blob: worker/README.md
Exhibit application
The React frontend and Cloudflare Worker for Pocket Radio. It uses React 19, TypeScript, Vite with the Cloudflare plugin, Tailwind CSS v4, Hono, Zod, Floating UI, and Prettier. Versions are pinned in package.json and the lockfile. Vite bundles IBM Plex Sans and Mono from the Fontsource dependencies for self-hosting. Their license notices are included in the public assets.
Local development
For frontend-only work, use Node 24 and run from the repository root:
make setup-web
make devOpen http://localhost:11880. This starts a local Worker with independent room
state; a board publishing to a deployed Worker will appear offline here.
For frontend work with that board, prepare credentials with the
root setup and use the explicit live API proxy instead.
The firmware toolchain is needed only when building or flashing the board:
export SIGNALING_URL=https://radio.example.com
make dev-liveUse your deployed origin. The proxy forwards viewer APIs, including login, to that Worker. It requires the viewer password; device endpoints are not proxied. Audio and data travel through the SFU. Only one dev server can use port 11880 at a time.
For a phone, allow the development machine's LAN IP or a hostname that resolves
on both devices. Put the value in ignored worker/.env.local:
RADIO_DEV_HOSTS=192.168.1.20Replace the example with your machine's address, restart the dev server, and
open http://192.168.1.20:11880 on the phone. Comma-separated entries configure
both Vite and the proxy; use exact hostnames/IPs without schemes or ports.
The phone must reach the development machine on its LAN. Loopback hosts are
allowed by default; the Worker's local authentication bypass is limited to them.
Deployment
Use a Cloudflare account with an active zone for your domain.
Authenticate from worker/ using the installed CLI:
./node_modules/.bin/wrangler loginIn wrangler.jsonc, replace the routes hostname with one you
own, keeping custom_domain: true. For example:
{ "routes": [{ "pattern": "radio.example.com", "custom_domain": true }] }Keep the Worker name esp32-radio: the build/deploy helpers use its generated
dist/esp32_radio/ directory. The firmware's exported SIGNALING_URL must match
the configured HTTPS origin. After preparing credentials with make secrets,
run from the repository root:
make check
make test-worker
make test-worker-bundle
make deploy-dry-run
make deployThe deploy helper reads the four declared secrets directly from the root
.credential.env and uses Vite's generated Worker configuration. Only
dist/client/ contains public assets; Worker output
can contain a private .dev.vars for local preview. Do not publish dist/ as
an unfiltered directory.
Compatible deployments preserve RobotRoom's class name, migration history,
and stored state. Changes to persisted state need a migration and rollback plan.
To roll back, run ./node_modules/.bin/wrangler rollback <version-id> from
worker/, after checking the compatibility and cleanup constraints.
Code map
| Location | Responsibility |
|---|---|
| src/client/App.tsx | Application shell and radio lifecycle |
| src/client/exhibit/ | Board illustration, panels, diagram and explanations |
| src/client/radio/ | Session ownership, React subscription, typed API and decoding |
| src/client/ui/ | Icons, login and display formatting |
| src/shared/contracts/ | Zod schemas and inferred wire types |
| src/server/index.ts, routes/ | Authentication and Hono routing |
| robot-room.ts, room-state.ts | Durable Object coordination and persisted state |
| sfu.ts | SFU timeouts, allocation receipts and validation |
| auth.ts, http.ts, rpc.ts | Cookies, input validation and serializable errors |
Session and media flow
The exhibit passes each panel only the radio-state fields it needs; panels do
not receive the complete RadioSession. use-radio.ts creates the session,
subscribes React to its snapshots and attaches the audio element. session.ts
manages the peer, audio playback, connection lifecycle, timers, command
acknowledgments and cleanup. playback.ts accepts metadata updates in revision
order and combines them with telemetry to produce the displayed playback state.
The spectrum canvas reads the board's 25 Hz sample buffer directly, while React state updates at a lower frequency. The Worker and Durable Object authenticate users, allocate sessions for up to eight listeners and manage control permission. WebRTC audio and data-channel packets pass through the SFU; the Worker and Durable Object handle signaling and coordination.
Viewer login issues a signed, 24-hour HttpOnly cookie. Operations on an existing
viewer also require the token returned when that viewer joined. Device routes
use the board's bearer token. The local Worker's loopback authentication bypass
does not apply to the deployed API reached through make dev-live.
Control grants the viewer permission to reply on the SFU's robot data channel.
The browser sends a heartbeat every five seconds; a controller's successful
heartbeat renews its 15-second lease while that lease is still active. Cleanup
revokes expired permission before another viewer can claim control. Failed
revocation is retained for retry, and status polling preserves the earliest
scheduled cleanup alarm. Volume and mute stay in each browser; LED and playback
commands go to the board and affect all listeners.
Each board publisher has a generation identifier. A new boot discards the prior publisher, viewer membership, and controller state before allocating its session. It makes no SFU cleanup calls for the discarded generation; those transports rely on SFU expiry. The same policy applies after 90 seconds without a board heartbeat. This is the application's disposable-session policy. Browsers close their old PeerConnection when status changes or their membership is rejected. Requests from an old generation cannot change the replacement session. Retries of the current boot reuse its completed startup response.
Within the current generation, an SFU allocation response can contain channel
IDs or track mids alongside an error. RobotRoom retains those IDs as cleanup
receipts and retries cleanup before replacing the affected channels or tracks.
Failed viewer cleanup also remains pending while that generation is current.
The board includes current-song metadata in its HTTPS heartbeats. The status API returns the last accepted metadata before a browser establishes WebRTC; connected browsers also receive metadata changes over the reliable data channel. Both sources include a playback revision. Within one publisher generation, the browser accepts only newer metadata revisions from either source. A generation change clears the accepted metadata; telemetry and spectrum packets that carry revisions must match the accepted playback revision. See Change the playlist for the replacement workflow.
Wrangler generates worker-configuration.d.ts from bindings and required
secret names, including in CI without real credentials.
Imports across directories use @/ for src/, @dev/ for dev/, and @tests/
for tests/; neighboring modules can keep ./ imports. Vite and test tooling
share dev/aliases.ts, with matching TypeScript paths in
tsconfig.paths.json. Direct Node runs that import aliased
modules need the preload hook, for example:
node --import ./dev/register-aliases.mjs --test src/client/radio/*.test.tsWorker tests
make test-worker runs TypeScript tests under tests/runtime/ through the
Cloudflare Vitest plugin. The source Worker and Durable Object execute in a
local Workers runtime with generated binding types, synthetic credentials and
an intercepted SFU. Cases reset storage between tests; no real account or board
is needed. From worker/, npm run test:worker:watch reruns cases as code changes.
Vitest has a separate configuration, sharing only the import
aliases with the frontend. It does not start the live API proxy. Versions are
pinned together in package.json because the Cloudflare plugin supports a
specific Vitest range.
make test-worker-bundle builds the application and checks the production bundle
and scheduled controller-lease expiry in Miniflare with production compatibility
settings. Source tests exercise alarms and eviction through the plugin helpers.
Both suites share the synthetic SFU and API client in tests/helpers/.
npm test runs portable protocol and state tests with Node's test runner.
Run make check for all type checks, portable tests and formatting. CI runs these
checks and both Worker suites.
Browser tests
The browser scripts connect to an existing Chrome DevTools Protocol endpoint. Start a dedicated Chromium instance in a separate terminal; substitute your installed Chromium/Chrome executable if needed:
chromium --headless=new --remote-debugging-address=127.0.0.1 \
--remote-debugging-port=19223 --user-data-dir="$(mktemp -d)" about:blankWith make dev running, execute from worker/:
export RADIO_CDP_URL=http://127.0.0.1:19223
RADIO_TEST_URL=http://localhost:11880 node tests/layout.mjs
RADIO_TEST_URL=http://localhost:11880 node tests/popovers.mjs
RADIO_TEST_URL=http://localhost:11880 node tests/states.mjslayout.mjs and popovers.mjs supply an offline board fixture. states.mjs
uses the real app with a fixture at its React/session boundary to check geometry,
long metadata, keyboard focus and pending-action guards. It needs Vite dev mode.
These fixture modules are not entry points in the production build.
For the hardware test, use the deployed
Worker URL, or the local URL when running make dev-live:
RADIO_TEST_URL=https://radio.example.com \
RADIO_EXPECT_TRACK='Your track title' node tests/live.mjsRADIO_EXPECT_TRACK is optional. Set RADIO_EXPECT_PLAYLIST=1 to exercise Next
without reconnecting, and RADIO_EXPECT_AUTO_ADVANCE=1 when testing a short
synthetic playlist (songs must end within the test's 45-second wait). The test reads the viewer password from
the root .credential.env, opens two listeners, and changes the LED and shared playback.
It checks audio, spectrum, measured hardware telemetry, metadata, and controller handoff. Close other test
listeners first. Results and screenshots go to ignored artifacts/worker/.