> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Meet calls

> Run Tuturuuu Meet video calls on Cloudflare Realtime SFU, including room signaling, host controls, and in-call recording.

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

| piece                                            | where                                 |
| ------------------------------------------------ | ------------------------------------- |
| Protocol, permissions, pure room reducer         | `packages/realtime/src/meet/`         |
| Room server (Bun + Docker, port 7816)            | `apps/meet-realtime/src/server.ts`    |
| Room server (Cloudflare Worker + Durable Object) | `apps/meet-realtime/src/worker.ts`    |
| Cloudflare SFU HTTPS client                      | `packages/realtime/src/meet/sfu.ts`   |
| Call client and UI                               | `apps/meet/src/features/call/`        |
| Call route (full-bleed, no sidebar)              | `apps/meet/src/app/[locale]/r/[code]` |

`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.

```bash theme={null}
bun apps/meet-realtime/src/verify-credentials.ts
```

Start the room server and the app, then open a meeting and press **Join call**:

```bash theme={null}
bun apps/meet-realtime/src/index.ts
```

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:

```bash theme={null}
bun apps/meet-realtime/src/integration-check.ts
```

**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:

```bash theme={null}
bun apps/meet-realtime/src/media-check.ts
```

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.

```bash theme={null}
bun apps/meet-realtime/src/reconnect-check.ts
```

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

```bash theme={null}
bun wrangler deploy -c apps/meet-realtime/wrangler.jsonc
```

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.

| surface                     | URL                                 |
| --------------------------- | ----------------------------------- |
| Workspace plans, meetings   | `/<wsId>/plans`, `/<wsId>/meetings` |
| A call                      | `/r/<code>`                         |
| A public Meet Together plan | `/plans/<planId>`                   |

`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.
