Components
The CLI and the desktop app share one Go transfer engine in
cli/engine/, joined by the go.work
workspace file, so they behave identically on the wire.
File data never passes through the signaling server, and only end-to-end-encrypted packets
ever transit the relay. Every byte of every file travels over a WebRTC data channel between the
two peers.
The server runs as one process
Rooms, room codes, and all four rate limiters live in that process’s memory. Nothing is persisted except the global byte counter, and only when a Redis database is configured. This has one consequence worth stating plainly: running two replicas behind a load balancer does not scale Floe out. It breaks pairing, because the two peers can land on different replicas and never see each other. Run one instance. A single small server goes a long way, since no file data passes through it.Two signaling transports, one room registry
Both share the same in-memory
rooms registry, Map<roomId, [peer, peer]>, which is what makes
browser-to-CLI and browser-to-desktop transfers work with no translation layer.
/ws is matched by exact string equality. A trailing slash or a path prefix does not match,
and the connection is dropped with no error, which matters when you put Floe behind a proxy.
Socket.IO events
Client to server:
Server to client:
WebSocket messages
Every message is a JSON object with atype field.
Client to server:
Server to client:
Both transports cap a single message at 1 MB. An oversized WebSocket frame is closed with code
1009. Signaling messages are SDP and ICE candidates, so this is a guard rather than a limit
anyone meets.
A room outlives a one-sided drop. When a peer disconnects the server removes only that peer and
keeps the room for the other one, which is what lets a browser rejoin the same room after its
socket reconnects. The room is deleted when the last peer leaves.
The connection is kept alive below this layer. Every 30 seconds the server sends a WebSocket
protocol-level ping to each /ws client and terminates any that did not answer the previous one.
The Go client answers automatically, so nothing in Floe’s own message set is involved. A reverse
proxy therefore needs an idle read timeout longer than 30 seconds, or it cuts healthy connections.
A session, end to end
- The sender generates a UUID room id. All three surfaces put it in the fragment of a share
link, 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 it withPOST /api/codeto get a three-word phrase; the web app has no code UI. The server therefore learns the room id from that registration if there is one, and otherwise only when a peer joins. - The sender joins the room. The server assigns the role
sender. - The sender displays the code, link, or QR code and waits.
- The receiver joins the same room. The server assigns
receiverand tells the sender someone arrived. - The sender creates a WebRTC offer and sends it through the server.
- The receiver answers.
- Both exchange ICE candidates as they are gathered, rather than in one batch.
- The data channel opens and the signaling server plays no further part.
Rate limiting
Counters live in plain
Maps and are swept every 60 seconds. The three HTTP limiters answer
429. The connection limiter is not an HTTP response: a Socket.IO handshake over the limit is
rejected in middleware, which the browser sees as a connect_error, and a WebSocket over the
limit is closed with code 1008.
Every limiter records only what it admits. A refused connection attempt leaves no timestamp, just
as the three HTTP limiters return the 429 before recording anything, so a client that keeps
retrying gets back in as soon as its admitted connections age out of the 60-second window. One
behavior does surprise people: setting any MAX_* variable to 0 does not disable a limit; it
silently restores the default.
The active-code cap is global rather than per-IP, because it guards the size of the in-memory
registry. It counts stored entries rather than genuinely live ones, so the number can briefly
include codes that have already expired but that the once-a-minute sweeper has not cleared.
Room codes
Three random words joined by hyphens, drawn from a 1247-word list inserver/words.json (the EFF
short word list without its hyphenated entry and without words that read badly in a shared code)
using crypto.randomInt, a cryptographically secure source rather than Math.random. That matters
because the phrase is the only secret guarding the transfer.
On a collision with a code that is still live, the server draws again, up to ten times, and only
if all ten collide does it fall back to a four-word phrase. An expired code counts as free. Codes
expire ten minutes after registration, and resolving one does not delete it.
Global statistics counter
The homepage shows a public, all-time total of bytes transferred by everyone. 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. Only the receiver reports, so each transfer is counted once, and the data-channel protocol is unchanged. The total is viewable only in the browser. The CLI and desktop receivers contribute to it and never fetch or display it. The server keeps the running total in two places. An in-memory value answers every read, so homepage polling never touches Redis. Each accepted report additionally fires an atomic increment against Upstash Redis over its REST API, and the in-memory value is seeded from that key on startup, so the total survives restarts. Without Redis configured the counter is memory-only and resets to zero on restart. Guardrails are deliberately light: a per-IP rate limit and a per-report size cap. The figure is an honest best-effort metric rather than an audited count, and the stored value is a single anonymous integer with no file names, no per-transfer rows, and no link to any user or IP. Every receiving surface can opt out, and the opt-out happens on the client before any request is made; the server has no role in it.Where the details are
HTTP API
Every endpoint, with request and response shapes, status codes, and the CORS rules.
Transfer protocol
The data-channel wire format, protocol versioning, flow control, and every timeout.