Blog/Components/Pages/Send.razor.js 20.8 K · 601 lines · raw · history

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