The short version
Two devices meet in a room on the signaling server, swap the details they need to connect, and then send the files straight to each other over an encrypted data channel. The server never sees a file. Browsers talk to it over Socket.IO, and the CLI and Floe Desktop over a plain WebSocket, and both kinds meet in the same rooms.Components
The CLI and Floe Desktop 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 TURN relay. Every byte of every file travels over a WebRTC data
channel between the two peers.
A session, end to end
- The sender generates a UUID room id.
- The sender joins the room, which is the first time the server sees the room id. The server
assigns the role
sender. - The CLI and Floe Desktop then register the room id with
POST /api/codeto get a room code. The web app never does. - The sender displays what it has to share and waits: a link on every surface, a room code on the
CLI and Floe Desktop, and a QR code on the web app and Floe Desktop. All three surfaces put the
room id in the fragment of the link, and the web app,
floe1.12 and later and Floe Desktop 1.1 and later put a link key there too. Browsers never send a fragment to a server, so opening the link keeps both out of request URLs,Refererheaders, and analytics. - The receiver joins the same room, after resolving the room code with
GET /api/code/:codeif that is what it was given. A code carries no link key. The server assignsreceiver, retires the room’s code, and 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. Each side that holds the
link key sends its proof first, as a
kcframe.
GET /api/turn-credentials before it builds its peer
connection. The server takes the room id in every message that needs one and never the link key, so
a link key needs nothing from the server.
A request link turns the first half around. Floe Desktop joins first with
its host token and receives. The sender, on the page the link opens or in floe send --to, comes
second through request-join, and sends. Floe Desktop still creates the offer, once the server
tells it the sender arrived. See Request rooms.
The server runs as one process
Rooms, room codes, request-link reservations, and every rate limiter live in that process’s memory. Nothing is persisted except the global byte counter, and only when a Redis database is configured, so a restart forgets every room, code, reservation, and limiter window. 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.
The WebSocket path must be exactly /ws. A query string is fine. A WebSocket upgrade on any other
path, a trailing slash or a prefix included, is answered 404 Not Found, which matters when you
put Floe behind a proxy. An upgrade whose request target cannot be read as a path is answered
400 Bad Request. Both answers are plain text and write no log line. A server image of version
1.11.0 or earlier sends no answer to an upgrade on another path, and closes the socket about a
second later.
A room id is matched exactly as sent. The server accepts a UUID in either letter case, but it
stores an ordinary room under the id as it arrived. Two peers that send the same UUID in different
case therefore land in two different rooms. Send one consistent spelling. A request room is the
exception: its id is read in lowercase on every message.
Socket.IO events
Client to server:
Server to client:
WebSocket messages
Every message is a JSON object with atype field. A frame that is not a JSON object, or whose
type the server does not know, is ignored with no reply.
Client to server:
Server to client:
When the server answers room-full
The same frame answers several situations on both transports:- A
join-roominto an ordinary room that already holds two peers. - A
join-roomfrom a third network into a sealed ordinary room (see A room seals). - A plain
join-roomaimed at a request room’s id. - A
request-joinwhose seat is taken, whose link is sealed or used, or whose link has spent its seating budget. - A seated sender that the host unseats with
request-reopen. - Everyone in an ordinary room that sits on a request link’s id, when the link’s host takes the id back with its token.
- On
/wsonly, a host join for a link that was already used, or whose token does not match the reservation.
What the server relays, and what it drops
Both transports cap a single inbound message at 1 MB (1,000,000 bytes); an oversized WebSocket frame closes the connection. Signaling messages are SDP and ICE candidates, so this is a guard rather than a limit anyone meets. The server relays asignal only when all of these hold:
The count is of the JSON text as the server would write it, keys included. Every double quote,
backslash, control character, and half of a surrogate pair counts as 6. A line ending of CR and LF
is two control characters, so it costs 12, and an SDP of many lines reaches the bound well before
its JSON text is 65,536 characters long. A real offer carrying every candidate of a many-adapter PC
comes to under a quarter of the bound.
Anything else is dropped with no answer. So are a
signal whose value is empty or false, a signal
with nobody to receive it, and one whose target is not the other peer. On Socket.IO, the event
must carry an object with a signal in it.
A room outlives a one-sided drop
When a peer disconnects, the server removes only that peer, sendspeer-disconnected to the one
left, and keeps the room for it. That is what lets a browser rejoin the same room after its socket
reconnects. The room is deleted when the last peer leaves.
A peer that leaves an ordinary room by joining another room on the same connection leaves without
that notice: the peer left behind hears nothing until it acts.
A room seals
Once peers from two different client addresses have each sent asignal that the server routed to
the other peer, a peer from a third address gets room-full until the room is deleted. Joining
alone does not count an address, so a sender that reconnects from a new address beside its own
stale connection does not seal its own room.
The address is the one the per-IP limits count (an IPv6 address counts by its /64), so peers that
share one address are never sealed out. It is read through TRUSTED_PROXY_COUNT, so a server
behind a proxy needs that set right for the seal to work (see
Configuration). The server keeps a keyed hash of
each counted address for as long as the room exists, never the address itself. A request room does
not use this seal; it has its own, described under Request rooms. What the seal
means for the people in a transfer is in
How a transfer works.
Keeping connections alive
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
with a matching pong. The Go client answers automatically, so nothing in Floe’s own message set is
involved. Socket.IO connections use that library’s default heartbeat, a ping every 25 seconds. A
reverse proxy therefore needs an idle read timeout longer than 30
seconds, or it cuts healthy connections.
Floe Desktop also runs a check of its own on the connection that holds a request link. It sends
the ping message every 25 seconds, and treats 60 seconds without any message from the server,
this pong included, as a lost connection. floe send, floe receive, and Floe Desktop’s ordinary
transfers never send it.
A /ws client that stops reading is cut off as well: once more than 1 MB of output is waiting for
it, the server terminates the connection instead of sending more.
How a /ws connection is closed
What a Go peer accepts
The CLI and Floe Desktop bound what the server and the other peer can make them hold during setup. A client built to talk to them has to stay inside these:
A client that puts many candidates in its SDP, or trickles more than 256, still connects to
browsers and can fail against the CLI and Floe Desktop with no message.
Request rooms
A request link gets a room that is reserved for the Floe Desktop that made it, rather than seated by join order: that app holds the room and receives, and the person sending joins second. Request rooms exist only while the server’s policy file turns request links on (see Turn on request links), andGET /health lists request-1 in
features while it does.
The host token and the room id
The host token is 32 random bytes, sent as 43 base64url characters. Floe Desktop keeps it in memory, and it leaves the app only inside the host’sjoin-room. The room id in the link is
derived from it:
- Take the SHA-256 of the text
floe-request-room-v1:followed by the token. - Keep the first 16 bytes.
- Set the version and variant bits as for a version 4 UUID.
- Write the result as lowercase hex in the
8-4-4-4-12form.
cli/engine/signaling/testdata/derivation-vectors.json.
A link from Floe Desktop 1.1 and later also holds a link key behind the room id,
as #<room id>.<link key>. It is random, derives from nothing, and plays no part in taking a seat.
The server never receives it, and handles a link with one and a link without one alike. See
kc.
The host join
endsIn is the link’s remaining life in milliseconds. The server keeps it as an end time on its
own clock, and ignores a missing or out-of-range value. It reads endsIn only when the join makes
the room: a reclaim keeps the end the room already has, so a later join cannot move it.
The server checks a host join in this order, and the first answer that applies is the one sent:
A reservation whose host has been away longer than its grace is let go at this point, and the join
goes on as a new link. An ordinary room that sits on the id is taken back before the limits are
checked: everyone in it gets
room-full and loses their seat. A host join that is over more than
one request-link limit answers limited, because the daily budget is checked first.
The sender’s request-join
The server checks arequest-join in this order:
- The connection has already sent 30
request-joinframes in its current 60-second window: no answer at all. The window opens at the first frame after the last one ran out, so it is not a rolling minute. - The room id is not a UUID:
error. - Request links are off:
disabled. - No reservation has that id:
link-endedif a link with that id ended within the last 24 hours, otherwisehost-absent. An id that is no request link at all answers the same way, so the answer confirms nothing. - This connection already holds that room’s seat: no answer at all.
- The link is sealed or used:
room-full. - The host is away in its grace:
link-endedonce the link has passed the end its host named, otherwisehost-absent. - The seat is taken:
room-full. - The link has seated a sender 6 times in the last minute:
room-full, and nothing changes. - Otherwise:
request-joinedwith rolevisitorto the sender, anduser-connectedto the host.
request-join again to be safe. The
request-link page and floe send --to each wait 10 seconds for an answer, then report that none
came.
How a request room seals and ends
A request room seals as soon as both seats have routed a connection-setup message, or when the host sendsrequest-seal, and from then on request-join answers room-full. POST /api/code refuses
a request room’s id, and making the room retires any code already registered for that id, so no
code ever points at one.
The reservation outlives an empty room. When the host’s socket closes, the server keeps the
reservation for about 10 minutes so Floe Desktop can reclaim its seat with the same token, which
does not count as a new link. The edge is exact for the host: a host join up to 10 minutes after
the socket closed is a reclaim, and a later one makes the link again, which counts toward the daily
budget. The once-a-minute sweep then removes the reservation, so it lasts between 10 and 11
minutes. When the host leaves, a sender seated in a room that has not sealed gets host-absent and
loses the seat. A sender in a sealed room keeps the seat and gets peer-disconnected.
Whatever its host is doing, a reservation ends at the first sweep after it turns 7 days and 10
minutes old. Nobody is sent a frame for that, a connected host included.
What request-join answers afterward depends on how the link ended:
A link past the 7-day ceiling is marked ended at once. A lapsed link younger than that, whose host
named no end, leaves no mark and keeps answering
host-absent.
All of it is in memory: a restart forgets every reservation and every mark. An open Floe Desktop
makes its waiting link again when it reconnects, which counts toward its network’s daily budget.
Turning request links off in the policy file ends every request room that is not sealed, at the
server’s next once-a-minute read of the file. A seated host gets refused with disabled, and a
seated sender gets disabled. A sealed room is left alone, because its drop runs on the data
channel and needs nothing more from the server. If its host sends request-reopen while request
links are still off, as Floe Desktop does for Keep waiting after a decline, the server ends
that room the same way. A server image of version 1.11.0 or earlier reopens it instead, and no
request-join can reach it.
Floe Desktop takes refused with disabled as its link’s end. Once links are back on, a link
whose host was told answers link-ended for 24 hours, and a link whose host was away answers from
the end it named, as a lapsed one does.
Rate limiting
Every limit below lives in the server’s memory. The ones with an environment variable have their home in Configuration, and this table repeats their defaults. The request-link limits are fixed in the server; what they mean for the people on a server is in Turn on request links. For the limits of the web app, the CLI, and Floe Desktop as well, see Limits.
Past either mark cap the oldest mark goes. A link whose used mark went answers
host-absent again
and can be made again; a link whose ended mark went answers host-absent again.
The per-IP counters live in plain Maps and are swept every 60 seconds. The three HTTP limiters
and the failed-lookup budget 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 the web app’s transfer page then retries with a growing delay. A /ws
connection over the limit is closed with code 1008 (see
How a /ws connection is closed).
The open-connection cap is checked first, in the same two places, and refuses with the same
Rate limit exceeded, so a client cannot tell the two apart and does not need to. It counts a
connection once the server has admitted it and gives the place back when that connection closes,
however it closes: the client leaves, or a heartbeat in
Keeping connections alive ends a connection that stopped answering,
such as one whose network dropped. It needs no sweep, because a count is dropped when it reaches
zero. An Engine.IO session that never joins a namespace is not counted, as it is not rate limited
either, and Socket.IO closes it after 45 seconds. A session whose client leaves the namespace
without closing is closed by the server at once.
Every per-IP 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. A client
that keeps retrying therefore gets back in as soon as its admitted connections age out of the
60-second window. Setting a MAX_* variable to 0 does not disable a limit, and
neither does any other value that is not a whole number of at least 1. The server keeps the
default and logs one line, as
Configuration describes.
The failed-lookup budget counts the other way around, on purpose: only a miss is recorded, and a
lookup that finds a live code costs nothing. The budget is spent before the lookup runs. A caller
that has run out therefore gets the same 429 whether the code it asked about is live, expired, or
was never registered. That includes a code that works: once an address has 10 misses inside the
window, every lookup from it answers 429 until misses age out.
The budget exists because the shared code limiter is a volume control rather than a guessing
control: 60 requests a minute is 60 guesses a minute, indefinitely. A budget of 10 failures a
minute is far more than a real receiver needs, since a receiver whose code works never spends any
of it.
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.
Every per-IP limiter normalizes the client address before it counts. A port that a proxy appended
to the address is dropped first. An IPv6 address keys on its /64 prefix, so everyone sharing a
/64 shares one bucket. That stops a client from stepping past a limit by rotating the low 64 bits
of its address. An IPv4-mapped IPv6 address (::ffff:1.2.3.4) unwraps to plain IPv4, so the same
client reached through a dual-stack listener counts once rather than twice. Plain IPv4 is
unchanged. On dual-stack deployments, size MAX_CONNECTIONS_PER_IP, MAX_SOCKETS_PER_IP,
MAX_TURN_REQUESTS_PER_IP, MAX_CODE_REQUESTS_PER_IP, and MAX_FAILED_CODE_RESOLVES for a whole
IPv6 /64 rather than a single address.
Room codes
A room code is three random words joined by hyphens, drawn from the 1,247-word list inserver/words.json with crypto.randomInt, a cryptographically secure source rather than
Math.random. The list is the EFF short word list without its hyphenated entry and without words
that read badly in a shared code. The secure source matters because the code is the only secret
guarding the transfer.
On a collision with a code that is still live, the server draws again, up to 10 times, and only
if all 10 collide does it fall back to a four-word code. An expired code counts as free.
A code lasts 10 minutes at most, and the server retires it sooner when its room pairs, empties or
registers a new code, or when the server restarts. What each of those means for the people sharing
a code is in How a transfer works. One more event
retires a code: a request link’s host making its room on that id, which retires every code
registered for it in any letter case. Resolving a code never retires it, so a receiver that fails
after the lookup and before it takes its seat can retry the same code.
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. One case reports without completing. A request link drop can stop after the person at Floe Desktop accepted it. Floe Desktop then reports the bytes of the files it saved under their final names, once, and only with Contribute to global stats on. A drop that completes is reported by the receive itself, so no drop counts twice, and the CLI reports completed transfers only. The total is viewable only in the browser. The CLI and Floe Desktop contribute to it when they receive, 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. Without Redis configured the counter is memory-only and resets to zero on restart. With it, the total lives in Upstash Redis under the keyfloe:bytes_total:
- At startup the server reads the key once and seeds the in-memory value from it.
- Each accepted report adds to the in-memory value at once, and its bytes go to the key in an
INCRBYover Upstash’s REST API, without the report waiting for the answer. OneINCRBYis on its way at a time: the bytes of reports that arrive meanwhile are added up and sent in the next. - Each answer to
INCRBYis the stored total, and raises the in-memory value to 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.