Blog/Components/Pages/Send.razor.js 17.7 K · 519 lines · raw · history

1 // /Send — a file from one browser to another, over a WebRTC data channel.
2 //
3 // The site's part is the introduction only: a WebSocket at /api/send/{code} carries the offer,
4 // the answer and the ICE candidates between the two browsers holding the same code. Once the data
5 // channel opens, the bytes go peer to peer and this page stops talking to the server. Closing the
6 // signalling socket after that would not interrupt a transfer.
7
8 import {getById, h, writeDebug, writeError, writeInfo} from "/common.module.js";
9 import {QRCode} from "/qrcode.js";
10
11 // Crockford base32: no I, L, O or U, so a code survives being read out loud or written down.
12 const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13 const CODE_LENGTH = 12;
14 const CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{12}$/;
15
16 // 64 KiB is inside every current browser's SCTP limit, and pc.sctp trims it further where a peer
17 // asks for less. Smaller chunks cost a round through the event loop each; larger ones buy little.
18 const CHUNK = 64 * 1024;
19
20 // Hand the channel a few megabytes of work at a time and then wait. Without this the whole file
21 // is queued in the first tick, which costs the tab the file's worth of memory and gives the
22 // progress bar nothing to say.
23 const HIGH_WATER = 8 * 1024 * 1024;
24 const LOW_WATER = 1 * 1024 * 1024;
25
26 const state = getById("state");
27 const pairing = getById("pairing");
28 const linkField = getById("link");
29 const codeText = getById("code");
30 const copyButton = getById("copy");
31 const joinForm = getById("joinForm");
32 const joinInput = getById("joinCode");
33 const fileInput = getById("files");
34 const dropZone = getById("drop");
35 const outgoing = getById("outgoing");
36 const incoming = getById("incoming");
37 const nothingYet = getById("nothingYet");
38
39 /** @type {RTCPeerConnection|null} */
40 let connection = null;
41 /** @type {RTCDataChannel|null} */
42 let channel = null;
43
44 /** Set once the server has said why this socket is over, so the close event stays quiet. */
45 let signallingEnded = false;
46
47 /** Whether this connection ever came up, which is the difference between "no route" and "lost". */
48 let everConnected = false;
49
50 /** Candidates that arrived before the description they belong to; added once it lands. */
51 let earlyCandidates = [];
52
53 /** The file currently arriving, if any. Only one is ever in flight in each direction. */
54 let arriving = null;
55
56 /** Outgoing files go one at a time, because the receiver's side of the protocol assumes it. */
57 let queue = Promise.resolve();
58
59
60 /* -- The room ------------------------------------------------------------- */
61
62 function newCode() {
63 const bytes = new Uint8Array(CODE_LENGTH);
64 crypto.getRandomValues(bytes);
65 // 5 bits per character out of 8: the other 3 are thrown away rather than folded in, which
66 // keeps every character uniform over the alphabet.
67 return Array.from(bytes, byte => ALPHABET[byte & 31]).join("");
68 }
69
70 /** Put a typed or pasted code back into the shape the alphabet uses. */
71 function normalise(text) {
72 return text.toUpperCase().replace(/[^0-9A-Z]/g, "")
73 .replace(/[IL]/g, "1")
74 .replace(/O/g, "0");
75 }
76
77 function group(code) {
78 return code.replace(/(.{4})(?=.)/g, "$1-");
79 }
80
81 const fromHash = normalise(location.hash.slice(1));
82 const room = CODE_PATTERN.test(fromHash) ? fromHash : newCode();
83
84 const shareUrl = new URL(location.href);
85 shareUrl.hash = room;
86
87 // replaceState rather than assigning to location.hash: the code belongs in the address bar so the
88 // link can be copied out of it, but arriving here should not leave a history entry behind. The
89 // whole URL, not a bare "#code": App.razor sets <base href="/">, and replaceState resolves a
90 // relative URL against that, which would quietly move this page to the site root.
91 if (room !== fromHash) history.replaceState(null, "", shareUrl.href);
92
93 linkField.value = shareUrl.href;
94 codeText.textContent = group(room);
95 new QRCode("qrcode", shareUrl.href);
96
97 copyButton.addEventListener("click", async () => {
98 try {
99 await navigator.clipboard.writeText(shareUrl.href);
100 writeInfo("Link copied.");
101 } catch {
102 // No clipboard permission, or an insecure origin. Select it so ctrl-C still works.
103 linkField.select();
104 writeError("Could not copy — the link is selected instead.");
105 }
106 });
107
108 joinForm.addEventListener("submit", event => {
109 event.preventDefault();
110 const code = normalise(joinInput.value);
111
112 if (!CODE_PATTERN.test(code)) {
113 writeError("That is not a code — twelve letters and digits, no I, L, O or U.");
114 return;
115 }
116 if (code === room) return;
117
118 location.hash = code;
119 });
120
121 // Someone already on this page who follows a link to another room, or types a code above, only
122 // changes the fragment — the browser does not reload for that, and this module body *is* the
123 // page's setup, so without this the page would sit in the room it opened with. A whole load
124 // rather than rewiring the connection in place, which is what every other page here relies on
125 // too. replaceState above does not fire this.
126 addEventListener("hashchange", () => location.reload());
127
128
129 /* -- Signalling ----------------------------------------------------------- */
130
131 if (!window.isSecureContext || !("RTCPeerConnection" in window)) {
132 setState("This browser will not do WebRTC here — it needs https (or localhost).");
133 throw new Error("WebRTC unavailable");
134 }
135
136 const iceUrls = JSON.parse(getById("ice").dataset.servers);
137 const rtcConfig = {iceServers: iceUrls.length > 0 ? [{urls: iceUrls}] : []};
138
139 const socket = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/send/${room}`);
140
141 socket.addEventListener("open", () => writeDebug("Signalling socket open."));
142 socket.addEventListener("error", () => writeError("The signalling socket failed."));
143 socket.addEventListener("close", () => {
144 writeDebug("Signalling socket closed.");
145 // Only worth reporting while it still had a job to do, and only when the server has not
146 // already said something better than "it closed" — the close follows that message.
147 if (!signallingEnded && channel?.readyState !== "open") {
148 setState("Lost the signalling socket. Reload to try again.");
149 }
150 });
151
152 // One at a time, in the order they arrived. Handling an offer is several awaits long, and a
153 // candidate that overtook it would be added against a connection that is still half-described.
154 let handling = Promise.resolve();
155
156 socket.addEventListener("message", event => {
157 let message;
158 try {
159 message = JSON.parse(event.data);
160 } catch {
161 writeError("Unreadable signalling message.");
162 return;
163 }
164
165 handling = handling.then(() => onSignal(message)).catch(error => writeError(error));
166 });
167
168 async function onSignal(message) {
169 switch (message.type) {
170 case "waiting":
171 setState("Waiting for the other side — send them the link.");
172 break;
173
174 case "ready":
175 setState("Other side found; connecting…");
176 await connect(message.initiator);
177 break;
178
179 case "peer-left":
180 if (channel?.readyState === "open") {
181 // The peer connection stands on its own once it is up; only the introduction ended.
182 writeInfo("The other side's signalling link dropped. The transfer link is still up.");
183 } else {
184 teardown();
185 setState("The other side left. Waiting for someone to join.");
186 }
187 break;
188
189 case "busy":
190 signallingEnded = true;
191 setState("That room already has two browsers in it. Start a fresh one from /Send.");
192 break;
193
194 case "offer":
195 await connect(false);
196 await connection.setRemoteDescription(message.sdp);
197 await connection.setLocalDescription(await connection.createAnswer());
198 signal({type: "answer", sdp: connection.localDescription});
199 await flushCandidates();
200 break;
201
202 case "answer":
203 await connection.setRemoteDescription(message.sdp);
204 await flushCandidates();
205 break;
206
207 case "ice":
208 // A candidate can outrun the description it belongs to; hold it until there is one.
209 if (connection?.remoteDescription) await connection.addIceCandidate(message.candidate);
210 else earlyCandidates.push(message.candidate);
211 break;
212
213 default:
214 writeDebug(`Ignoring signalling message: ${message.type}`);
215 }
216 }
217
218 function signal(message) {
219 if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
220 }
221
222 async function flushCandidates() {
223 const candidates = earlyCandidates;
224 earlyCandidates = [];
225 for (const candidate of candidates) await connection.addIceCandidate(candidate);
226 }
227
228
229 /* -- The peer connection --------------------------------------------------- */
230
231 /**
232 * Build the peer connection, once. The browser that arrives second makes the offer, because it is
233 * the only one that knows both ends are present.
234 */
235 async function connect(initiator) {
236 if (connection) return;
237
238 connection = new RTCPeerConnection(rtcConfig);
239
240 connection.addEventListener("icecandidate", event => {
241 if (event.candidate) signal({type: "ice", candidate: event.candidate});
242 });
243
244 connection.addEventListener("connectionstatechange", () => {
245 // Null once teardown has run: closing the connection queues one last event of its own.
246 const current = connection?.connectionState;
247 if (!current) return;
248
249 writeDebug(`Connection: ${current}`);
250
251 if (current === "connected") {
252 everConnected = true;
253 return;
254 }
255
256 if (current !== "failed" && current !== "closed") return;
257
258 // Take it all down rather than leaving a dead connection in place: the other side may
259 // still be holding the link, and a peer that comes back is a fresh negotiation that
260 // connect() would otherwise refuse to start.
261 const lost = everConnected;
262 teardown();
263 setState(lost
264 ? "The connection dropped. Waiting for the other side to come back."
265 : "Could not find a route between the two browsers.");
266 });
267
268 // The answering side is handed the channel the offering side made.
269 connection.addEventListener("datachannel", event => attach(event.channel));
270
271 if (initiator) {
272 attach(connection.createDataChannel("files", {ordered: true}));
273 await connection.setLocalDescription(await connection.createOffer());
274 signal({type: "offer", sdp: connection.localDescription});
275 }
276 }
277
278 function teardown() {
279 channel?.close();
280 connection?.close();
281 channel = null;
282 connection = null;
283 earlyCandidates = [];
284 arriving = null;
285 everConnected = false;
286 fileInput.disabled = true;
287 pairing.hidden = false;
288 }
289
290 function attach(dataChannel) {
291 channel = dataChannel;
292 channel.binaryType = "arraybuffer";
293 channel.bufferedAmountLowThreshold = LOW_WATER;
294
295 channel.addEventListener("open", () => {
296 fileInput.disabled = false;
297 pairing.hidden = true;
298 setState("Connected. Pick a file.");
299 describeRoute();
300 });
301
302 channel.addEventListener("close", () => {
303 fileInput.disabled = true;
304 pairing.hidden = false;
305 setState("The connection closed.");
306 });
307
308 channel.addEventListener("error", event => writeError(event.error ?? "Data channel error."));
309 channel.addEventListener("message", onData);
310 }
311
312 /**
313 * Say which route the two ends settled on, since "peer to peer" is the whole point of the page
314 * and a relayed connection would not be one.
315 */
316 async function describeRoute() {
317 try {
318 const stats = await connection.getStats();
319 let pair = null;
320 stats.forEach(report => {
321 if (report.type === "candidate-pair" && report.state === "succeeded") pair = report;
322 });
323 if (!pair) return;
324
325 const local = stats.get(pair.localCandidateId)?.candidateType;
326 const remote = stats.get(pair.remoteCandidateId)?.candidateType;
327 const relayed = local === "relay" || remote === "relay";
328 writeInfo(`Route: ${local} to ${remote}${relayed ? " (relayed)" : " (direct)"}.`);
329 } catch (error) {
330 writeDebug(`No route stats: ${error}`);
331 }
332 }
333
334
335 /* -- Sending --------------------------------------------------------------- */
336
337 fileInput.addEventListener("change", () => {
338 enqueue(fileInput.files);
339 // Clear it, so picking the same file again is still a change event.
340 fileInput.value = "";
341 });
342
343 for (const type of ["dragenter", "dragover"]) {
344 dropZone.addEventListener(type, event => {
345 event.preventDefault();
346 dropZone.classList.add("over");
347 });
348 }
349
350 for (const type of ["dragleave", "drop"]) {
351 dropZone.addEventListener(type, event => {
352 event.preventDefault();
353 dropZone.classList.remove("over");
354 });
355 }
356
357 dropZone.addEventListener("drop", event => {
358 if (channel?.readyState !== "open") {
359 writeError("Not connected yet.");
360 return;
361 }
362 if (event.dataTransfer?.files?.length) enqueue(event.dataTransfer.files);
363 });
364
365 function enqueue(files) {
366 for (const file of files) {
367 queue = queue.then(() => sendFile(file)).catch(error => writeError(error));
368 }
369 }
370
371 async function sendFile(file) {
372 if (channel?.readyState !== "open") {
373 writeError(`Not connected — ${file.name} was not sent.`);
374 return;
375 }
376
377 const row = addRow(outgoing, file.name, file.size);
378 channel.send(JSON.stringify({kind: "start", name: file.name, size: file.size, mime: file.type}));
379
380 // pc.sctp only exists once the transport is up, which it is by the time a channel is open.
381 const limit = Math.min(CHUNK, connection.sctp?.maxMessageSize || CHUNK);
382
383 for (let offset = 0; offset < file.size; offset += limit) {
384 const chunk = await file.slice(offset, offset + limit).arrayBuffer();
385 await drain();
386
387 if (channel.readyState !== "open") {
388 row.fail("interrupted");
389 return;
390 }
391
392 channel.send(chunk);
393 // What the channel still holds has not gone anywhere yet, so take it back off the total.
394 row.progress(Math.max(0, offset + chunk.byteLength - channel.bufferedAmount));
395 }
396
397 channel.send(JSON.stringify({kind: "end"}));
398 row.done("sent");
399 }
400
401 /** Resolve once the channel has worked off its backlog, or given up. */
402 function drain() {
403 if (channel.bufferedAmount < HIGH_WATER) return Promise.resolve();
404
405 return new Promise(resolve => {
406 const go = () => {
407 channel.removeEventListener("bufferedamountlow", go);
408 channel.removeEventListener("close", go);
409 resolve();
410 };
411 channel.addEventListener("bufferedamountlow", go);
412 // A channel that closes mid-file would otherwise leave this promise hanging, and the
413 // queue behind it with nothing to resolve it.
414 channel.addEventListener("close", go);
415 });
416 }
417
418
419 /* -- Receiving ------------------------------------------------------------- */
420
421 function onData(event) {
422 if (typeof event.data === "string") {
423 onControl(JSON.parse(event.data));
424 return;
425 }
426
427 if (!arriving) {
428 writeError("A chunk arrived with no file to put it in.");
429 return;
430 }
431
432 arriving.parts.push(event.data);
433 arriving.received += event.data.byteLength;
434 arriving.row.progress(arriving.received);
435 }
436
437 function onControl(message) {
438 if (message.kind === "start") {
439 nothingYet.hidden = true;
440 arriving = {
441 name: message.name,
442 size: message.size,
443 mime: message.mime,
444 parts: [],
445 received: 0,
446 row: addRow(incoming, message.name, message.size),
447 };
448 return;
449 }
450
451 if (message.kind === "end") {
452 if (!arriving) return;
453
454 if (arriving.received !== arriving.size) {
455 writeError(`${arriving.name} came to ${arriving.received} bytes, not ${arriving.size}.`);
456 }
457
458 // Held in memory until it is saved: the parts become one Blob and the browser hands it
459 // over as a download. A file much larger than the tab can hold will not survive this.
460 const blob = new Blob(arriving.parts, {type: arriving.mime || "application/octet-stream"});
461 arriving.row.finish(blob, arriving.name);
462 arriving = null;
463 }
464 }
465
466
467 /* -- Rows ------------------------------------------------------------------ */
468
469 /** A name, a size, a bar and a word, and the handful of things that change them. */
470 function addRow(list, name, size) {
471 const label = h("span", {class: "transfer-name"}, name);
472 const bar = h("progress", {max: String(size || 1), value: "0"});
473 const status = h("span", {class: "transfer-status"}, "0%");
474 const row = h("li", {class: "transfer"}, [
475 label,
476 h("span", {class: "transfer-size"}, formatSize(size)),
477 bar,
478 status,
479 ]);
480
481 list.appendChild(row);
482
483 return {
484 progress(done) {
485 bar.value = done;
486 status.textContent = size ? `${Math.floor(done / size * 100)}%` : "0%";
487 },
488 done(text) {
489 bar.value = bar.max;
490 status.textContent = text;
491 },
492 finish(blob, filename) {
493 bar.value = bar.max;
494 status.textContent = "ready";
495 row.replaceChild(
496 h("a", {class: "transfer-name", href: URL.createObjectURL(blob), download: filename}, name),
497 label);
498 },
499 fail(text) {
500 status.textContent = text;
501 row.classList.add("failed");
502 },
503 };
504 }
505
506 function formatSize(bytes) {
507 const units = ["B", "kB", "MB", "GB", "TB"];
508 let size = bytes;
509 let unit = 0;
510 while (size >= 1000 && unit < units.length - 1) {
511 size /= 1000;
512 unit++;
513 }
514 return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
515 }
516
517 function setState(text) {
518 state.textContent = text;
519 }