Файлы из браузера в браузер без сервера: что я узнал о WebRTC, пока делал tfile.io
- понедельник, 26 октября 2026 г. в 00:00:19
Задача звучит как упражнение на выходные: человек 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».
Сервер шлёт 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) — передачи сразу завершаются ошибкой.
Без 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.
Это, пожалуй, самая неочевидная находка. Два устройства в одной 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 секунд каждый раз.
Когда соединение всё-таки идёт через релей, полезно объяснить почему. Клиент парсит 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 бит делают такой перебор непрактичным.
Канал один, упорядоченный (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 мс — потому что если канал закрылся, событие может не прийти никогда, и отправитель повиснет.
Вот здесь было самое интересное. В 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 / 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% — файл идёт без сжатия; случайные и зашифрованные данные от сжатия даже немного растут.
Размер в заголовке остаётся оригинальным, прогресс считается в байтах исходного файла на обеих сторонах.
Держать принятый файл в памяти — нормально для мегабайт, но не для гигабайт. Начиная с 16 МБ файл пишется в Origin Private File System — приватную файловую систему сайта:
tfile/<id вкладки>/<id передачи>
Когда все байты записаны, из файла получается Blob, который ссылается на диск, а не на память, и из него — object URL для кнопки «Сохранить».
Проблема: как чистить мусор? Удалять на pagehide нельзя — пользователь мог нажать «Сохранить», и загрузка ещё идёт из этого файла. Решение через Web Locks: каждая вкладка держит блокировку на своей папке. При старте приложение проходит по папкам и удаляет те, чья блокировка свободна — то есть вкладка-владелец закрыта.
Fallback: нет createWritable или это приватное окно Firefox (там OPFS недоступна) — пишем в память.
Папку можно выбрать (webkitdirectory) или перетащить (webkitGetAsEntry, читать надо прямо в обработчике drop). Пути проходят через cleanPath, который выкидывает .., пустые и управляющие сегменты — на обеих сторонах, потому что путь пришёл от чужого браузера.
На приёме папка собирается в ZIP одной кнопкой. ZIP — stored, без сжатия, и собирается как Blob из маленьких заголовков вокруг уже принятых Blob’ов файлов. Ничего не копируется: файл на диске остаётся на диске. Единственный проход по данным — подсчёт CRC-32. Для файлов больше 4 ГБ добавляются поля ZIP64, имена в UTF-8.
Первая версия диспатчила 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 приостанавливает скрытую страницу за секунды: таймеры стоят, соединения тихо умирают. Отсюда целый набор мер:
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-js 2.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. Откройте на компьютере, отправьте ссылку себе на телефон — и сравните коды безопасности. Буду рад вопросам и багрепортам в комментариях, особенно про экзотические сети и браузеры.