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 samerooms 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
Response
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
- 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,Refererheaders, and analytics. The CLI and the desktop app additionally register the room ID withPOST /api/codeto 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. - Sender joins the room via
join-room. Server assigns rolesender. - Sender shows the code, link, or QR code and waits.
- Receiver joins the same room via
join-room. Server assigns rolereceiverand emitsuser-connectedto the sender. - Sender creates a WebRTC offer and sends it via
signal. - Receiver receives the offer, creates an answer, and sends it back via
signal. - Both peers exchange ICE candidates via
signal(trickle ICE). - 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
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
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) andpvMin(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
incompatiblemessage and each side prints an actionable message with a remedy for its own surface: the CLI says runfloe 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’spv/pvMin; thereasoncarried on the wire is worded from the recipient’s perspective, so older peers that display it verbatim still name the correct side. veris the human release string (for examplev1.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.
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 viaPOST /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). EveryGET /api/statsis answered from this value, so homepage polling never incurs a Redis read. - Upstash Redis (durable). Each accepted report fires an atomic
INCRBY floe:bytes_totalover the Upstash REST API. On startup the server seedscachedTotalfrom this key, so the total survives restarts and redeploys.
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, thefetchto/api/stats/reportand the optimisticfloe:bytes-reportedevent are both skipped. - CLI:
--no-reportflag onfloe receive, orFLOE_NO_STATS=1environment variable. Both gate the report by passing an emptystatsURLintoReceiveFiles, which hits the existingif serverURL == "" { return }guard inreportBytesToServer. - 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 emptystatsURLinto the shared engine receiver and hits the same guard as the CLI.