javascript

Файлы из браузера в браузер без сервера: что я узнал о WebRTC, пока делал tfile.io

  • понедельник, 26 октября 2026 г. в 00:00:19
https://habr.com/ru/articles/1099032/

Файлы из браузера в браузер без сервера: что я узнал о 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.

Решение в несколько шагов:

  1. Сервер сообщает обеим сторонам флаг sameNetwork: одинаковый публичный IPv4 или одна IPv6-подсеть /64 (по последнему адресу в x-forwarded-for, который добавляет nginx). Сам адрес собеседнику не передаётся.

  2. Для пира в той же сети первая попытка идёт без TURN — только host и STUN-кандидаты.

  3. Если за 5 секунд соединение не установилось (или стало failed), инициатор повторяет попытку уже с TURN; вторая сторона, увидев второй offer, тоже добавляет TURN.

  4. Если прямая попытка провалилась, в 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.

Честное ограничение: кто первым получил ссылку, тот и зашёл в комнату. Отправляйте ссылку только тому, кому надо, и сверяйте код безопасности, если это важно.

Итоги

Что я вынес из этого проекта:

  1. WebRTC data channel — не TCP-сокет. У него свой SCTP со своими особенностями в каждом браузере, и для больших объёмов нужен собственный flow control.

  2. ICE не всегда выбирает лучший маршрут. Иногда TURN лучше не давать браузеру с первой попытки.

  3. Сигнальный сервер — точка доверия. Код безопасности из отпечатков DTLS стоит ~20 строк и закрывает MITM со стороны сервера.

  4. Мобильный браузер — враждебная среда. Проектируйте так, будто страницу могут заморозить в любую секунду, и проверяйте, не опоздал ли таймер.

  5. Современные Web API сильно выросли: OPFS, Web Locks, CompressionStream, Wake Lock — без них такой сервис в браузере пять лет назад был бы невозможен.

Попробовать: tfile.io. Откройте на компьютере, отправьте ссылку себе на телефон — и сравните коды безопасности. Буду рад вопросам и багрепортам в комментариях, особенно про экзотические сети и браузеры.