Skip to main content
Tuturuuu Meet calls are Google-Meet-style conferences carried by the Cloudflare Realtime SFU. This is a different product from Cloudflare Stream Live, which powers one-to-many broadcast and is documented separately. Every participant holds exactly two peer connections to the SFU — one publishing, one subscribing — regardless of room size. Calls are not mesh, so cost and CPU stay flat as a room grows.

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 in apps/meet-realtime/.dev.vars (gitignored). See the apps/meet-realtime README for variable names.
Start the room server and the app, then open a meeting and press Join call:
Point the app at it with 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:
Media — bundles the real client modules, opens two browser peers and proves audio and video traverse the SFU. Tracks are synthesised from a canvas and an oscillator, so no camera or microphone permission is needed:
Then open 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.mid only after setLocalDescription(). It is null before that, and publishing without it fails with 406 tracks[0]: Missing mid in track.
  • Do not send a sessionDescription when pulling remote tracks. Cloudflare returns the offer, which the client answers via sfu.renegotiate. Sending an empty SDP is rejected as a malformed event.
Creating a session with no local offer must also send no request body at all; {} and {autoDiscover: true} are both rejected.

Attributing remote tracks

An inbound track arrives with an opaque id, so nothing on the RTCPeerConnection 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.join and 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.
A clean close (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.
That check stops the room server mid-session and asserts the client recovers, mints a new token for the retry, and stays closed when the close was intentional.

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 in recording_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

One Durable Object per room gives each room a single authority. The Bun server keeps rooms in an in-process map, which is only correct for a single replica — prefer the Worker when running more than one instance.

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.