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 samerooms 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:
TURN_SECRET is unset:
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
- Sender calls
POST /api/codewith a UUID room ID to register a short code. - Sender joins the room via
join-room. Server assigns rolesender. - Sender prints the code and link 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 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)
reason plus the receiver’s own version range:
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 both sides print an actionable message: the older peer is told to runfloe update, or to ask the other side to update. veris the human release string (for examplev1.6.0). It is informational only, used for the optional peer-version note, and 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 (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 viaPOST /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). 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: 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, 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.