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

# Floe Architecture Reference

> Reference for the Floe signaling protocol, HTTP endpoints, WebSocket events, and the WebRTC data-channel binary framing used for file transfers.

This page is a technical reference for contributors and advanced self-hosters. For a plain-language overview, see [How It Works](/docs/how-it-works/signaling).

## Components

| Component  | Runtime                                                   | Port       | Purpose                                           |
| ---------- | --------------------------------------------------------- | ---------- | ------------------------------------------------- |
| Client     | Next.js 16 (React 19)                                     | 3000       | Browser UI, WebRTC peer (via simple-peer)         |
| Server     | Node.js (Express 5, Socket.IO 4, ws 8)                    | 3001       | Signaling broker, TURN credential issuer          |
| CLI        | Go 1.25 (Pion WebRTC v4)                                  | -          | Headless sender/receiver                          |
| TURN relay | Cloudflare Realtime TURN (floe.one) or self-hosted coturn | 3478, 5349 | Relays encrypted packets when a direct path fails |

File data never passes through the signaling server, and only end-to-end-encrypted packets ever transit the TURN relay. All file bytes travel over encrypted WebRTC data channels between peers.

## Signaling transports

The server exposes two transports. Both share the same `rooms` registry (`Map<roomId, [peer, peer]>`) so browser-to-CLI transfers work transparently.

| Transport | Path          | Used by           |
| --------- | ------------- | ----------------- |
| Socket.IO | `/socket.io/` | Browser (web app) |
| WebSocket | `/ws`         | CLI               |

## HTTP endpoints

| Method | Path                    | Description                                                                                                                                                                                                                                                                                                                                    |
| ------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`  | `/`                     | Status check. Returns `{ "status": "ok", "timestamp": "..." }`.                                                                                                                                                                                                                                                                                |
| `GET`  | `/health`               | Liveness probe. Returns `{ "status": "healthy", "uptime": <seconds> }`.                                                                                                                                                                                                                                                                        |
| `GET`  | `/api/turn-credentials` | Returns ICE server list. Prefers Cloudflare Realtime TURN when `CLOUDFLARE_TURN_KEY_ID` / `CLOUDFLARE_TURN_KEY_API_TOKEN` are set, then self-hosted coturn when `TURN_SECRET` is set, otherwise Google STUN only. Rate-limited to 20 requests per IP per 60 s.                                                                                 |
| `POST` | `/api/code`             | Registers a short code for a room ID. Body: `{ "roomId": "<uuid>" }`. Response: `{ "code": "word-word-word" }`. TTL: 10 minutes. Rate-limited to 60 requests per IP per 60 s (shared with `GET /api/code/:code`, configurable via `MAX_CODE_REQUESTS_PER_IP`). Returns `503` when the live-code count hits `MAX_ACTIVE_CODES` (default 10000). |
| `GET`  | `/api/code/:code`       | Resolves a short code to a room ID. Response: `{ "roomId": "<uuid>" }`. Returns 404 if expired or not found. Rate-limited to 60 requests per IP per 60 s (shared with `POST /api/code`, configurable via `MAX_CODE_REQUESTS_PER_IP`).                                                                                                          |
| `GET`  | `/api/stats`            | Returns the all-time global transfer counter: `{ "totalBytes": <integer> }`. Served from an in-memory cache (no Redis read per request).                                                                                                                                                                                                       |
| `POST` | `/api/stats/report`     | Receiver peers report bytes after a completed transfer. Body: `{ "bytes": <positive integer> }`. Response: `{ "totalBytes": <integer> }`. Rate-limited to 60 requests per IP per 60 s; rejects values that are not positive integers or exceed `MAX_REPORT_BYTES` (default 5 TB).                                                              |

### TURN credentials response

On floe.one the endpoint returns short-lived Cloudflare Realtime TURN credentials (`turn.cloudflare.com`). For a self-hosted coturn relay, when `TURN_SECRET` and `TURN_DOMAIN` are set it returns:

```json theme={null}
[
  { "urls": "stun:turn.your-domain.com:3478" },
  { "urls": "turn:turn.your-domain.com:3478", "username": "...", "credential": "..." },
  { "urls": "turns:turn.your-domain.com:5349", "username": "...", "credential": "..." }
]
```

When `TURN_SECRET` is unset:

```json theme={null}
[
  { "urls": "stun:stun.l.google.com:19302" },
  { "urls": "stun:stun1.l.google.com:19302" }
]
```

For self-hosted coturn, credentials use HMAC-SHA1: `username = "{expiry_unix}:floeuser"`, `credential = base64(HMAC-SHA1(TURN_SECRET, username))`. Expiry is 24 hours from issuance.

## Socket.IO events (browser)

### Client to server

| Event       | Payload                        | Description                                                       |
| ----------- | ------------------------------ | ----------------------------------------------------------------- |
| `join-room` | `roomId: string`               | Join a room. UUID format required.                                |
| `signal`    | `{ signal, target?, roomId? }` | Forward a WebRTC signal (SDP or ICE candidate) to the other peer. |
| `ping`      | callback function              | Keepalive. Server invokes the callback immediately.               |

### Server to client

| Event               | Payload                            | Description                                   |
| ------------------- | ---------------------------------- | --------------------------------------------- |
| `room-joined`       | `{ role: "sender" \| "receiver" }` | Confirms room join and assigns role.          |
| `user-connected`    | `peerId: string`                   | Notifies the sender that the receiver joined. |
| `signal`            | `{ signal, sender: peerId }`       | Delivers a WebRTC signal from the other peer. |
| `peer-disconnected` | `{}`                               | The other peer left the room.                 |
| `room-full`         | `{}`                               | Room already has two peers.                   |
| `error`             | `{ message: string }`              | Server error.                                 |

## WebSocket messages (CLI, path `/ws`)

All messages are JSON objects with a `type` field.

### Client to server

| `type`      | Other fields             | Description                                           |
| ----------- | ------------------------ | ----------------------------------------------------- |
| `join-room` | `roomId: string`         | Join a room.                                          |
| `signal`    | `signal, roomId: string` | Forward a WebRTC signal.                              |
| `ping`      | -                        | Keepalive. Server responds with `{ "type": "pong" }`. |

### Server to client

| `type`              | Other fields                   | Description                        |
| ------------------- | ------------------------------ | ---------------------------------- |
| `room-joined`       | `role: "sender" \| "receiver"` | Role assignment.                   |
| `user-connected`    | `id: string`                   | Receiver joined.                   |
| `signal`            | `signal, sender: string`       | WebRTC signal from the other peer. |
| `peer-disconnected` | -                              | Other peer disconnected.           |
| `room-full`         | -                              | Room already full.                 |
| `pong`              | -                              | Response to client `ping`.         |
| `error`             | `message: string`              | Server error.                      |

## Rate limiting

| Limit                | Value                                                                 | Applies to                                                    |
| -------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------- |
| Connection rate      | 30 per IP per 60 s (configurable via `MAX_CONNECTIONS_PER_IP`)        | Socket.IO connections and WebSocket `/ws` connections, shared |
| TURN credential rate | 20 per IP per 60 s                                                    | `GET /api/turn-credentials`                                   |
| Code endpoint rate   | 60 per IP per 60 s (configurable via `MAX_CODE_REQUESTS_PER_IP`)      | `POST /api/code` and `GET /api/code/:code`, shared            |
| Active code cap      | 10000 simultaneously-live codes (configurable via `MAX_ACTIVE_CODES`) | `POST /api/code`. Returns `503` when the registry is full.    |
| Stats report rate    | 60 per IP per 60 s                                                    | `POST /api/stats/report`                                      |

Counters are tracked in memory (`Map<ip, timestamp[]>`) and cleaned every 60 seconds. Per-IP limits respond with `429`. The active-code cap responds with `503` and applies globally, not per-IP, because it guards the size of the in-memory code registry.

## Signaling flow

1. Sender calls `POST /api/code` with a UUID room ID to register a short code.
2. Sender joins the room via `join-room`. Server assigns role `sender`.
3. Sender prints the code and link and waits.
4. Receiver joins the same room via `join-room`. Server assigns role `receiver` and emits `user-connected` to the sender.
5. Sender creates a WebRTC offer and sends it via `signal`.
6. Receiver receives the offer, creates an answer, and sends it back via `signal`.
7. Both peers exchange ICE candidates via `signal` (trickle ICE).
8. WebRTC data channel opens. Signaling server plays no further part.

## Data-channel transfer protocol

Once the WebRTC data channel is open, the sender runs the following sequence for each file. The CLI and browser use the same protocol and are fully interoperable.

### Message types

**Metadata (JSON, sender to receiver)**

```json theme={null}
{
  "type": "metadata",
  "id": "<uuid>",
  "fileName": "photo.jpg",
  "fileSize": 6291456,
  "index": 1,
  "total": 3,
  "totalBytes": 11800000,
  "pv": 1,
  "pvMin": 1,
  "ver": "v1.6.0"
}
```

`pv`, `pvMin`, and `ver` carry the protocol version range and release string (see [Protocol versioning](#protocol-versioning)). They were added in v1.6.0 and are absent on older peers, which are treated as protocol version 1.

**Ack (JSON, receiver to sender)**

```json theme={null}
{
  "type": "ack",
  "id": "<uuid>",
  "offset": 0,
  "pv": 1,
  "pvMin": 1,
  "ver": "v1.6.0"
}
```

`offset` supports mid-file resume. In normal operation it is always 0. The ack carries the receiver's own `pv`, `pvMin`, and `ver` so the sender can verify compatibility from its side.

**Binary chunks (raw bytes)**

Both the CLI and the browser size chunks adaptively to the connection's negotiated maximum message size, capped at 256 KB. Chunk size is a local sender choice, not part of the wire protocol; receivers handle any size.

**End marker (JSON, sender to receiver)**

```json theme={null}
{ "type": "end" }
```

**Received confirmation (JSON, receiver to sender)**

After all files are complete, the CLI receiver sends:

```json theme={null}
{ "type": "received" }
```

This lets the sender exit cleanly instead of polling the SCTP buffer. Browser receivers do not send this; the sender waits for the data channel buffer to drain to zero.

**Incompatible (JSON, receiver to sender)**

Sent in place of the ack when the two peers' protocol version ranges do not overlap (see [Protocol versioning](#protocol-versioning)). It carries a human-readable `reason` plus the receiver's own version range:

```json theme={null}
{
  "type": "incompatible",
  "reason": "Cannot transfer: your floe is too old for this peer. ...",
  "pv": 1,
  "pvMin": 1
}
```

The receiver sends this and aborts before creating any file, so the sender fails fast with a clear message instead of waiting for an ack that never arrives. Older senders that predate this message type ignore it safely.

### Protocol versioning

The transfer protocol carries its own version, independent of the floe release version (the 1.x line). This lets peers on different releases interoperate, while still detecting a genuine breaking wire-format change cleanly.

* Each peer advertises `pv` (the highest protocol version it speaks) and `pvMin` (the lowest it still supports) in its metadata and ack. Both are 1 today.
* Two peers are compatible when their ranges overlap: `max(localMin, remoteMin) <= min(localMax, remoteMax)`. They operate at the highest common version.
* A peer that omits the fields (any release before v1.6.0) is treated as protocol version 1, so all existing peers stay compatible.
* When the ranges do not overlap, the receiver sends an `incompatible` message and both sides print an actionable message: the older peer is told to run `floe update`, or to ask the other side to update.
* `ver` is the human release string (for example `v1.6.0`). It is informational only, used for the optional peer-version note, and never gates a transfer.

The constants live in `cli/engine/transfer/protocol.go` (`ProtocolVersion`, `MinProtocolVersion`) and `client/lib/transfer/protocol.ts` (`PROTOCOL_VERSION`, `MIN_PROTOCOL_VERSION`). Bump `ProtocolVersion` only on a breaking wire change; raise `MinProtocolVersion` only when deliberately dropping support for an old wire format.

### Backpressure

The sender pauses when the SCTP send buffer reaches the high-water mark (8 MB) and resumes when it drains below the low-water mark (4 MB). This prevents buffer overflow on large or slow transfers.

### Multi-file transfers

The four-step sequence (metadata, ack, binary chunks, end) repeats for each file in order. The receiver processes files sequentially.

## Room codes

Codes are three random words joined by hyphens (e.g. `olive-tiger-castle`), sampled from a 288-word corpus in `server/words.json` using `crypto.randomInt` (a CSPRNG, bias-free), since the code phrase is the only secret guarding a transfer. On collision (extremely rare), a fourth word is appended. Codes expire 10 minutes after registration. The number of simultaneously-live codes is capped by `MAX_ACTIVE_CODES` (default 10000); `POST /api/code` returns `503` when the cap is reached.

## Global statistics counter

The homepage shows a public, all-time counter of total bytes transferred across every Floe user. Because file data never reaches the server, the counter is fed out-of-band: after a transfer completes, the **receiver** reports the number of bytes it received via `POST /api/stats/report`. Only the receiver reports (the browser receiver and the CLI receiver), so each transfer is counted exactly once. The sender never reports, and the data-channel protocol is unchanged.

The global total is **viewable only in the browser** (`GlobalStats.tsx` on the homepage, polling `GET /api/stats` every 10 seconds). The CLI receiver contributes to the counter but never fetches or displays it.

The server keeps the running total in two places:

* **In-memory cache (`cachedTotal`).** Every `GET /api/stats` is answered from this value, so homepage polling never incurs a Redis read.
* **Upstash Redis (durable).** Each accepted report fires an atomic `INCRBY floe:bytes_total` over the Upstash REST API. On startup the server seeds `cachedTotal` from this key, so the total survives restarts and redeploys.

If `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are not configured, the counter runs in memory only and resets to 0 on restart. The endpoint never blocks a transfer: reports are fire-and-forget on the client, and a Redis write failure is swallowed rather than surfaced.

Guardrails are deliberately lightweight. A per-IP rate limit (60 reports per 60 s) and a per-report cap (`MAX_REPORT_BYTES`, default 5 TB) reject abuse and implausible values, but the figure is an honest best-effort metric, not a tamper-proof audited count. The stored value is a single anonymous integer: it carries no file names, no per-transfer records, and no link to any user or IP.

**Opt-out:** Both clients support opting out of the byte-count report. The opt-out is enforced on the receiver side before the HTTP request is made; the server has no role in it.

* Browser: a "Contribute to global stats" toggle on the receiver view, persisted in `localStorage['floe:report-stats']`. When unchecked, the `fetch` to `/api/stats/report` and the optimistic `floe:bytes-reported` event are both skipped.
* CLI: `--no-report` flag on `floe receive`, or `FLOE_NO_STATS=1` environment variable. Both gate the report by passing an empty `statsURL` into `ReceiveFiles`, which hits the existing `if serverURL == "" { return }` guard in `reportBytesToServer`.
