Pieces
room.ts is a pure reducer: it returns the next room snapshot plus the messages
to fan out and any Cloudflare work to perform. Both transports run the same
reducer, so the Bun server and the Durable Object cannot drift.
The app secret never reaches a browser. The client receives only a short-lived
HMAC join token; the room server performs every SFU call on its behalf.
Running a call locally
Create an SFU app under Cloudflare dashboard → Realtime → SFU and put the values inapps/meet-realtime/.dev.vars (gitignored). See the
apps/meet-realtime README for variable names.
NEXT_PUBLIC_MEET_REALTIME_URL, which defaults to
ws://127.0.0.1:7816/realtime.
Verifying without a full environment
Two checks run against real Cloudflare without needing Supabase, auth or seed data. Signaling and host controls — boots the room server, connects a host and a guest, and exercises the lobby, chat, hand raise, force mute and removal:http://127.0.0.1:7898/?peer=a and ?peer=b. Each tab reports
bytesReceived and framesDecoded, and renders the other peer’s video.
Two contract details worth remembering
Both of these were found only by running against the live API, and both produce confusing errors if reintroduced:- Read
transceiver.midonly aftersetLocalDescription(). It isnullbefore that, and publishing without it fails with406 tracks[0]: Missing mid in track. - Do not send a
sessionDescriptionwhen pulling remote tracks. Cloudflare returns the offer, which the client answers viasfu.renegotiate. Sending an empty SDP is rejected as a malformed event.
{} and {autoDiscover: true} are both rejected.
Attributing remote tracks
An inbound track arrives with an opaque id, so nothing on theRTCPeerConnection says who published it. Attribution comes from the track
name, which is deliberately <userId>-<kind>: the subscribe response returns a
mid per track, the client maps mid → userId, and the track event is
matched against that map.
This is load-bearing. Keying inbound streams by the browser’s track id instead
means the UI cannot tell participants apart, and every tile ends up rendering
whichever stream arrived first.
One MediaStream is accumulated per participant, since their audio and video
arrive as separate tracks but belong to one tile. The map and the streams are
both cleared on reconnect, because mids are only meaningful within a session.
Reconnection
Signaling drops are expected: servers restart, networks blip, laptops sleep. The client reconnects with exponential backoff and full jitter, so a room server restart does not produce a synchronised retry stampede from every participant. Two details make it actually work:- Each attempt fetches a fresh join token from web’s
POST /api/v1/workspaces/<wsId>/meetings/<meetingId>/realtime-token, which the satellite proxies. Join tokens are short-lived and calls are not, so reusing the original token would let a client reconnect for ten minutes and then fail forever on “Reconnecting…”. Membership is re-checked on every refresh. - A reconnect rebuilds room state. The client re-announces itself with
presence.joinand clears its subscription ledger and peer connections, so every remote track is pulled again. Without that it would rejoin a room it could not see.
1000) and a host removal (4403) are decisions, not blips, and
are never retried.
Media survives a signaling drop: the peer connections to the SFU are
independent, so audio and video keep flowing while signaling is down.
Meeting codes
Every meeting has a shareable code and a/r/<code> link, surfaced by the Copy invite button in the call header.
The code is a reversible Crockford base32 encoding of the meeting id rather
than a stored random string, so there is no schema change, no collision risk
and no lookup table — an invalid code is rejected before any query runs.
Decoding tolerates case, spaces, dashes, and the I/L/O/U characters
Crockford omits, folding them onto the digits the sender meant.
A code is a convenience for sharing inside a workspace, not a bypass:
/r/<code> still resolves workspace membership and redirects non-members
away.
Every participant must be signed in. There are no anonymous guest links and
none are planned, so a code always resolves against a real Tuturuuu user and a
workspace membership. The lobby (admission: 'lobby') stays in the protocol for
future use, but today every workspace member joins with admission: 'open'.
Recording
A host can record from inside the call. The control creates a row inrecording_sessions through the existing /record endpoint, captures local
audio with MediaRecorder, uploads through the existing /upload endpoint, and
broadcasts recording.state so every participant sees the indicator.
Because it reuses those endpoints, a call recording is indistinguishable from
one started on the meeting page and feeds the same transcription pipeline.
Why the Next app stays on Vercel
Only the realtime server runs on Cloudflare Workers.apps/meet itself
stays on Vercel, deliberately.
Media never traverses the Next app: the browser talks to the Cloudflare SFU
directly, and signaling goes to the room server. Moving the Next app to Workers
would therefore not shorten the media path by a single hop.
It is also not currently possible. @opennextjs/cloudflare requires Next
>=16.2.11, and the monorepo is on the 16.3.0-preview line, which npm semver
ranges exclude. Pinning apps/meet alone to 16.2.12 was tried and rejected:
16.2’s cacheComponents is stricter than the preview, and both Meet Together
routes fail to prerender with Uncached data was accessed outside of <Suspense>. Fixing that means changing @tuturuuu/ui/legacy/meet/*, which
other apps share.
Revisit when the monorepo moves to a stable Next 16 line.
Deploying the Worker
URL shapes
Meet matches the other satellites: workspace routes sit directly under the workspace segment, with no/workspace prefix.
plans and r are static segments, which is what frees the bare /<wsId>
position — public plan pages previously occupied it, so /<wsId> and
/<planId> could not coexist.
Every retired shape is permanently redirected in proxy.ts:
/workspace/* → /*, /call/<wsId>/<meetingId> and /join/<code> → /r/<code>,
and a bare 32-hex segment → /plans/<planId>, so shared plan links keep working.