Send a file straight to another browser at /Send

The file goes over a WebRTC data channel and never touches the server. The site's part is the introduction only: a socket at /api/send/{code} relays the offer, the answer and the ICE candidates between the two browsers holding the same code, and carries nothing else. Once the channel is up that socket has no job left, and closing it does not interrupt a transfer. The server never mints or stores a code. The page takes sixty bits out of the browser's CSPRNG, writes them as twelve Crockford base32 characters into the fragment of the URL, and a room comes into existence when the first socket asks for it and disappears when the last one leaves. So there is no registry to expire and nothing to clean up on a restart beyond dropping the sockets. A room holds two peers and refuses a third, which is what keeps someone from slipping into a pair that has already formed. Messages are relayed verbatim - nothing here parses SDP, which is also why a browser can restart ICE without this code learning a new message type. STUN only, deliberately. A TURN server would relay the bytes, which is the one thing the page exists not to do, so a pair with no route between them fails rather than quietly going through a third party. The servers are configuration rather than a constant in the script, and reach it as a data attribute because Razor HTML-encodes element content. Three things in the client cost time and are commented where they are, because each of them breaks quietly. App.razor sets <base href="/">, and replaceState resolves a relative URL against the base rather than the current one, so a bare "#code" silently moved the page to the site root. Following a link to another room from an already-open page changes only the fragment, so the browser does not reload and the module body - which is this page's whole setup - never re-runs; hashchange reloads on purpose. And signalling messages are handled one at a time through a promise chain, because handling an offer is several awaits long and a candidate that overtook it would be added against a connection that is still half-described. Sending is chunked with a high-water mark and one file at a time, since the receiving side of the protocol assumes it, and both ends can send. Verified between two browsers: 3, 5 and 12 MB files arrive with matching SHA-256 digests over a route the page reports as direct, a third browser is turned away without disturbing the pair, and a peer that vanishes and comes back re-pairs without a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-20 14:18 UTC
commit
dbdcaffffe6433e1ef6da6b3075aa42554a98920
parent
0882c1477a
tree
browse at this commit

8 files changed +1032 -2

Blog/Components/Layout/SiteFooter.razor +1 -1

@@ -12,9 +12,9 @@
12 12 <li><NavLink href="/Query">Query</NavLink></li>
13 13 <li><NavLink href="/Storage">Storage</NavLink></li>
14 14 <li><NavLink href="/Warframe">Warframe drops</NavLink></li>
15 + <li><NavLink href="/Send">Send (peer-to-peer file transfer)</NavLink></li>
15 16 @* <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> *@
16 17 <li><NavLink href="/git">Git repositories</NavLink></li>
17 - <!-- <li><a href="/webrtc.html">WebRTC</a></li> -->
18 18 <!-- <li><a href="/spotify/index.html">Spotify</a></li> -->
19 19 </ul>
20 20 </footer>

Blog/Components/Pages/Send.razor +79 -0

@@ -0,0 +1,79 @@
1 +@page "/Send"
2 +@using System.Text.Json
3 +@using Blog.Services
4 +@using Microsoft.Extensions.Options
5 +@inject IOptions<SendOptions> Options
6 +
7 +<PageTitle>Send</PageTitle>
8 +<script type="module" src="@Assets["Components/Pages/Send.razor.js"]"></script>
9 +
10 +<main>
11 + <h1>Send</h1>
12 +
13 + <p>
14 + Send a file to another browser. Open this page in both, pair them with the link or the
15 + code, and the file goes straight from one to the other.
16 + </p>
17 +
18 + @* The ICE servers are configuration, so the page carries them rather than the script
19 + hard-coding them. A data attribute rather than a <script type="application/json"> block:
20 + Razor HTML-encodes element content, and attribute encoding round-trips through JSON.parse. *@
21 + <div id="ice" data-servers="@IceServers" hidden></div>
22 +
23 + <Panel Legend="Connection">
24 + <p id="state">Starting…</p>
25 +
26 + @* Put away once the room has its two browsers: the link is no use to a third. *@
27 + <div id="pairing">
28 + <div class="flex-row">
29 + <label for="link">Link</label>
30 + <input type="text" id="link" readonly/>
31 + <button type="button" id="copy">Copy</button>
32 + </div>
33 +
34 + <p>…or read out the code: <strong id="code"></strong></p>
35 +
36 + <div id="qrcode"></div>
37 + </div>
38 +
39 + <form id="joinForm" class="flex-row">
40 + <label for="joinCode">Been given a code?</label>
41 + <input type="text" id="joinCode" placeholder="ABCD-EFGH-JKMN" autocomplete="off"
42 + spellcheck="false"/>
43 + <button type="submit">Join</button>
44 + </form>
45 + </Panel>
46 +
47 + <Panel Legend="Sending">
48 + <div id="drop" class="dropzone">
49 + <input type="file" id="files" multiple disabled/>
50 + <p>…or drop files here.</p>
51 + </div>
52 + <ul id="outgoing" class="transfers"></ul>
53 + </Panel>
54 +
55 + <Panel Legend="Received">
56 + <ul id="incoming" class="transfers"></ul>
57 + <p id="nothingYet">Nothing yet.</p>
58 + </Panel>
59 +
60 + <Panel Legend="How this works">
61 + <p>
62 + <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API"
63 + rel="external noreferrer">WebRTC</a> is the browser's own way of opening a
64 + connection straight to another browser. The two ends swap what they know about how
65 + to reach each other, settle on a route, and from then on talk directly — on a shared
66 + network, over the local one. The file is read off disk in chunks and written down
67 + that connection.
68 + </p>
69 + </Panel>
70 +
71 + <Log/>
72 +</main>
73 +
74 +@code {
75 +
76 + /// <summary>The configured STUN/TURN URLs, as a JSON array for <c>RTCPeerConnection</c>.</summary>
77 + private string IceServers => JsonSerializer.Serialize(Options.Value.IceServers);
78 +
79 +}

Blog/Components/Pages/Send.razor.js +519 -0

@@ -0,0 +1,519 @@
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 +}

Blog/Program.cs +26 -0

@@ -28,6 +28,12 @@ builder.Services.AddSingleton<WarframeDropService>();
28 28 builder.Services.Configure<GitOptions>(builder.Configuration.GetSection(GitOptions.Section));
29 29 builder.Services.AddSingleton<GitService>();
30 30
31 +// /Send introduces two browsers to each other and then gets out of the way: the signalling
32 +// socket below carries their WebRTC handshake, and the file goes straight from one to the other.
33 +// Singleton because the rooms are the service - see SignalingService.
34 +builder.Services.Configure<SendOptions>(builder.Configuration.GetSection(SendOptions.Section));
35 +builder.Services.AddSingleton<SignalingService>();
36 +
31 37 // /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this
32 38 // site dials with is started on the first request, not here - see RvrbService.
33 39 builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section));
@@ -48,6 +54,9 @@ if (!app.Environment.IsDevelopment())
48 54
49 55 app.UseHttpsRedirection();
50 56
57 +// /Send's signalling socket. Nothing else on this site uses WebSockets.
58 +app.UseWebSockets();
59 +
51 60 app.UseAntiforgery();
52 61
53 62 app.MapStaticAssets();
@@ -82,4 +91,21 @@ app.MapGet("/api/warframe/names", async (string? q, WarframeDropService drops, C
82 91 return status.Tables?.Search(q ?? "", 10) ?? [];
83 92 });
84 93
94 +// The introduction for /Send: two browsers holding the same room code trade their WebRTC offer,
95 +// answer and ICE candidates through here, and once the peer connection is up this socket has
96 +// nothing left to carry. The file never passes through this process.
97 +app.MapGet("/api/send/{room}", async (
98 + string room,
99 + HttpContext context,
100 + SignalingService signaling) =>
101 +{
102 + if (!context.WebSockets.IsWebSocketRequest) return Results.BadRequest("Expected a WebSocket request.");
103 + if (!SignalingService.IsRoomCode(room)) return Results.BadRequest("Malformed room code.");
104 +
105 + using var socket = await context.WebSockets.AcceptWebSocketAsync();
106 + await signaling.RelayAsync(room, socket, context.RequestAborted);
107 +
108 + return Results.Empty;
109 +});
110 +
85 111 app.Run();
No newline at end of file

Blog/Services/SignalingService.cs +300 -0

@@ -0,0 +1,300 @@
1 +using System.Buffers;
2 +using System.Collections.Concurrent;
3 +using System.Net.WebSockets;
4 +using System.Text;
5 +using System.Text.RegularExpressions;
6 +using Microsoft.Extensions.Options;
7 +
8 +namespace Blog.Services;
9 +
10 +public sealed class SendOptions
11 +{
12 + public const string Section = "Send";
13 +
14 + /// <summary>
15 + /// The ICE servers the page hands to <c>RTCPeerConnection</c>, so two browsers behind NAT can
16 + /// find a route to each other. STUN only: it tells a peer what its public address looks like
17 + /// and then steps aside. A TURN server would <em>relay</em> the bytes, which is the one thing
18 + /// this page is for not doing, so there is deliberately none — a pair of symmetric NATs with
19 + /// no route between them fails instead of quietly going through a server.
20 + /// </summary>
21 + public string[] IceServers { get; set; } = ["stun:stun.l.google.com:19302"];
22 +
23 + /// <summary>How many rooms may exist at once, before a new code is turned away.</summary>
24 + public int MaxRooms { get; set; } = 500;
25 +
26 + /// <summary>
27 + /// The largest signalling message that will be relayed. An SDP offer carrying a handful of
28 + /// candidates is a few kilobytes; anything near this is not a handshake.
29 + /// </summary>
30 + public int MaxMessageBytes { get; set; } = 64 * 1024;
31 +
32 + /// <summary>
33 + /// How long a signalling socket is kept open. Closing it does not interrupt a transfer: once
34 + /// the peer connection is up the two browsers talk to each other directly, and this socket has
35 + /// nothing left to carry.
36 + /// </summary>
37 + public TimeSpan RoomLifetime { get; set; } = TimeSpan.FromMinutes(30);
38 +}
39 +
40 +/// <summary>
41 +/// The introduction service behind /Send: it carries the WebRTC handshake between the two browsers
42 +/// in a room, and carries nothing else. The file goes peer to peer over a data channel that this
43 +/// process is not part of and cannot see.
44 +/// </summary>
45 +/// <remarks>
46 +/// <para>
47 +/// The server never mints a room code and never stores one. The page generates twelve Crockford
48 +/// base32 characters — sixty bits out of the browser's CSPRNG — and a room springs into existence
49 +/// when the first socket asks for it and disappears when the last one leaves. So there is no
50 +/// registry to expire, no state to persist, and nothing to clean up on a restart beyond dropping
51 +/// the sockets.
52 +/// </para>
53 +/// <para>
54 +/// A room holds at most two peers and a third is refused, so nobody can slip into a pair that has
55 +/// already formed. What this does <em>not</em> give you is protection from the server itself: it
56 +/// relays the DTLS fingerprints the two peers authenticate each other with, so whoever runs this
57 +/// process could stand in the middle if they rewrote them. The page says so.
58 +/// </para>
59 +/// <para>
60 +/// Messages are relayed verbatim as text. Nothing here parses SDP — the server has no opinion
61 +/// about what the two ends agree on, which is also why a browser can change its mind about codecs
62 +/// or restart ICE without this code learning a new message type.
63 +/// </para>
64 +/// </remarks>
65 +public sealed partial class SignalingService(IOptions<SendOptions> options, ILogger<SignalingService> logger)
66 +{
67 + private readonly SendOptions _options = options.Value;
68 + private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.Ordinal);
69 +
70 + /// <summary>
71 + /// The shape the page generates: Crockford base32, which drops I, L, O and U so a code read
72 + /// out loud survives the trip. Codes are checked rather than parsed — the server only needs
73 + /// them to be a bounded, boring dictionary key.
74 + /// </summary>
75 + [GeneratedRegex("^[0-9A-HJKMNP-TV-Z]{8,32}$")]
76 + private static partial Regex RoomCode { get; }
77 +
78 + public static bool IsRoomCode(string code) => RoomCode.IsMatch(code);
79 +
80 + /// <summary>
81 + /// Runs one peer's signalling socket to completion: join the room, relay until it goes away.
82 + /// </summary>
83 + public async Task RelayAsync(string code, WebSocket socket, CancellationToken cancellationToken)
84 + {
85 + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
86 + lifetime.CancelAfter(_options.RoomLifetime);
87 + var token = lifetime.Token;
88 +
89 + var peer = new Peer(socket);
90 +
91 + if (Join(code, peer) is not { } room)
92 + {
93 + await peer.SendAsync("""{"type":"busy"}""", token).ConfigureAwait(false);
94 + await CloseAsync(socket, WebSocketCloseStatus.PolicyViolation, "Room full", token).ConfigureAwait(false);
95 + return;
96 + }
97 +
98 + try
99 + {
100 + await AnnounceAsync(room, token).ConfigureAwait(false);
101 + await PumpAsync(room, peer, token).ConfigureAwait(false);
102 + }
103 + catch (OperationCanceledException)
104 + {
105 + // Either the request was aborted or the room hit its lifetime. Both are ordinary ends.
106 + }
107 + catch (WebSocketException exception)
108 + {
109 + logger.LogDebug(exception, "Send socket ended abruptly");
110 + }
111 + finally
112 + {
113 + await LeaveAsync(code, room, peer).ConfigureAwait(false);
114 + }
115 + }
116 +
117 + /// <summary>
118 + /// Puts the peer in the room named by the code, creating the room if it is the first one there.
119 + /// Null when the room already has its two peers, or the server is holding as many rooms as it
120 + /// is willing to.
121 + /// </summary>
122 + private Room? Join(string code, Peer peer)
123 + {
124 + while (true)
125 + {
126 + if (!_rooms.TryGetValue(code, out var room))
127 + {
128 + if (_rooms.Count >= _options.MaxRooms) return null;
129 + room = _rooms.GetOrAdd(code, _ => new Room());
130 + }
131 +
132 + lock (room.Gate)
133 + {
134 + // The last peer left between the lookup and the lock, taking the room out of the
135 + // dictionary with it. Whatever is under that key now is what we want.
136 + if (room.Removed) continue;
137 + if (room.Peers.Count >= 2) return null;
138 +
139 + room.Peers.Add(peer);
140 + return room;
141 + }
142 + }
143 + }
144 +
145 + /// <summary>
146 + /// Tells everyone in the room where they stand. Alone, you wait; in a pair, the one who just
147 + /// arrived makes the offer, because it is the only one that knows both ends are present.
148 + /// </summary>
149 + private static async Task AnnounceAsync(Room room, CancellationToken cancellationToken)
150 + {
151 + Peer[] peers;
152 + lock (room.Gate) peers = [.. room.Peers];
153 +
154 + if (peers.Length == 1)
155 + {
156 + await peers[0].SendAsync("""{"type":"waiting"}""", cancellationToken).ConfigureAwait(false);
157 + return;
158 + }
159 +
160 + for (var i = 0; i < peers.Length; i++)
161 + {
162 + var initiator = i == peers.Length - 1 ? "true" : "false";
163 + await peers[i].SendAsync($$"""{"type":"ready","initiator":{{initiator}}}""", cancellationToken).ConfigureAwait(false);
164 + }
165 + }
166 +
167 + /// <summary>Reads this peer's messages and hands each one to the other peer, unread.</summary>
168 + private async Task PumpAsync(Room room, Peer peer, CancellationToken cancellationToken)
169 + {
170 + var buffer = ArrayPool<byte>.Shared.Rent(8 * 1024);
171 + var message = new ArrayBufferWriter<byte>(8 * 1024);
172 +
173 + try
174 + {
175 + while (peer.Socket.State == WebSocketState.Open)
176 + {
177 + var result = await peer.Socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
178 +
179 + if (result.MessageType == WebSocketMessageType.Close) return;
180 +
181 + if (result.MessageType != WebSocketMessageType.Text)
182 + {
183 + await CloseAsync(peer.Socket, WebSocketCloseStatus.InvalidMessageType, "Text only", cancellationToken).ConfigureAwait(false);
184 + return;
185 + }
186 +
187 + message.Write(buffer.AsSpan(0, result.Count));
188 +
189 + if (message.WrittenCount > _options.MaxMessageBytes)
190 + {
191 + await CloseAsync(peer.Socket, WebSocketCloseStatus.MessageTooBig, "Signalling message too large", cancellationToken).ConfigureAwait(false);
192 + return;
193 + }
194 +
195 + if (!result.EndOfMessage) continue;
196 +
197 + var text = Encoding.UTF8.GetString(message.WrittenSpan);
198 + message.ResetWrittenCount();
199 +
200 + Peer[] others;
201 + lock (room.Gate) others = [.. room.Peers.Where(other => other != peer)];
202 +
203 + foreach (var other in others)
204 + {
205 + await other.SendAsync(text, cancellationToken).ConfigureAwait(false);
206 + }
207 + }
208 + }
209 + finally
210 + {
211 + ArrayPool<byte>.Shared.Return(buffer);
212 + }
213 + }
214 +
215 + /// <summary>
216 + /// Takes the peer out of the room, tells whoever is left, and drops the room once it is empty.
217 + /// </summary>
218 + private async Task LeaveAsync(string code, Room room, Peer peer)
219 + {
220 + Peer[] remaining;
221 +
222 + lock (room.Gate)
223 + {
224 + room.Peers.Remove(peer);
225 + remaining = [.. room.Peers];
226 +
227 + // Marked before the removal, so a peer that is mid-Join on this room sees it and takes
228 + // the room that replaces it instead of joining one nobody can reach.
229 + if (remaining.Length == 0) room.Removed = true;
230 + }
231 +
232 + if (remaining.Length == 0)
233 + {
234 + _rooms.TryRemove(new KeyValuePair<string, Room>(code, room));
235 + return;
236 + }
237 +
238 + foreach (var other in remaining)
239 + {
240 + await other.SendAsync("""{"type":"peer-left"}""", CancellationToken.None).ConfigureAwait(false);
241 + }
242 + }
243 +
244 + private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, CancellationToken cancellationToken)
245 + {
246 + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) return;
247 +
248 + try
249 + {
250 + await socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false);
251 + }
252 + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException)
253 + {
254 + // The other end is already gone; there is nobody to say goodbye to.
255 + }
256 + }
257 +
258 + private sealed class Room
259 + {
260 + public readonly Lock Gate = new();
261 +
262 + /// <summary>Never more than two, so a list beats anything cleverer.</summary>
263 + public readonly List<Peer> Peers = [];
264 +
265 + /// <summary>Set under <see cref="Gate"/> once this room is on its way out of the dictionary.</summary>
266 + public bool Removed;
267 + }
268 +
269 + private sealed class Peer(WebSocket socket)
270 + {
271 + /// <summary>One write at a time: two interleaved sends would corrupt the frame.</summary>
272 + private readonly SemaphoreSlim _sending = new(1, 1);
273 +
274 + public WebSocket Socket { get; } = socket;
275 +
276 + public async Task SendAsync(string message, CancellationToken cancellationToken)
277 + {
278 + await _sending.WaitAsync(cancellationToken).ConfigureAwait(false);
279 +
280 + try
281 + {
282 + if (Socket.State != WebSocketState.Open) return;
283 +
284 + await Socket.SendAsync(
285 + Encoding.UTF8.GetBytes(message),
286 + WebSocketMessageType.Text,
287 + endOfMessage: true,
288 + cancellationToken).ConfigureAwait(false);
289 + }
290 + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException)
291 + {
292 + // A peer that has gone away is not this peer's problem: its own pump will notice.
293 + }
294 + finally
295 + {
296 + _sending.Release();
297 + }
298 + }
299 + }
300 +}

Blog/appsettings.json +3 -0

@@ -10,6 +10,9 @@
10 10 "Node": "rvrb@127.0.0.1",
11 11 "LocalNode": "blog@127.0.0.1"
12 12 },
13 + "Send": {
14 + "IceServers": [ "stun:stun.l.google.com:19302" ]
15 + },
13 16 "Git": {
14 17 "RepositoryRoot": "/home/git",
15 18 "CloneUrl": "ssh://git@git.bes.is/{repo}"

Blog/wwwroot/app.css +67 -0

@@ -802,6 +802,73 @@ progress {
802 802 }
803 803
804 804
805 +/* -- Send (peer-to-peer file transfer) ------------------------------------ */
806 +
807 +/* A target big enough to aim a dragged file at, dashed so it reads as a place to drop rather
808 + than a box with something in it. */
809 +.dropzone {
810 + border: var(--border-width) dashed var(--border);
811 + border-radius: var(--radius);
812 + padding: var(--s0);
813 + text-align: center;
814 +}
815 +
816 +.dropzone > p {
817 + margin-block-end: 0;
818 +}
819 +
820 +/* The only feedback that the drop will land here. Translucent so it works over either scheme
821 + without a token of its own. */
822 +.dropzone.over {
823 + background-color: color-mix(in srgb, var(--color) 12%, transparent);
824 +}
825 +
826 +.transfers {
827 + list-style: none;
828 + margin: 0;
829 + padding: 0;
830 +}
831 +
832 +.transfers:not(:empty) {
833 + margin-block-start: var(--s0);
834 +}
835 +
836 +/* Name and size on one line, bar and status under them: two rows of two, with the name free to
837 + wrap and the two right-hand cells sized to their text. */
838 +.transfer {
839 + display: grid;
840 + grid-template-columns: minmax(0, 1fr) auto;
841 + column-gap: var(--s0);
842 + align-items: baseline;
843 +}
844 +
845 +.transfer + .transfer {
846 + margin-block-start: var(--s-1);
847 +}
848 +
849 +/* A file name is not prose and has no spaces to break at. */
850 +.transfer-name {
851 + overflow-wrap: anywhere;
852 +}
853 +
854 +.transfer-size,
855 +.transfer-status {
856 + white-space: nowrap;
857 + font-variant-numeric: tabular-nums;
858 +}
859 +
860 +/* The one control that arrives with a colour of its own: the browser's accent is nowhere else
861 + on this site. */
862 +.transfer progress {
863 + inline-size: 100%;
864 + accent-color: var(--color);
865 +}
866 +
867 +.transfer.failed .transfer-status {
868 + color: var(--error);
869 +}
870 +
871 +
805 872 /* -- Scroll shortcuts ------------------------------------------------------ */
806 873
807 874 /* Docked to the viewport edge rather than the page flow, so it stays

CLAUDE.md +37 -1

@@ -84,7 +84,7 @@ BRP dataset in `wwwroot/brp.json`).
84 84
85 85 `Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the
86 86 rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs`
87 -gets them by joining the bot's Erlang cluster: [BeamSharp](https://github.com/Besselking/BeamSharp)
87 +gets them by joining the bot's Erlang cluster: [BeamSharp](https://github.com/Besselking/SendSharp)
88 88 (referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a
89 89 hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would —
90 90 no HTTP endpoint on the Elixir side.
@@ -159,6 +159,42 @@ results are linkable and work without JS. `Warframe.razor.js` only fills the sea
159 159 `<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import
160 160 `/common.module.js`: that module binds to a `#log` element this page doesn't have.
161 161
162 +### Send (`/Send`) — a file from one browser to another
163 +
164 +`Send.razor`/`.razor.js` sends a file over a **WebRTC data channel**. The site's only part is the
165 +introduction: `Services/SignalingService.cs` behind a WebSocket at `/api/send/{code}` (mapped in
166 +`Program.cs`, and the only reason `app.UseWebSockets()` is there) relays the offer, the answer and
167 +the ICE candidates between the two browsers holding the same code. The file never passes through
168 +this process, and closing the signalling socket does not interrupt a transfer.
169 +
170 +The server never mints or stores a code: the page generates twelve Crockford base32 characters
171 +from `crypto.getRandomValues`, a room exists while a socket is in it, and a third peer is refused.
172 +Messages are relayed verbatim — nothing parses SDP, which is why a browser can restart ICE or
173 +change codecs without this code learning a new message type. Rooms are a `ConcurrentDictionary`
174 +in a **singleton**, so the rooms *are* the service.
175 +
176 +Configuration lives under `Send` (`SendOptions`): `IceServers`, `MaxRooms`, `MaxMessageBytes`,
177 +`RoomLifetime`. STUN only, deliberately — a TURN server would relay the bytes, which is the one
178 +thing the page is for not doing, so a pair with no route between them fails rather than quietly
179 +going through a third party. The URLs reach the script as a `data-` attribute rather than a
180 +`<script type="application/json">` block, because Razor HTML-encodes element content.
181 +
182 +Three things about the client that are easy to undo by accident:
183 +
184 +- The code lives in the URL **fragment**, and `App.razor` sets `<base href="/">`. A bare
185 + `history.replaceState(null, "", "#code")` resolves against that base and silently moves the page
186 + to the site root — pass the whole URL.
187 +- Following a link to another room from an already-open page changes only the fragment, so the
188 + browser does not reload and the module body — which *is* this page's setup — never re-runs.
189 + `hashchange` reloads on purpose.
190 +- Signalling messages are handled one at a time through a promise chain. Handling an offer is
191 + several awaits long, and a candidate that overtook it would be added against a connection that
192 + is still half-described.
193 +
194 +Sending is chunked with backpressure (`bufferedAmountLowThreshold`, a high-water mark) and one
195 +file at a time, because the receiver's side of the protocol assumes it; a received file is held in
196 +memory as `Blob` parts until it is saved. Both ends can send — the page is symmetric.
197 +
162 198 ### Git browser (`/git`)
163 199
164 200 `Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a