Содержание статьи
Файлы из браузера в браузер без сервера: что я узнал о 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-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. Откройте на компьютере, отправьте ссылку себе на телефон — и сравните коды безопасности. Буду рад вопросам и багрепортам в комментариях, особенно про экзотические сети и браузеры.
