// /Send — a file from one browser to another, over a WebRTC data channel. // // The site's part is the introduction only: a WebSocket at /api/send/{code} carries the offer, // the answer and the ICE candidates between the two browsers holding the same code. Once the data // channel opens, the bytes go peer to peer and this page stops talking to the server. Closing the // signalling socket after that would not interrupt a transfer. import {getById, h, writeDebug, writeError, writeInfo} from "/common.module.js"; import {QRCode} from "/qrcode.js"; // Crockford base32: no I, L, O or U, so a code survives being read out loud or written down. const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const CODE_LENGTH = 12; const CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{12}$/; // 64 KiB is inside every current browser's SCTP limit, and pc.sctp trims it further where a peer // asks for less. Smaller chunks cost a round through the event loop each; larger ones buy little. const CHUNK = 64 * 1024; // Hand the channel a few megabytes of work at a time and then wait. Without this the whole file // is queued in the first tick, which costs the tab the file's worth of memory and gives the // progress bar nothing to say. const HIGH_WATER = 8 * 1024 * 1024; const LOW_WATER = 1 * 1024 * 1024; const state = getById("state"); const pairing = getById("pairing"); const linkField = getById("link"); const codeText = getById("code"); const copyButton = getById("copy"); const joinForm = getById("joinForm"); const joinInput = getById("joinCode"); const fileInput = getById("files"); const dropZone = getById("drop"); const outgoing = getById("outgoing"); const incoming = getById("incoming"); const nothingYet = getById("nothingYet"); /** @type {RTCPeerConnection|null} */ let connection = null; /** @type {RTCDataChannel|null} */ let channel = null; /** Set once the server has said why this socket is over, so the close event stays quiet. */ let signallingEnded = false; /** Whether this connection ever came up, which is the difference between "no route" and "lost". */ let everConnected = false; /** Candidates that arrived before the description they belong to; added once it lands. */ let earlyCandidates = []; /** The file currently arriving, if any. Only one is ever in flight in each direction. */ let arriving = null; /** Outgoing files go one at a time, because the receiver's side of the protocol assumes it. */ let queue = Promise.resolve(); /* -- The room ------------------------------------------------------------- */ function newCode() { const bytes = new Uint8Array(CODE_LENGTH); crypto.getRandomValues(bytes); // 5 bits per character out of 8: the other 3 are thrown away rather than folded in, which // keeps every character uniform over the alphabet. return Array.from(bytes, byte => ALPHABET[byte & 31]).join(""); } /** Put a typed or pasted code back into the shape the alphabet uses. */ function normalise(text) { return text.toUpperCase().replace(/[^0-9A-Z]/g, "") .replace(/[IL]/g, "1") .replace(/O/g, "0"); } function group(code) { return code.replace(/(.{4})(?=.)/g, "$1-"); } const fromHash = normalise(location.hash.slice(1)); const room = CODE_PATTERN.test(fromHash) ? fromHash : newCode(); const shareUrl = new URL(location.href); shareUrl.hash = room; // replaceState rather than assigning to location.hash: the code belongs in the address bar so the // link can be copied out of it, but arriving here should not leave a history entry behind. The // whole URL, not a bare "#code": App.razor sets , and replaceState resolves a // relative URL against that, which would quietly move this page to the site root. if (room !== fromHash) history.replaceState(null, "", shareUrl.href); linkField.value = shareUrl.href; codeText.textContent = group(room); new QRCode("qrcode", shareUrl.href); copyButton.addEventListener("click", async () => { try { await navigator.clipboard.writeText(shareUrl.href); writeInfo("Link copied."); } catch { // No clipboard permission, or an insecure origin. Select it so ctrl-C still works. linkField.select(); writeError("Could not copy — the link is selected instead."); } }); joinForm.addEventListener("submit", event => { event.preventDefault(); const code = normalise(joinInput.value); if (!CODE_PATTERN.test(code)) { writeError("That is not a code — twelve letters and digits, no I, L, O or U."); return; } if (code === room) return; location.hash = code; }); // Someone already on this page who follows a link to another room, or types a code above, only // changes the fragment — the browser does not reload for that, and this module body *is* the // page's setup, so without this the page would sit in the room it opened with. A whole load // rather than rewiring the connection in place, which is what every other page here relies on // too. replaceState above does not fire this. addEventListener("hashchange", () => location.reload()); /* -- Signalling ----------------------------------------------------------- */ if (!window.isSecureContext || !("RTCPeerConnection" in window)) { setState("This browser will not do WebRTC here — it needs https (or localhost)."); throw new Error("WebRTC unavailable"); } const iceUrls = JSON.parse(getById("ice").dataset.servers); const rtcConfig = {iceServers: iceUrls.length > 0 ? [{urls: iceUrls}] : []}; const socket = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/send/${room}`); socket.addEventListener("open", () => writeDebug("Signalling socket open.")); socket.addEventListener("error", () => writeError("The signalling socket failed.")); socket.addEventListener("close", () => { writeDebug("Signalling socket closed."); // Only worth reporting while it still had a job to do, and only when the server has not // already said something better than "it closed" — the close follows that message. if (!signallingEnded && channel?.readyState !== "open") { setState("Lost the signalling socket. Reload to try again."); } }); // One at a time, in the order they arrived. Handling an offer is several awaits long, and a // candidate that overtook it would be added against a connection that is still half-described. let handling = Promise.resolve(); socket.addEventListener("message", event => { let message; try { message = JSON.parse(event.data); } catch { writeError("Unreadable signalling message."); return; } handling = handling.then(() => onSignal(message)).catch(error => writeError(error)); }); async function onSignal(message) { switch (message.type) { case "waiting": setState("Waiting for the other side — send them the link."); break; case "ready": setState("Other side found; connecting…"); await connect(message.initiator); break; case "peer-left": if (channel?.readyState === "open") { // The peer connection stands on its own once it is up; only the introduction ended. writeInfo("The other side's signalling link dropped. The transfer link is still up."); } else { teardown(); setState("The other side left. Waiting for someone to join."); } break; case "busy": signallingEnded = true; setState("That room already has two browsers in it. Start a fresh one from /Send."); break; case "offer": await connect(false); await connection.setRemoteDescription(message.sdp); await connection.setLocalDescription(await connection.createAnswer()); signal({type: "answer", sdp: connection.localDescription}); await flushCandidates(); break; case "answer": await connection.setRemoteDescription(message.sdp); await flushCandidates(); break; case "ice": // A candidate can outrun the description it belongs to; hold it until there is one. if (connection?.remoteDescription) await connection.addIceCandidate(message.candidate); else earlyCandidates.push(message.candidate); break; default: writeDebug(`Ignoring signalling message: ${message.type}`); } } function signal(message) { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } async function flushCandidates() { const candidates = earlyCandidates; earlyCandidates = []; for (const candidate of candidates) await connection.addIceCandidate(candidate); } /* -- The peer connection --------------------------------------------------- */ /** * Build the peer connection, once. The browser that arrives second makes the offer, because it is * the only one that knows both ends are present. */ async function connect(initiator) { if (connection) return; connection = new RTCPeerConnection(rtcConfig); connection.addEventListener("icecandidate", event => { if (event.candidate) signal({type: "ice", candidate: event.candidate}); }); connection.addEventListener("connectionstatechange", () => { // Null once teardown has run: closing the connection queues one last event of its own. const current = connection?.connectionState; if (!current) return; writeDebug(`Connection: ${current}`); if (current === "connected") { everConnected = true; return; } if (current !== "failed" && current !== "closed") return; // Take it all down rather than leaving a dead connection in place: the other side may // still be holding the link, and a peer that comes back is a fresh negotiation that // connect() would otherwise refuse to start. const lost = everConnected; teardown(); setState(lost ? "The connection dropped. Waiting for the other side to come back." : "Could not find a route between the two browsers."); }); // The answering side is handed the channel the offering side made. connection.addEventListener("datachannel", event => attach(event.channel)); if (initiator) { attach(connection.createDataChannel("files", {ordered: true})); await connection.setLocalDescription(await connection.createOffer()); signal({type: "offer", sdp: connection.localDescription}); } } function teardown() { channel?.close(); connection?.close(); channel = null; connection = null; earlyCandidates = []; arriving = null; everConnected = false; fileInput.disabled = true; pairing.hidden = false; } function attach(dataChannel) { channel = dataChannel; channel.binaryType = "arraybuffer"; channel.bufferedAmountLowThreshold = LOW_WATER; channel.addEventListener("open", () => { fileInput.disabled = false; pairing.hidden = true; setState("Connected. Pick a file."); describeRoute(); }); channel.addEventListener("close", () => { fileInput.disabled = true; pairing.hidden = false; setState("The connection closed."); }); channel.addEventListener("error", event => writeError(event.error ?? "Data channel error.")); channel.addEventListener("message", onData); } /** * Say which route the two ends settled on, since "peer to peer" is the whole point of the page * and a relayed connection would not be one. */ async function describeRoute() { try { const stats = await connection.getStats(); let pair = null; stats.forEach(report => { if (report.type === "candidate-pair" && report.state === "succeeded") pair = report; }); if (!pair) return; const local = stats.get(pair.localCandidateId)?.candidateType; const remote = stats.get(pair.remoteCandidateId)?.candidateType; const relayed = local === "relay" || remote === "relay"; writeInfo(`Route: ${local} to ${remote}${relayed ? " (relayed)" : " (direct)"}.`); } catch (error) { writeDebug(`No route stats: ${error}`); } } /* -- Sending --------------------------------------------------------------- */ fileInput.addEventListener("change", () => { enqueue(fileInput.files); // Clear it, so picking the same file again is still a change event. fileInput.value = ""; }); for (const type of ["dragenter", "dragover"]) { dropZone.addEventListener(type, event => { event.preventDefault(); dropZone.classList.add("over"); }); } for (const type of ["dragleave", "drop"]) { dropZone.addEventListener(type, event => { event.preventDefault(); dropZone.classList.remove("over"); }); } dropZone.addEventListener("drop", event => { if (channel?.readyState !== "open") { writeError("Not connected yet."); return; } if (event.dataTransfer?.files?.length) enqueue(event.dataTransfer.files); }); function enqueue(files) { for (const file of files) { queue = queue.then(() => sendFile(file)).catch(error => writeError(error)); } } async function sendFile(file) { if (channel?.readyState !== "open") { writeError(`Not connected — ${file.name} was not sent.`); return; } const row = addRow(outgoing, file.name, file.size); channel.send(JSON.stringify({kind: "start", name: file.name, size: file.size, mime: file.type})); // pc.sctp only exists once the transport is up, which it is by the time a channel is open. const limit = Math.min(CHUNK, connection.sctp?.maxMessageSize || CHUNK); for (let offset = 0; offset < file.size; offset += limit) { const chunk = await file.slice(offset, offset + limit).arrayBuffer(); await drain(); if (channel.readyState !== "open") { row.fail("interrupted"); return; } channel.send(chunk); // What the channel still holds has not gone anywhere yet, so take it back off the total. row.progress(Math.max(0, offset + chunk.byteLength - channel.bufferedAmount)); } channel.send(JSON.stringify({kind: "end"})); row.done("sent"); } /** Resolve once the channel has worked off its backlog, or given up. */ function drain() { if (channel.bufferedAmount < HIGH_WATER) return Promise.resolve(); return new Promise(resolve => { const go = () => { channel.removeEventListener("bufferedamountlow", go); channel.removeEventListener("close", go); resolve(); }; channel.addEventListener("bufferedamountlow", go); // A channel that closes mid-file would otherwise leave this promise hanging, and the // queue behind it with nothing to resolve it. channel.addEventListener("close", go); }); } /* -- Receiving ------------------------------------------------------------- */ function onData(event) { if (typeof event.data === "string") { onControl(JSON.parse(event.data)); return; } if (!arriving) { writeError("A chunk arrived with no file to put it in."); return; } arriving.parts.push(event.data); arriving.received += event.data.byteLength; arriving.row.progress(arriving.received); } function onControl(message) { if (message.kind === "start") { nothingYet.hidden = true; arriving = { name: message.name, size: message.size, mime: message.mime, parts: [], received: 0, row: addRow(incoming, message.name, message.size), }; return; } if (message.kind === "end") { if (!arriving) return; if (arriving.received !== arriving.size) { writeError(`${arriving.name} came to ${arriving.received} bytes, not ${arriving.size}.`); } // Held in memory until it is saved: the parts become one Blob and the browser hands it // over as a download. A file much larger than the tab can hold will not survive this. const blob = new Blob(arriving.parts, {type: arriving.mime || "application/octet-stream"}); arriving.row.finish(blob, arriving.name); arriving = null; } } /* -- Rows ------------------------------------------------------------------ */ /** A name, a size, a bar and a word, and the handful of things that change them. */ function addRow(list, name, size) { const label = h("span", {class: "transfer-name"}, name); const bar = h("progress", {max: String(size || 1), value: "0"}); const status = h("span", {class: "transfer-status"}, "0%"); const row = h("li", {class: "transfer"}, [ label, h("span", {class: "transfer-size"}, formatSize(size)), bar, status, ]); list.appendChild(row); return { progress(done) { bar.value = done; status.textContent = size ? `${Math.floor(done / size * 100)}%` : "0%"; }, done(text) { bar.value = bar.max; status.textContent = text; }, finish(blob, filename) { bar.value = bar.max; status.textContent = "ready"; row.replaceChild( h("a", {class: "transfer-name", href: URL.createObjectURL(blob), download: filename}, name), label); }, fail(text) { status.textContent = text; row.classList.add("failed"); }, }; } function formatSize(bytes) { const units = ["B", "kB", "MB", "GB", "TB"]; let size = bytes; let unit = 0; while (size >= 1000 && unit < units.length - 1) { size /= 1000; unit++; } return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`; } function setState(text) { state.textContent = text; }