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

The CLI and the desktop app share one Go transfer engine (cli/engine/), joined by the workspace file go.work, so they behave identically on the wire. See Desktop installation for the app itself. 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 and browser-to-desktop transfers work transparently.

HTTP endpoints

TURN credentials response

On floe.one the endpoint returns short-lived Cloudflare Realtime TURN credentials (turn.cloudflare.com). They are requested with a 24-hour TTL and cached in memory for 12 hours, so a burst of page loads costs one upstream call. Cloudflare’s full URL list is trimmed before it is served, down to one STUN URL, one UDP TURN URL, and one TCP-based TURN URL (preferring TLS on port 443). Clients gather ICE candidates per URL per network interface, so redundant URLs multiply connection setup time on machines with VPN or VM adapters. For a self-hosted coturn relay, when TURN_SECRET and TURN_DOMAIN are set it returns:
Response
When neither Cloudflare nor coturn is configured:
Response
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 (Socket.IO)

Server to client (Socket.IO)

WebSocket messages (CLI and desktop, path /ws)

All messages are JSON objects with a type field.

Client to server (WebSocket)

Server to client (WebSocket)

Rate limiting

Counters are tracked in memory (Map<ip, timestamp[]>) and cleaned every 60 seconds. The three HTTP limits (TURN, code, stats) respond with 429. The connection limit is not an HTTP response: a Socket.IO handshake over the limit is rejected in the connection middleware, which the browser sees as a connect_error, and a WebSocket over the limit is closed with code 1008. The active-code cap responds with 503 and applies globally, not per-IP, because it guards the size of the in-memory code registry. That cap counts stored entries rather than genuinely live ones: expired codes are swept once a minute, so the count can briefly include codes that have already expired.

Signaling flow

  1. The sender generates a UUID room ID. All three surfaces put it in the fragment of a share link (#room=<id>), and browsers never send a fragment to a server, so opening the link keeps the room ID out of request URLs, Referer headers, and analytics. The CLI and the desktop app additionally register the room ID with POST /api/code to get a short phrase; the web app has no code UI. The signaling server learns the room ID from that code registration, if there is one, and otherwise only when a peer joins in the next step.
  2. Sender joins the room via join-room. Server assigns role sender.
  3. Sender shows the code, link, or QR code 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 browser, the CLI, and the desktop app use the same protocol and are fully interoperable in every direction. The CLI and the desktop app share one implementation (cli/engine/transfer).

Message types

Metadata (JSON, sender to receiver)
Metadata
pv and pvMin carry the protocol version range and are always present; ver is the release string (see Protocol versioning). All three were added in v1.6.0 and are absent on older peers, which are treated as protocol version 1. ver is populated by Go peers (the CLI and the desktop app) and omitted by browser peers. Ack (JSON, receiver to sender)
Ack
offset supports mid-file resume. In normal operation it is always 0. The ack carries the receiver’s own pv and pvMin so the sender can verify compatibility from its side, plus ver when the receiver is a Go peer. Binary chunks (raw bytes) Both the Go engine (CLI and desktop) 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)
End marker
Received confirmation (JSON, receiver to sender) After all files are complete, a Go receiver (the CLI or the desktop app) sends:
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). It carries a human-readable reason plus the receiver’s own version range, and ver when the receiver knows its release string. As with metadata and acks, Go receivers populate ver and browser receivers omit it.
Incompatible
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 each side prints an actionable message with a remedy for its own surface: the CLI says run floe update, the desktop app points at the Microsoft Store or floe.one/download, and the browser says refresh the page. The newer peer is told to ask the other side to update. Current Go peers rebuild the message locally from the frame’s pv/pvMin; the reason carried on the wire is worded from the recipient’s perspective, so older peers that display it verbatim still name the correct side.
  • ver is the human release string (for example v1.6.0). It is optional: Go peers (the CLI and the desktop app) always send it, browser peers never do. It is informational only, used for the optional peer-version note the CLI prints on either side when the two releases differ, and it 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 the server simply draws a new phrase, up to 10 times; only if all 10 draws collide does it fall back to a four-word phrase. An expired code counts as free. Codes expire 10 minutes after registration. The number of registered codes held in memory is capped by MAX_ACTIVE_CODES (default 10000); POST /api/code returns 503 when the cap is reached. Expired codes are swept once a minute, so that count can briefly include codes that have already expired.

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, CLI, and desktop receivers), 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 and desktop receivers contribute to the counter but never fetch or display 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: All three receiving surfaces 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.
  • Desktop: a “Contribute to global stats” toggle in Settings, persisted to the desktop config file (with a one-time import from the older localStorage['floe:report-stats'] value). When it is off, the app passes an empty statsURL into the shared engine receiver and hits the same guard as the CLI.