Building a Peer-to-Peer File Transfer Tool with WebRTC, Cloudflare Durable Objects and No File Server
How I built browser-to-browser file transfer for floi.dev: a 200-line Cloudflare Durable Object that relays a WebRTC handshake and nothing else, a DataChannel that streams gigabytes without dying, and the seven problems nobody warns you about.
Every "send a big file" service on the web works the same way. You upload the file to somebody's bucket, they give you a link, the link expires, and for the time in between your file is sitting on a machine you do not own. That is fine for a lot of things. It is a strange amount of ceremony for moving a screen recording from your laptop to your phone.
So I built File Transfer for floi.dev. Open it on two devices, type a six digit code, and the file goes straight from one browser to the other. No account, no upload, no expiry, no size limit on Chrome. This post is the full walkthrough: the architecture, the code, and the seven problems that turned a "two afternoons" project into something considerably longer.
Contents
- What the tool actually does
- Why peer-to-peer, and where the purity breaks
- The stack
- The signalling room: a Durable Object that forgets
- The handshake in the browser
- Problem 1: async signalling loses ICE candidates
- The wire protocol
- Problem 2: backpressure, or how to kill a DataChannel
- Problem 3: streaming to disk needs a user gesture
- Problem 4: a six digit code is one guess
- Problem 5: the peer is hostile input
- Problem 6: no TURN, so fail honestly
- Problem 7: keeping the code off every other page
- What it still cannot do
What the tool actually does
You open the page. It rolls a six digit code, shows it with a QR code, and waits. On the second device you scan the QR or type the code. Within a second or two you are looking at a shared conversation view: drop a file in on one side, it appears on the other, with a live progress bar and then an inline preview if the browser can render it.
Images preview. Video and audio get a player. PDFs get an open button. Text files render inline up to 256 KB. You can also just send text, which turns out to be the feature people use most, because copying a URL from a desktop to a phone is otherwise miserable.
The interesting part is what is not there. There is no upload step, no "preparing your file", no progress bar that fills up twice. The bytes make exactly one trip, device to device.
Why peer-to-peer, and where the purity breaks
floi is a library of single-purpose web tools, and the architecture is the promise: every page is pre-rendered static HTML on a CDN, and all the work happens in your own browser. Image conversion, QR generation, CSV parsing, background removal. None of it touches a server, because there is no server to touch.
File Transfer breaks that rule, on purpose, and it is worth being precise about how.
Two browsers cannot open a direct connection out of nothing. WebRTC needs each side to learn the other's network candidates and DTLS certificate fingerprint, and there is no browser API that does that without a third party in the middle. That third party is called a signalling server, and it is unavoidable.
So the honest claim is not "there is no backend". It is there is no backend any file passes through. The signalling server sees a session description, relays it, and is destroyed. It never learns a filename, never sees a byte of file data, and cannot, because the file travels on a channel it is not part of.
That distinction matters enough that I reworded four pages of site copy around it rather than let the marketing outrun the code.
The stack
| Layer | Choice |
|---|---|
| Site | Astro 7, static output, zero UI framework |
| Transport | WebRTC RTCDataChannel, ordered, SCTP |
| Signalling | Cloudflare Worker + Durable Object with WebSocket hibernation |
| NAT traversal | STUN only (Cloudflare + Google). No TURN. |
| Disk writes | File System Access API where available, Blob fallback |
| Verification | Web Crypto SHA-256 over both DTLS fingerprints |
| Tool JS | Plain ES modules, dynamically imported, ~8 KB shell |
No React, no socket.io, no simple-peer, no PeerJS. The whole client is one 1,300-line module and the server is under 200 lines. Libraries in this space tend to bring a signalling service with them, which is exactly the part I wanted to own.
Here is the shape of it:
The signalling room: a Durable Object that forgets
A Durable Object is the right primitive here for one reason: two browsers using the same code must reach the same instance, and Workers are otherwise stateless and globally distributed. idFromName('482913') guarantees that by construction.
The whole room is a message pipe with a strict diet. It holds two WebSockets, copies frames from one to the other, and refuses to do anything else.
import { DurableObject } from 'cloudflare:workers';
/** Close codes. 1000 and 3000-4999 are the only values a server may send. */
const CLOSE = {
TAKEN: 4001, // that code already has someone waiting on it
NO_ROOM: 4004, // nobody is waiting on that code
FULL: 4009, // two devices are already paired here
EXPIRED: 4408, // the ten minutes ran out
OVERSIZE: 4413, // a frame far larger than a session description
CHATTY: 4429, // more frames than any handshake needs
};
const ROOM_TTL_MS = 10 * 60 * 1000;
const MAX_FRAME = 16 * 1024;
const MAX_FRAMES = 200;
Those three constants are the entire abuse story. A session description with candidates inlined is a few kilobytes, so 16 KB is generous and anything past it is not signalling. A complete handshake is an offer, an answer and a few dozen ICE candidates, so 200 frames per room is generous and anything past it is someone trying to use my Durable Object as a free chat relay.
Routing: the isolate never runs for a page
This was the part I was most careful about. floi's whole cost model is that page loads are pure CDN reads. Adding a main entry point to wrangler.jsonc used to mean putting a script in front of every request. It does not any more, but I did not want that to be an inference:
"main": "./src/worker/index.js",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page",
// The one prefix that reaches the script. Everything else is an asset.
"run_worker_first": ["/signal/*"]
},
"durable_objects": {
"bindings": [{ "name": "SIGNAL_ROOM", "class_name": "SignalRoom" }]
},
// Must be new_sqlite_classes. The Free plan has only ever supported
// SQLite-backed Durable Objects.
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SignalRoom"] }]
run_worker_first makes it explicit: the privacy policy page does not spawn an isolate, the QR generator does not spawn an isolate, and the only path on the site that costs a Worker request is /signal/*.
Origin checking before the object exists
Without this, any site on the internet can point its own WebRTC app at floi.dev/signal/ and use it as free signalling infrastructure, billed to me.
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/signal\/(\d{6})$/);
if (!match) return env.ASSETS.fetch(request);
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('This endpoint speaks WebSocket only.', { status: 426 });
}
// Checked BEFORE the Durable Object is addressed, so a rejected request
// never instantiates one and never costs a DO request.
if (!originAllowed(request)) {
return new Response('Not allowed from this origin.', { status: 403 });
}
// idFromName is a hash, so the code is not recoverable from the object id,
// and two people using the same code land in the same room by construction.
const id = env.SIGNAL_ROOM.idFromName(match[1]);
return env.SIGNAL_ROOM.get(id).fetch(request);
},
};
Browsers always send Origin on a WebSocket handshake, so this is cheap and effective against the case it is meant for. It is not a security boundary against a non-browser client, which can send any Origin it likes. The frame caps and the ten minute alarm are what bound that case.
Roles, hibernation and the one-guest rule
export class SignalRoom extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
// Keepalives must not wake the object. The runtime answers these itself
// while the room hibernates, which is what makes an idle room free.
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
}
async fetch(request) {
const role = new URL(request.url).searchParams.get('role');
const hosts = this.ctx.getWebSockets('host');
const guests = this.ctx.getWebSockets('guest');
if (role === 'host' && hosts.length) return refuse(CLOSE.TAKEN, 'Code in use');
if (role === 'guest' && !hosts.length) return refuse(CLOSE.NO_ROOM, 'No such code');
if (role === 'guest' && guests.length) return refuse(CLOSE.FULL, 'Room already paired');
if (role !== 'host' && role !== 'guest') return new Response('Bad role', { status: 400 });
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
// Hibernatable, and TAGGED so the role survives eviction. In-memory fields
// would not: the object can be evicted between two messages of one
// handshake and reconstructed with an empty constructor.
this.ctx.acceptWebSocket(server, [role]);
// Set once, on first arrival. Re-arming per message would let a busy room
// live forever, which is the opposite of what the ten minutes is for.
if (!(await this.ctx.storage.getAlarm())) {
await this.ctx.storage.setAlarm(Date.now() + ROOM_TTL_MS);
}
server.send(JSON.stringify({ t: 'role', role }));
// The host has been staring at a code waiting for exactly this.
if (role === 'guest') for (const h of hosts) send(h, { t: 'peer-joined' });
return new Response(null, { status: 101, webSocket: client });
}
Two details there are load-bearing.
The role goes in the WebSocket tag, not an instance field. Hibernation means the object can be evicted between two messages of a single handshake and reconstructed with a fresh, empty constructor. Anything you need to survive that must live in the tags or in storage. I learned this the way you would expect: it worked perfectly in local dev, where nothing ever hibernates.
A guest is one shot. If nobody is waiting, the code is wrong. If a guest has already been here, the room is spent. This one-guest rule is the entire defence for a six digit code, and it is Magic Wormhole's design: a guess either wins immediately or burns the room.
I first sketched this as a "five wrong attempts then lock" counter, which protects nothing. One correct guess is already a full compromise, so rationing further guesses is theatre. Burning the room on the first wrong-but-live guess is loud: the legitimate pair see the failure and start again on a fresh code.
Relaying without reading
webSocketMessage(ws, message) {
if (typeof message !== 'string' || message.length > MAX_FRAME) {
ws.close(CLOSE.OVERSIZE, 'Frame too large');
return;
}
// Deliberately an instance field rather than storage. Storage would mean a
// write per relayed frame, which is the cost this is trying to avoid. It
// resets if the object hibernates, and that is correct: a client spamming
// to hold the object awake is exactly the client whose counter survives.
this.frames = (this.frames || 0) + 1;
if (this.frames > MAX_FRAMES) {
ws.close(CLOSE.CHATTY, 'Too many messages');
return;
}
// Relayed verbatim. Not parsed, not inspected, not kept.
const mine = this.ctx.getWebSockets('host').includes(ws) ? 'guest' : 'host';
for (const peer of this.ctx.getWebSockets(mine)) {
try { peer.send(message); } catch { /* peer went away mid-relay */ }
}
}
The counter lives in memory on purpose. Putting it in storage means a durable write per relayed ICE candidate, which is precisely the cost this design exists to avoid.
And the cleanup:
webSocketClose(ws) {
// Tell the other side promptly. Without this the survivor waits on an ICE
// timeout to work out that nobody is coming, which reads as a hang.
for (const peer of this.ctx.getWebSockets()) {
if (peer !== ws) send(peer, { t: 'peer-left' });
}
this.#reapIfEmpty(ws);
}
async alarm() {
for (const ws of this.ctx.getWebSockets()) {
try { ws.close(CLOSE.EXPIRED, 'Room expired'); } catch {}
}
await this.ctx.storage.deleteAll();
}
/**
* `ws` is STILL COUNTED by getWebSockets() inside the close handler, hence
* the comparison against one rather than zero.
*/
async #reapIfEmpty(ws) {
const live = this.ctx.getWebSockets().filter((s) => s !== ws);
if (live.length) return;
await this.ctx.storage.deleteAlarm();
await this.ctx.storage.deleteAll();
}
}
One more small trick. When the room refuses a connection, it does not return a 4xx. It returns a 101 that closes immediately, so the client can read a close code instead of a fetch error:
/** A 101 that closes immediately, so the client reads a code rather than a fetch error. */
function refuse(code, reason) {
const pair = new WebSocketPair();
pair[1].accept();
pair[1].close(code, reason);
return new Response(null, { status: 101, webSocket: pair[0] });
}
That is what lets the client tell "that code is taken, roll another" apart from "nothing is waiting on that code, you typed it wrong". The browser WebSocket API gives you almost nothing on a failed handshake, so smuggling the reason through a close code is the only way to get a decent error message to the user.
The handshake in the browser
The client rolls a code with crypto.getRandomValues, so two people opening the page at the same moment do not collide:
const digits = (n) => String(n).padStart(6, '0');
function rollCode() {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return digits(buf[0] % 1000000);
}
Opening the room is a promise, and it has a timeout that is not optional:
/**
* A WebSocket that cannot connect does NOT reliably tell you so. If something
* accepts the TCP connection and then never completes the upgrade, the socket
* sits in CONNECTING with no open, no error and no close, forever. A captive
* portal does this, some corporate proxies do this, and so does the Astro dev
* server, which has no /signal/ route and leaves the upgrade hanging.
*/
const OPEN_TIMEOUT_MS = 8000;
function openRoom(code, as) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(socketUrl(code, as));
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
ws.close();
reject({ code: 0, timeout: true });
}, OPEN_TIMEOUT_MS);
const done = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(value);
};
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.t === 'role') { done(resolve, ws); return; }
onSignal(msg);
});
ws.addEventListener('close', (e) => {
if (settled) onRoomClosed(e);
else done(reject, e);
});
ws.addEventListener('error', () => done(reject, { code: 0 }));
});
}
The room resolves the promise by sending {t:'role'}, not by the socket opening. An open socket only means Cloudflare accepted the upgrade; the role message means the room accepted you.
The peer connection is deliberately STUN only:
/** STUN only. A TURN server would relay the file bytes, which is the one thing
* this tool promises not to do. */
const ICE_SERVERS = [
{ urls: 'stun:stun.cloudflare.com:3478' },
{ urls: 'stun:stun.l.google.com:19302' },
];
async function makeOffer() {
makePeer();
// The host opens the channel, so exactly one side does and there is no
// race over which one wins.
attachChannel(pc.createDataChannel('floi', { ordered: true }));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signal({ t: 'offer', sdp: offer.sdp });
}
Only the host calls createDataChannel. The guest gets it from the datachannel event. Both sides creating one is a classic way to end up with two half-used channels and a bug you only see on slow networks.
Problem 1: async signalling loses ICE candidates
This is the bug that cost me the most time, and it is invisible on a single machine.
WebSocket messages arrive in order. Your handler is async. Those two facts do not compose. If an ICE candidate arrives while setRemoteDescription is still awaiting, addIceCandidate runs against a connection with no remote description, throws, and the candidate is gone. The only trace is a line in the console.
On one laptop testing against itself, a host candidate always works, so you never notice. Across two networks, the early candidates are often precisely the ones that would have succeeded. Which is the exact case the tool exists for.
The fix is two-part. First, serialise every signalling message through a promise chain:
let signalChain = Promise.resolve();
const onSignal = (msg) => {
signalChain = signalChain
.then(() => handleSignal(msg))
.catch((err) => console.error('[floi transfer] signalling', err));
};
Second, queue candidates that arrive before there is anywhere to put them:
let pendingCandidates = [];
async function addCandidate(candidate) {
if (!pc || !pc.remoteDescription) {
pendingCandidates.push(candidate);
return;
}
await pc.addIceCandidate(candidate);
}
async function flushCandidates() {
const list = pendingCandidates;
pendingCandidates = [];
for (const candidate of list) {
try { await pc.addIceCandidate(candidate); }
catch (err) { console.error('[floi transfer] candidate', err); }
}
}
async function handleSignal(msg) {
if (msg.t === 'peer-joined') { await makeOffer(); return; }
if (!pc) return;
if (msg.t === 'offer') {
await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
await flushCandidates();
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signal({ t: 'answer', sdp: answer.sdp });
} else if (msg.t === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
await flushCandidates();
} else if (msg.t === 'ice' && msg.candidate) {
await addCandidate(msg.candidate);
}
}
If you take one thing from this post: your WebRTC signalling handler must be serialised and your candidates must be queued. Every tutorial skips both, because every tutorial is tested in two tabs on one machine.
The wire protocol
Once the channel opens, the two browsers speak a tiny protocol of their own. JSON strings are control messages, ArrayBuffers are file data. The split is that clean because the DataChannel preserves the distinction for you:
function onChannelMessage(e) {
if (typeof e.data !== 'string') return onChunk(e.data);
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.t === 'hello') { /* peer's display name */ }
if (msg.t === 'offer-files') { /* here is a batch, want it? */ }
if (msg.t === 'accept') { sendQueue(msg.ids); }
if (msg.t === 'decline') { /* they said no */ }
if (msg.t === 'text') { /* a chat message */ }
if (msg.t === 'file-start') { startIncoming(msg); }
if (msg.t === 'file-end') { finishIncoming(); }
}
Because the channel is ordered: true, the receiver can rely on every binary chunk between a file-start and a file-end belonging to that file. No per-chunk headers, no sequence numbers, no reassembly buffer. SCTP already did that work; duplicating it in the application layer would be pure overhead.
The first thing that crosses the channel is a safety code and a greeting:
async function onChannelOpen() {
everConnected = true;
setState('chat');
const code = await safetyCode(pc.localDescription?.sdp, pc.remoteDescription?.sdp);
if (ui.safety) ui.safety.textContent = code || '----';
channel.send(JSON.stringify({ t: 'hello', label: me }));
systemLine(`Connected. Safety code ${code || 'unavailable'}, it should match on both devices.`);
// The room has done its only job. Keeping it open would hold a Durable
// Object alive for the length of the transfer.
if (socket) socket.close(1000, 'Paired');
ui.text?.focus({ preventScroll: true });
}
That socket.close() is the moment the server exits the story. From there the transfer is two browsers and nothing else.
Problem 2: backpressure, or how to kill a DataChannel
Here is the naive send loop, which is also the one in most tutorials:
// DO NOT DO THIS
for (let offset = 0; offset < file.size; offset += chunk) {
channel.send(await file.slice(offset, offset + chunk).arrayBuffer());
}
RTCDataChannel.send() does not block and does not push back. It accepts everything you give it and queues it. Chrome tears the channel down once roughly 16 MB is sitting in the send buffer, and reading a file from disk is far faster than pushing bytes across a network, so that loop is a race you lose on anything bigger than a photo.
It works flawlessly on a 2 MB image. It fails on every real video. That asymmetry is what makes it such a good trap.
The fix is bufferedAmountLowThreshold and the bufferedamountlow event:
/** Pause the sender above this, resume when the buffer drains below a quarter
* of it. Well under Chrome's cutoff, and deep enough to keep the link busy. */
const BUFFER_HIGH = 4 * 1024 * 1024;
channel.binaryType = 'arraybuffer';
channel.bufferedAmountLowThreshold = BUFFER_HIGH / 4;
async function sendOne({ id, file }) {
const chunk = Math.max(16384, Math.min(pc.sctp?.maxMessageSize || 65536, 65536));
channel.send(
JSON.stringify({ t: 'file-start', id, name: file.name, size: file.size, mime: file.type })
);
let offset = 0;
while (offset < file.size) {
// Waiting here is the whole reason large files work.
if (channel.bufferedAmount > BUFFER_HIGH) await drained();
if (channel.readyState !== 'open') {
settle(id, { note: 'Stopped, the connection went away', size: file.size });
return;
}
const slice = file.slice(offset, offset + chunk);
channel.send(await slice.arrayBuffer());
offset += chunk;
progress(id, Math.min(offset, file.size), file.size);
}
channel.send(JSON.stringify({ t: 'file-end', id }));
settle(id, { blob: file, size: file.size });
}
The chunk size is negotiated, not guessed. pc.sctp.maxMessageSize is what the peer said it can accept, and it varies between implementations. Clamping to 64 KB and flooring at 16 KB keeps it inside what every browser handles.
And the wait itself has a subtlety worth its own comment:
/**
* Wait for the send buffer to drain.
*
* Resolves on close as well as on drain. `bufferedamountlow` never fires on
* a channel that has gone away, so waiting only for that leaves the send
* loop parked forever on a peer that closed its tab mid-transfer.
*/
function drained() {
return new Promise((resolve) => {
const off = () => {
channel.removeEventListener('bufferedamountlow', off);
channel.removeEventListener('close', off);
channel.removeEventListener('error', off);
resolve();
};
channel.addEventListener('bufferedamountlow', off);
channel.addEventListener('close', off);
channel.addEventListener('error', off);
});
}
A promise that only resolves on the happy path is a hang waiting to happen. Resolve on close and error too, then re-check readyState at the top of the loop.
Note also that file.slice() is lazy. Slicing a 4 GB file does not read 4 GB; arrayBuffer() on the slice reads exactly that window. Memory on the sending side stays flat regardless of file size.
Problem 3: streaming to disk needs a user gesture
The receiving side has a harder problem. If you accumulate chunks in an array and build a Blob at the end, the whole file lives in the tab's memory. That is fine for 40 MB and fatal for 4 GB.
The File System Access API solves it. showSaveFilePicker() gives you a handle, createWritable() gives you a stream, and chunks go to disk as they arrive with a flat memory profile.
Except: showSaveFilePicker requires a user gesture, and a file arriving over the network is not one.
You cannot open the picker in the file-start handler. By the time bytes are landing, there is no gesture to spend. The picker has to be opened during a click that happened earlier.
That constraint produced the best design decision in the tool. There is an Accept step for large batches, and it is both the consent prompt and the gesture:
/**
* Batches at or under this arrive without asking.
*
* Above it there is an Accept step, which is also exactly where one is needed:
* a browser will only open a save dialog during a click, so a transfer too big
* to hold in memory has to be authorised by one anyway. The threshold does two
* jobs with one prompt.
*/
const AUTO_ACCEPT_LIMIT = 25 * 1024 * 1024;
/** Past this, a browser without the File System Access API is being asked to
* hold more than it comfortably can, so we say so instead of trying. */
const MEMORY_LIMIT = 256 * 1024 * 1024;
const supportsStreaming =
typeof window.showSaveFilePicker === 'function' &&
typeof window.showDirectoryPicker === 'function';
Anything under 25 MB just arrives, because asking twice to send a photo between your own two devices is the kind of friction that sends people back to emailing themselves. Above it, you get a card with the file count and size, and Accept spends the gesture:
/**
* The click is the user gesture the File System Access API requires, so the
* save target has to be chosen HERE and not when the first byte arrives.
*/
async function acceptOffer(msg, total, ask) {
let target = null;
// A native save dialog only where one is genuinely needed. Anything that
// fits in memory arrives in the conversation instead, so a 40 MB video is
// still something you can play before deciding to keep it.
if (supportsStreaming && total > MEMORY_LIMIT) {
try {
target = msg.files.length > 1
? { dir: await window.showDirectoryPicker({ mode: 'readwrite' }) }
: { file: await window.showSaveFilePicker({ suggestedName: safeName(msg.files[0].name) }) };
} catch {
// Picker dismissed. Nothing was declined, so leave the offer standing.
return;
}
}
pending = { ...msg, target, accepted: true };
ask.remove();
channel.send(JSON.stringify({ t: 'accept', ids: msg.files.map((f) => f.id) }));
}
Then the chunk handler branches on whether there is a writer:
async function onChunk(buf) {
if (!incoming) return;
// The size was declared by the other device before any of this arrived.
if (incoming.received + buf.byteLength > incoming.size) {
await abortIncoming('The other device sent more than it said it would.');
return;
}
incoming.received += buf.byteLength;
if (incoming.writer) await incoming.writer.write(buf); // straight to disk
else incoming.parts.push(buf); // held in memory
progress(incoming.id, incoming.received, incoming.size);
}
async function finishIncoming() {
if (!incoming) return;
const file = incoming;
incoming = null;
if (file.writer) {
await file.writer.close();
settle(file.id, { note: 'Saved to disk', size: file.size });
} else {
const blob = new Blob(file.parts, { type: file.mime || 'application/octet-stream' });
settle(file.id, { blob, size: file.size });
}
}
Safari and Firefox have no File System Access API, so a transfer there is held in memory until it completes. That caps a practical transfer at a gigabyte or two, and the page says so up front rather than letting a tab die at 90%:
ui.capability.textContent = supportsStreaming
? 'Anything over 25 MB asks first. Over 256 MB it streams straight to disk, so there is no size limit.'
: 'Anything over 25 MB asks first. This browser holds a transfer in memory until it finishes, '
+ 'so very large files may not complete. Chrome or Edge will stream instead.';
One more thing that only shows up in production: an interrupted transfer leaves an open writable on a real file, and a partial file on disk with nothing to indicate it is incomplete. So the channel's close handler aborts it explicitly:
channel.addEventListener('close', () => {
if (incoming) abortIncoming('The other device disconnected part-way.');
else if (ui.stage.dataset.state === 'chat') peerLeft();
});
async function abortIncoming(reason) {
const file = incoming;
incoming = null;
if (!file) return;
if (file.writer) {
try { await file.writer.abort(); } catch { /* already gone */ }
}
settle(file.id, { note: 'Stopped', size: file.size });
systemLine(reason);
}
Problem 4: a six digit code is one guess
A six digit room code has a million values, and the one-guest rule means an attacker gets exactly one attempt before the room burns. That is a good property. It is not the same as knowing you connected to the right device.
WebRTC encrypts the DataChannel with DTLS, and each side's SDP carries a fingerprint of the certificate that encryption actually uses. If both devices hash both fingerprints and show you the result, matching digits mean the encryption on your screen is the encryption on theirs. This is the same idea as Signal's safety numbers.
/**
* The DTLS fingerprint out of an SDP. This is the hash of the certificate the
* connection is actually encrypted with, which is what makes it worth showing.
*/
function fingerprintOf(sdp) {
const m = sdp && sdp.match(/^a=fingerprint:\s*\S+\s+(\S+)/m);
return m ? m[1].toUpperCase() : '';
}
/** Four digits from both fingerprints. Order-independent, so both ends agree. */
async function safetyCode(localSdp, remoteSdp) {
const parts = [fingerprintOf(localSdp), fingerprintOf(remoteSdp)].sort();
if (!parts[0] || !parts[1]) return null;
const bytes = new TextEncoder().encode(parts.join('|'));
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
return String(((hash[0] << 8) | hash[1]) % 10000).padStart(4, '0');
}
The .sort() is what makes it order-independent, so both ends compute the same four digits without needing to agree on who is "first". Four digits is a deliberate trade: it is short enough that people will actually compare it, and it is not a secret, it is a comparison.
Problem 5: the peer is hostile input
"The peer is someone you gave a code to" is a reason to trust a person. It is not a reason to trust bytes. Everything arriving over the channel was chosen by the other device: the filename, the declared size, the message body, the display name.
Filenames go into getFileHandle() and download attributes, so anything that could read as a path is stripped:
function safeName(name) {
const bare = String(name ?? '')
.split(/[/\\]/)
.pop()
.replace(/^\.+/, '')
.slice(0, 200)
.trim();
return bare || 'file';
}
The declared size is a hard ceiling, not a progress denominator. Nothing else stops a peer streaming without end into an array in memory until the tab dies. That is the check at the top of onChunk above.
Batch length is bounded, so the other device does not get to decide how long my loops are:
const BATCH_LIMIT = 500;
if (msg.files.length > BATCH_LIMIT) {
channel.send(JSON.stringify({ t: 'decline' }));
systemLine(`${peerName} offered more than ${BATCH_LIMIT} files at once. Declined.`);
return;
}
The display name is truncated and only ever set as textContent. Initials for the avatar are filtered to letters and digits, because that string goes into SVG markup:
const initials = name.split(/\s+/).map((w) => w[0] || '').join('')
.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || '?';
Per-transfer DOM handles live in a Map keyed by id, not in attribute selectors, so a peer cannot break a lookup by choosing an id with a quote in it.
And file-start is checked against what was actually accepted, because the offer list alone is not permission:
async function startIncoming(msg) {
// A peer that sent file-start straight after offer-files would otherwise
// start writing before the prompt was answered.
if (!pending?.accepted) return;
const offered = pending.files.find((f) => f.id === msg.id);
if (!offered) return;
// ...
}
None of these are exotic. They are just the checks you skip when you think of the other end as "my own phone" rather than as a socket.
Problem 6: no TURN, so fail honestly
WebRTC finds a direct path most of the time. On a symmetric NAT, a locked-down corporate network or some mobile carriers, it cannot. The standard answer is a TURN server that relays the traffic, and a TURN server would carry the file bytes, which is the one thing this tool promises not to do.
So there is no TURN, and the honest answer to a failure is a sentence naming the two things that actually fix it. Not a spinner.
The catch is that connectionState === 'failed' means two entirely different things depending on when it happens:
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState !== 'failed') return;
// `failed` means two different things and they need different answers.
//
// Before a connection was ever made, it means no route could be found,
// which is a network the visitor can do something about. After one has
// been made and lost, it means the other person closed their tab, and
// telling them their network blocks direct connections would be a lie.
if (everConnected) {
peerLeft();
return;
}
fail(
'This network will not allow a direct connection. Put both devices on the same Wi-Fi, '
+ 'or turn on a phone hotspot and join it from the other device.'
);
});
Same event, same state, opposite messages. One boolean.
Problem 7: keeping the code off every other page
floi has seventeen tools and one stylesheet, and the rule is that a tool's JavaScript loads only on the tool's own page. main.js is a shell with a registry, and every entry is a dynamic import:
{
name: 'transfer',
match: '[data-tool="transfer"]', // proves the tool is on this page
load: () => import('./file-transfer.js'),
start: (mod, el, ctx) => mod.initFileTransfer(el, ctx),
needs: true, // it uses the lightbox + workspace
}
The specifier has to be a literal string. A computed one stops Rollup emitting a separate chunk and quietly defeats the whole mechanism. One static import of an earlier tool put 34 KB of screen-capture code on the privacy policy page, and nothing about that failure is visible: the pages render, the tools work, the tests pass. So npm run check now fails the build on a static import, on a tool module that lost its own chunk, and on the every-page bundle going over 12 KB.
Current state: 8.1 KB on every page, seventeen tool chunks on demand. The 20 KB of WebRTC logic in this post ships to exactly one URL.
What it still cannot do
Being straight about the edges is the point of the whole site, so:
- Both tabs must stay open. There is no store-and-forward. If you close the page, the transfer stops. This is not a Dropbox replacement.
- Restrictive networks fail. No TURN means no relay fallback. Same Wi-Fi or a phone hotspot fixes it.
- Safari and Firefox cap out around a gigabyte, because they have no File System Access API and the file is held in memory.
- One file at a time on the wire. The channel is ordered and the receiver relies on that, so a batch is sequential rather than interleaved. It costs nothing in throughput and buys a much simpler protocol.
- Codes last ten minutes. That is the room's alarm, not the transfer's. Once paired, the connection lives as long as both tabs do.
Closing
The interesting thing about this build was how much of it was deleting server. The first sketch had a room that tracked attempt counters, persisted state, and knew about transfers. Every one of those turned out to be either theatre or a liability, and the version that shipped holds two sockets, counts to 200, and dies.
The tool is at floi.dev/file-transfer if you want to try it. Open it on two devices and send yourself a screenshot. That is the whole pitch.