Skip to main content
This page is a technical reference for contributors and advanced self-hosters. For a plain-language overview, see How It Works.

Components

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.

HTTP endpoints

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:
When TURN_SECRET is unset:
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

Server to client

WebSocket messages (CLI, path /ws)

All messages are JSON objects with a type field.

Client to server

Server to client

Rate limiting

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)
pv, pvMin, and ver carry the protocol version range and release string (see 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)
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)
Received confirmation (JSON, receiver to sender) After all files are complete, the CLI receiver sends:
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). It carries a human-readable reason plus the receiver’s own version range:
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.