Файлы из браузера в браузер без сервера: что я узнал о WebRTC, пока делал tfile.io
Задача звучит как упражнение на выходные: человек A открывает сайт, получает ссылку, отправляет её человеку B. B открывает ссылку — и они кидают друг другу файлы напрямую, браузер в браузер. Сервер нужен только чтобы «познакомить» их, сами байты через него не идут.
Демо на 200 строк с RTCPeerConnection и createDataChannel пишется за вечер. А потом начинается реальный мир: Safari на телефоне, который засыпает через три секунды после блокировки экрана; Chrome, у которого SCTP роняет скорость от собственной жадности; корпоративные NAT; папка на 5000 файлов, которая вешает вкладку; файл на 10 ГБ, который не влезает в память.
Ниже — что получилось в tfile.io и какие грабли были собраны по дороге. Будет много конкретики: константы, куски кода и причины, почему они такие.
Архитектура в одной картинке
Браузер A ──┐ ┌── Браузер B
│ WebSocket (сигналинг) │
└──────► api ◄─────────┘
Браузер A ◄═══════ WebRTC data channel (файлы) ═══════► Браузер B
Два независимых пакета:
api — сигнальный сервер на TypeScript и
ws. Комнаты — этоMap<roomId, Map<peerId, Peer>>, максимум два человека в комнате. Третий получаетroom-full.www — React 19 с SSR, Redux Toolkit, react-router 7.
Ссылка вида https://tfile.io/?r=<uuid> — это и есть комната. Тот, кто зашёл в комнату вторым, становится инициатором WebRTC-соединения (шлёт offer), первый создаёт своё соединение по входящему offer. Новый offer всегда заменяет старое соединение — это сильно упрощает логику переподключений: не нужно разбирать «glare», когда оба шлют offer одновременно.
Сервер пересылает только два типа сообщений — sdp и ice, только строкой и только участнику той же комнаты. Поле from сервер ставит сам по сокету, а не берёт из сообщения: иначе один участник мог бы прикинуться другим.
Сигналинг: скучная часть, в которой всё равно есть детали
Идентичность вкладки и токен переподключения
Каждая вкладка — отдельный пользователь. id и комната лежат в sessionStorage, а не в localStorage: две вкладки — два разных участника, а перезагрузка сохраняет тот же id.
Зачем сохранять id? Браузер умеет сохранить живое WebRTC-соединение при переподключении WebSocket — но только если собеседник видит, что вернулся тот же пир. А раз id сохраняется на клиенте, его надо защитить: второй участник комнаты знает твой id и мог бы занять его. Поэтому сервер при connect выдаёт владельцу токен — HMAC-SHA256 от id на серверном ключе — и принимает id от клиента только вместе с токеном. Без токена — новый id.
Забавный кейс: «Дублировать вкладку» в браузере копирует sessionStorage. Две вкладки с одним id и валидным токеном начинали бесконечно выбивать друг друга каждые 5 секунд (интервал автопереподключения). Решение: при переподключении с тем же id старый сокет сначала получает replaced, и клиент не переподключается после него, а показывает кнопку «Use here».
Keepalive и рестарты
Сервер шлёт
pingраз в 5 секунд, клиент отвечаетpong, через 10 секунд тишины сокет выкидывается.При
pm2 restartсервер закрывает всех с кодом 1012 (Service Restart). Клиент, увидев 1012, переподключается через 1–3 секунды со случайным разбросом и не считает это неудачей — чтобы рестарт деплоя не показывал пользователям «сервер недоступен» и чтобы все клиенты не ломились одновременно.Сообщения больше 64 КБ отбрасываются (
maxPayload, закрытие с 1009), невалидный JSON — закрытие с 1008.
Важный нюанс: уход с сигналинга ≠ уход из комнаты
Когда WebSocket собеседника отвалился (таймаут, смена сети), сервер шлёт peer-left. Но data channel между браузерами от сервера не зависит и может спокойно продолжать работать! Поэтому клиент различает два случая:
peer-leftбез причины (сокет закрылся) — открытый канал сохраняется, передачи ждут возвращения;peer-leftсreason: 'left'(явныйdisconnect) — передачи сразу завершаются ошибкой.
STUN, TURN и счёт от AWS
Без TURN-сервера часть пар просто не соединится: симметричный NAT, мобильные операторы, корпоративные файрволы. Я поднял eturnal — он слушает UDP/TCP на 3478 и TLS на 5349 (последний пролезает через сети, где разрешён только «HTTPS-подобный» трафик).
Учётные данные TURN одноразовые и короткоживущие, по схеме TURN REST API:
// username: "<expiry unix time>:<peer id>"
// password: base64(HMAC-SHA1(secret, username))
const expiry = Math.floor(Date.now() / 1000) + config.turn.ttl
const username = `${expiry}:${peerId}`
const credential = createHmac('sha1', secret).update(username).digest('base64')
iceServers.push({ urls: config.turn.urls, username, credential })
Сервер и eturnal делят один секрет, никакой базы пользователей не нужно. Список ICE-серверов клиент получает от api в сообщении connect, а не держит в бандле.
Ложка дёгтя: каждый посетитель получает TURN-креды, а значит, в теории может использовать твой TURN как бесплатный релей. Поэтому:
к WebSocket пускаются только страницы с нужным
Origin(иначе 401);nginx ограничивает
/wsпо IP;eturnal умеет ограничивать полосу (
max_bps) только для TCP/TLS-клиентов и не имеет квоты на аллокации.
Так что честно: реальный предохранитель — это billing alarm в AWS. Трафик через релей тарифицируется как исходящий трафик EC2.
Safari, LAN и «почему мы пошли через релей, если сидим в одной сети?»
Это, пожалуй, самая неочевидная находка. Два устройства в одной Wi-Fi-сети, TURN в списке ICE-серверов — и Safari в роли инициатора выбирает relay-кандидата вместо прямого маршрута по LAN. Файлы идут из соседней комнаты через AWS.
Решение в несколько шагов:
Сервер сообщает обеим сторонам флаг
sameNetwork: одинаковый публичный IPv4 или одна IPv6-подсеть /64 (по последнему адресу вx-forwarded-for, который добавляет nginx). Сам адрес собеседнику не передаётся.Для пира в той же сети первая попытка идёт без TURN — только host и STUN-кандидаты.
Если за 5 секунд соединение не установилось (или стало
failed), инициатор повторяет попытку уже с TURN; вторая сторона, увидев второй offer, тоже добавляет TURN.Если прямая попытка провалилась, в sessionStorage записывается метка
directFailed(живёт час): в этой вкладке следующие попытки сразу идут с TURN, чтобы не ждать 5 секунд каждый раз.
Подсказка для пользователя: iCloud Private Relay
Когда соединение всё-таки идёт через релей, полезно объяснить почему. Клиент парсит ICE-кандидаты и вытаскивает из них публичные адреса устройств:
// srflx/prflx: адрес — публичный адрес устройства;
// relay: адрес — наш TURN, а raddr — публичный адрес устройства.
// host-кандидаты локальные (часто скрыты за ".local") — пропускаем.
if (type === 'srflx' || type === 'prflx') address = tokens[4]
else if (type === 'relay') address = at('raddr')
Если по кандидатам публичные адреса устройств совпадают, а сервер видел разные — значит, между браузером и сервером стоит прокси. На практике это почти всегда iCloud Private Relay, и интерфейс так и пишет.
Отдельно про macOS: Chrome прячет локальные IP за .local-именами (mDNS), и они резолвятся только если браузеру разрешён доступ в Настройки → Конфиденциальность → Локальная сеть. Без этого два Mac’а на одном столе «не видят» друг друга.
Код безопасности: доверяй, но проверяй сигнальный сервер
WebRTC шифрует всё через DTLS, но тут есть тонкость. Отпечатки сертификатов DTLS передаются внутри SDP — а SDP идёт через мой сервер. Скомпрометированный сигнальный сервер мог бы подменить сертификаты и встать посередине.
Защита — как в мессенджерах: обе стороны хешируют пару отпечатков (свой и чужой, отсортированных, чтобы порядок не влиял) и показывают код. Совпадают коды на двух экранах — посредника нет.
const text = [local.join(','), remote.join(',')].sort().join('|')
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
// 40 бит → 12 цифр (точно: меньше 2^53)
let value = 0
for (const byte of hash.subarray(0, 5)) value = value * 256 + byte
const digits = (value % 1e12).toString().padStart(12, '0')
return digits.match(/.{3}/g)!.join(' ') // "123 456 789 012"
Почему 12 цифр, а не 6? Шесть цифр — это миллион вариантов. Посредник, генерируя сертификаты прямо во время попытки соединения, мог бы подобрать такой, чтобы коды на обоих концах совпали. 40 бит делают такой перебор непрактичным.
Передача файла: протокол поверх data channel
Канал один, упорядоченный (ordered). Сначала JSON-заголовок, потом бинарные чанки:
{ "type": "header", "id": "…", "name": "report.csv", "size": 18234112,
"mime": "text/csv", "compression": "gzip", "offset": 0 }
Детали, которые оказались важны:
Размер чанка — до 64 КБ, но не больше
pc.sctp.maxMessageSize, который объявил собеседник.Чтение на шаг вперёд. Файл читается через
file.stream()с опережением на один чанк, чтобы канал никогда не ждал диска или компрессора.Backpressure. Пауза, когда в
bufferedAmountбольше 4 МБ, продолжение на 1 МБ по событиюbufferedamountlow. Плюс опрос раз в 100 мс — потому что если канал закрылся, событие может не прийти никогда, и отправитель повиснет.
Почему одного backpressure мало: Chrome и его SCTP
Вот здесь было самое интересное. В Chrome SCTP-стек забирает всё, что лежит в очереди, и сразу отправляет в сеть. Если у тебя «в полёте» несколько мегабайт и случилась потеря пакета — восстановление занимает секунды. Я измерил на loopback: всплеск примерно в 2 МБ в начале передачи — и 2–3 секунды ничего не двигается.
То есть bufferedAmount в 4 МБ — это нормальный буфер для JS, но слишком много для того, что SCTP выплюнет в сеть разом. Пришлось писать свой flow control поверх data channel — по сути, мини-TCP на уровне приложения:
получатель шлёт
ackкаждые 128 КБ с количеством полученных байт;отправитель держит «в полёте» не больше окна: 2.5 × лучшая недавняя скорость × минимальный RTT;
окно не меньше 512 КБ и не больше 16 МБ.
// получатель подтверждает каждые столько байт
export const ACK_EVERY = 128 * 1024
// должно быть больше ACK_EVERY, иначе отправитель может ждать ack вечно
export const MIN_WINDOW = 4 * ACK_EVERY
export const MAX_WINDOW = 16 * 1024 * 1024
// больше 1, чтобы скорость могла расти
const WINDOW_GAIN = 2.5
// скорость — лучшая из стольких последних выборок (путь может меняться)
const RATE_HISTORY = 10
В локальной сети окно сидит на минимуме, на медленном далёком пути (TURN, RTT 100+ мс) растёт вместе с измеренной скоростью. Похоже на идею BBR: оцениваем пропускную способность и RTT, а не реагируем на потери.
Бонус: прогресс-бар у отправителя теперь честный — это то, что получатель подтвердил, а не то, что ушло в локальный буфер. Для старых клиентов без ack прогресс оценивается как sent − bufferedAmount.
Сжатие на лету через CompressionStream
Браузеры умеют CompressionStream / DecompressionStream, и это бесплатный выигрыш для текстов, логов, CSV, дампов. При открытии канала обе стороны шлют hello со списком форматов, которые браузер умеет и сжимать, и распаковывать:
const PREFERRED_FORMATS = ['zstd', 'gzip', 'deflate-raw', 'deflate']
gzip стоит раньше deflate-raw: тот же алгоритм, но с CRC32, который поймает битые данные.
brotli исключён намеренно: у
CompressionStreamнет настройки качества, а на дефолтном качестве brotli медленнее сети.
Сжимать всё подряд — плохая идея: JPEG, MP4, ZIP, PDF, DOCX уже сжаты, их пересжатие — только трата CPU. Поэтому файлы меньше 1 КБ и «уже сжатые» типы (по MIME и расширению) идут как есть. Для остальных — пробный прогон: 64 КБ из начала и 64 КБ из середины файла. Середина нужна, потому что текстовый заголовок может стоять перед упакованными данными (например, tar с фотографиями). Если сэмпл сжался хуже чем до 90% — файл идёт без сжатия; случайные и зашифрованные данные от сжатия даже немного растут.
Размер в заголовке остаётся оригинальным, прогресс считается в байтах исходного файла на обеих сторонах.
Куда класть 10 ГБ на стороне получателя: OPFS
Держать принятый файл в памяти — нормально для мегабайт, но не для гигабайт. Начиная с 16 МБ файл пишется в Origin Private File System — приватную файловую систему сайта:
tfile/<id вкладки>/<id передачи>
Когда все байты записаны, из файла получается Blob, который ссылается на диск, а не на память, и из него — object URL для кнопки «Сохранить».
Проблема: как чистить мусор? Удалять на pagehide нельзя — пользователь мог нажать «Сохранить», и загрузка ещё идёт из этого файла. Решение через Web Locks: каждая вкладка держит блокировку на своей папке. При старте приложение проходит по папкам и удаляет те, чья блокировка свободна — то есть вкладка-владелец закрыта.
Fallback: нет createWritable или это приватное окно Firefox (там OPFS недоступна) — пишем в память.
Папки и ZIP без копирования
Папку можно выбрать (webkitdirectory) или перетащить (webkitGetAsEntry, читать надо прямо в обработчике drop). Пути проходят через cleanPath, который выкидывает .., пустые и управляющие сегменты — на обеих сторонах, потому что путь пришёл от чужого браузера.
На приёме папка собирается в ZIP одной кнопкой. ZIP — stored, без сжатия, и собирается как Blob из маленьких заголовков вокруг уже принятых Blob’ов файлов. Ничего не копируется: файл на диске остаётся на диске. Единственный проход по данным — подсчёт CRC-32. Для файлов больше 4 ГБ добавляются поля ZIP64, имена в UTF-8.
Как 5000 файлов повесили вкладку
Первая версия диспатчила Redux-экшен на каждый файл: «начат», «прогресс», «готов». Папка на 5000 файлов — и страница замерзала. Решение банальное, но обязательное: экшены статуса принимают {ids}, один экшен добавляет сразу много передач, редьюсер копирует стейт один раз и сохраняет нетронутые объекты передач, а строка списка мемоизирована.
Телефоны: самая сложная платформа
Возобновление передачи
Телефон сменил Wi-Fi на LTE, ноутбук проснулся — соединение оборвалось. Передачи не падают, а переходят в статус interrupted. Инициатор открывает новый RTCPeerConnection с задержками 1/2/4/8/15/30 секунд.
В новом hello обе стороны рассказывают, что у них есть:
{ "resume": { "partial": { "<id>": 52428800 }, "done": ["…"], "cancelled": ["…"] } }
Отправитель заново ставит в очередь неподтверждённые файлы с offset в заголовке (сжатый файл пересжимается с этого места), получатель продолжает писать в тот же sink. Не вернулись за 60 секунд — передачи завершаются ошибкой.
iOS усыпляет страницу
iOS приостанавливает скрытую страницу за секунды: таймеры стоят, соединения тихо умирают. Отсюда целый набор мер:
Away-сигнал. При
visibilitychange → hiddenстраница шлёт собеседникуaway: true. Тот, получив его, ждёт не 60 секунд, а 10 минут — человек просто переключился в мессенджер.Оживление. При возврате (
visibilitychange → visibleилиpageshowиз bfcache) клиент сразу переподключает WebSocket, если тот молчал больше 10 секунд, инициатор открывает новый Peer немедленно, а не ждёт следующей задержки, и все таймеры «сдаться» перезапускаются.Опоздавшие таймеры. Если таймер «сдаться» сработал больше чем на 5 секунд позже, чем должен был, — значит, страница спала. Передачи в этом случае не валим.
Screen Wake Lock (iOS 16.4+), пока идёт передача, чтобы телефон не заблокировался сам. Браузер снимает блокировку при скрытии страницы, поэтому она берётся заново на
visibilitychange.Прогресс в заголовке вкладки:
42% · TFileвиден в переключателе приложений.
Буфер обмена: чего браузер не позволит
Хотелось сделать «общий буфер обмена между устройствами». Не получится, и это правильно: страница может прочитать буфер только при вставке или по тапу. Поэтому это «отправить текст или буфер»: Ctrl/Cmd+V на странице заполняет поле, отправка — только по кнопке. Никогда ничего не уходит по одной вставке — а вдруг человек скопировал пароль.
С записью у получателя всё ещё интереснее:
Chrome/Edge разрешают
clipboard.writeTextбез жеста, но только пока вкладка в фокусе. Текст, пришедший в фоне, ждёт событияfocus.Safari/Firefox пишут в буфер только внутри пользовательского действия. Поэтому — capture-слушатели на
document, иwriteTextвызывается синхронно в обработчике следующего клика или клавиши. Исключения — клавиши с Ctrl/Cmd/Alt (чтобы Ctrl+V не вставил только что пришедший текст) и набор в полях ввода.
Мелочи, которые съели больше времени, чем ожидалось
SSR и
<head>. React 19 рендерит<title>и<meta>на месте, то есть в<body>, если сам не рендерит<head>(а у меня рендерится только#app). Краулеры и Lighthouse смотрят только в<head>. Пришлось собирать теги в контекст на сервере и вписывать в<head>руками; на клиенте React переиспользует их при гидрации.CLS 0.34 → 0 на мобильных. Карточка соединения рендерится только после маунта (состояние приходит из sessionStorage и WebSocket, на сервере его нет). Пока её нет — слот фиксированной высоты, измеренной для самого длинного имени («Aquamarine Tyrannosaurus»), на ширинах меньше 375 px — своя высота.
Лицензия парсера User-Agent.
ua-parser-js2.x — под AGPL. Взялbowser(MIT).Логи без следа в проде. Отладочный
log()закрыт build-time константой через DefinePlugin, Terser вырезает вызовы целиком. В dev-сборке консоль показывает каждый шаг соединения с префиксамиWS:/RTC:, включая выбранную пару кандидатов.
Приватность
Это не только маркетинговый пункт, а следствие архитектуры: сервер видит IP, user agent и то, что два пира в одной комнате. Содержимое файлов и текстов он не видит физически — они идут по DTLS напрямую или через TURN, который тоже видит только зашифрованные пакеты. Нет cookies, в sessionStorage только id/комната и метка directFailed.
Честное ограничение: кто первым получил ссылку, тот и зашёл в комнату. Отправляйте ссылку только тому, кому надо, и сверяйте код безопасности, если это важно.
Итоги
Что я вынес из этого проекта:
WebRTC data channel — не TCP-сокет. У него свой SCTP со своими особенностями в каждом браузере, и для больших объёмов нужен собственный flow control.
ICE не всегда выбирает лучший маршрут. Иногда TURN лучше не давать браузеру с первой попытки.
Сигнальный сервер — точка доверия. Код безопасности из отпечатков DTLS стоит ~20 строк и закрывает MITM со стороны сервера.
Мобильный браузер — враждебная среда. Проектируйте так, будто страницу могут заморозить в любую секунду, и проверяйте, не опоздал ли таймер.
Современные Web API сильно выросли: OPFS, Web Locks, CompressionStream, Wake Lock — без них такой сервис в браузере пять лет назад был бы невозможен.
Попробовать: tfile.io. Откройте на компьютере, отправьте ссылку себе на телефон — и сравните коды безопасности. Буду рад вопросам и багрепортам в комментариях, особенно про экзотические сети и браузеры.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.