javascript

dc.send(file) не существует: что на самом деле нужно для передачи файла в браузере

  • вторник, 14 апреля 2026 г. в 00:01:18
https://habr.com/ru/articles/1022522/
Рис. 1. dc.send(file) не означает, что файл уже доставлен.
Рис. 1. dc.send(file) не означает, что файл уже доставлен.

Самая опасная иллюзия в WebRTC-файлообмене выглядит примерно так:

const dc = pc.createDataChannel('file');
for (let offset = 0; offset < file.size; offset += CHUNK) {
  dc.send(file.slice(offset, offset + CHUNK));
}
dc.send(JSON.stringify({ type: 'transfer_done' }));
// Готово! ...или нет?

Выглядит правдоподобно. DataChannel открыт, чанки летят, transfer_done отправлен. В туториале этого достаточно. В продакшене – нет. На медленном relay прогресс покажет 100%, а файл на диске получателя будет обрезан – потому что send() кладёт данные в SCTP-буфер, а не «отправляет в сеть». Получатель может быть не готов к записи. Объект File не переживёт refresh. А signaling-соединение может умереть раньше DataChannel – и peer_left окажется не ошибкой, а штатным завершением.

Эта статья – разбор шести проблем (и одного Safari-сюрприза), которые возникают при передаче файлов через WebRTC в продакшене. Ни одна из них не описана в туториалах.

Статья родилась по итогам работы над сервисом P2P-передачи файлов. Чтобы дальше термины были понятны – вот архитектура в двух словах.

Файлы передаются между браузерами через WebRTC DataChannel. В обычном случае – напрямую (P2P), при невозможности прямого соединения – через TURN relay. Сервер не хранит содержимое файла ни в одном из режимов – он координирует: REST API для создания передачи, WebSocket для signaling, машина состояний для отслеживания статусов. Даже через relay DTLS-шифрование сохраняется end-to-end – relay проксирует зашифрованный поток, не имея доступа к содержимому.

Relay стоит денег (трафик, VPS), поэтому на встроенный coturn установлены жёсткие лимиты: небольшой максимальный размер файла и несколько передач в день. Это аварийный запасной путь, а не основной режим. Пользователь может подключить свой TURN-сервер (Metered, Twilio, Xirsys, корпоративный – неважно; у Metered, например, бесплатный тариф даёт 500 GB). Учётные данные custom TURN шифруются неэкстрагируемым ключом в IndexedDB и никогда не покидают браузер. На custom TURN квот со стороны сервиса нет.

Архитектура: P2P передача с signaling и relay fallback

В вашем проекте может не быть custom TURN или relay-квот. Но базовые проблемы – управление буфером, готовность получателя, переподключение, потерянный File – возникнут в любом WebRTC-файлообменнике. Дальше разбираем все шесть, с конкретикой из нашего решения.

Рис. 2. Архитектура передачи. Прямое P2P-соединение – основной путь, TURN relay (встроенный coturn или пользовательский) – fallback при NAT, firewall или мобильной сети. Содержимое файла идёт мимо сервера даже при relay: DTLS-шифрование сохраняется end-to-end.
Рис. 2. Архитектура передачи. Прямое P2P-соединение – основной путь, TURN relay (встроенный coturn или пользовательский) – fallback при NAT, firewall или мобильной сети. Содержимое файла идёт мимо сервера даже при relay: DTLS-шифрование сохраняется end-to-end.

Сервер ничего не хранит, но без него ничего не работает

Архитектура кажется простой: содержимое файла идёт мимо сервера через WebRTC DataChannel, сервер его не видит и не хранит. Но «сервер не хранит файлы» не означает «сервер почти не нужен».

Вокруг каждой передачи – REST API для создания и резолва «шары» (share), WebSocket для signaling, серверная машина состояний и очередь получателей.

У шары есть жизненный цикл. Ключевое: после успешной передачи шара не умирает, а возвращается в active – отправитель остаётся онлайн и ждёт следующего получателя. Если отправитель занят передачей, новый получатель попадает в очередь и автоматически занимает место после завершения текущей.

Рис. 3. Жизненный цикл шары. Главный цикл active → matched → transferring → active обеспечивает переиспользование – после успешной передачи отправитель остаётся онлайн для следующего получателя. Терминальные состояния – только expired и cancelled; failed восстанавливается через reactivate.
Рис. 3. Жизненный цикл шары. Главный цикл active → matched → transferring → active обеспечивает переиспользование – после успешной передачи отправитель остаётся онлайн для следующего получателя. Терминальные состояния – только expired и cancelled; failed восстанавливается через reactivate.

Один WebSocket на шару. Подключение подписано ECDSA. Сервер координирует: кто отправитель, кто получатель, compare-and-swap на привязку получателя, TTL, очистка зависших шар. Содержимое файла идёт мимо сервера, но сервер – дирижёр всего процесса.

Проблема №1: P2P может не случиться

В туториалах WebRTC – это магия прямых соединений. В реальности значительная часть подключений идёт через TURN relay. Symmetric NAT, корпоративный firewall, мобильная сеть – relay нужен не как «редкий костыль», а как штатный запасной путь.

И relay меняет не только сетевой маршрут, но и всю логику ниже по стеку.

Тип соединения определяется на клиенте по ICE-кандидатам – причём проверяются оба: local и remote. Если хотя бы один relay – соединение классифицируется как TURN. Дальше TURN делится на server_relay (серверный coturn, с квотами) и custom_relay (пользовательский TURN, без ограничений).

От типа соединения зависит размер чанка:

function getOptimalChunkSize(classification: string): number {
  switch (classification) {
    case 'p2p':          return 1024 * 1024;  // 1 MB – меньше async ops
    case 'custom_relay': return 512 * 1024;   // 512 KB
    case 'server_relay': return 64 * 1024;    // 64 KB – быстрые retransmit
    default:             return 512 * 1024;
  }
}

Почему 64 KB на server relay? SCTP retransmit работает на уровне чанков. На медленном соединении большой чанк при потере ретранслируется целиком – задержка растёт экспоненциально. Маленькие чанки дают быстрый recovery.

Для server relay действуют квоты: максимальный размер файла, дневной лимит на аккаунт, глобальный дневной лимит. Лимиты проверяются на обеих сторонах – потому что sender может использовать custom TURN (без лимитов), а receiver – серверный coturn (с лимитами).

Перед основной передачей мы запускаем быструю проверку доступности TURN – отдельный RTCPeerConnection с iceTransportPolicy: 'relay'. За 5 секунд знаем, жив ли coturn, не блокируя основной путь. Это экономит пользователю минуту ожидания с последующим таймаутом, если relay мёртв.

Проблема №2: dc.send() не означает «файл доставлен»

Это центральная проблема статьи. Мы поймали её на тестировании через relay: прогресс-бар показал 100%, отправитель послал transfer_done – а на диске получателя не хватало последних двух мегабайт. Файл выглядел полным, но был обрезан. Причина оказалась простой и неприятной: dc.send() – это write() в SCTP-буфер браузера, а не отправка в сеть. Между «положил в буфер» и «receiver получил все байты» – пропасть.

На быстром P2P-соединении буфер пуст практически всегда. На медленном relay (≈100 KB/s) буфер может содержать секунды непереданных данных. Если отправить transfer_done сразу после последнего чанка – сообщение придёт receiver'у раньше, чем данные.

Вот как выглядит реальный send loop (упрощённо):

const BUFFER_THRESHOLD = 8 * 1024 * 1024; // 8 MB
const BUFFER_LOW       = 6 * 1024 * 1024; // 6 MB

dc.bufferedAmountLowThreshold = BUFFER_LOW;
let nextBuffer = await file.slice(0, chunkSize).arrayBuffer();

while (offset < file.size) {
  const buffer = nextBuffer;
  // Overlapped I/O: читаем следующий чанк, пока текущий уходит в сеть
  const readPromise = offset + buffer.byteLength < file.size
    ? file.slice(offset + buffer.byteLength, nextEnd).arrayBuffer()
    : null;

  // Backpressure: ждём, пока буфер подопустеет
  if (dc.bufferedAmount >= BUFFER_THRESHOLD) {
    await waitForBufferDrain(dc, computeDrainTimeout(dc.bufferedAmount, speed));
  }

  dc.send(buffer);
  offset += buffer.byteLength;

  // Реальный прогресс – не наивный offset
  const effectiveSent = Math.max(0, offset - dc.bufferedAmount);
  const pct = Math.min((effectiveSent / file.size) * 100, isLast ? 100 : 99);

  nextBuffer = readPromise ? await readPromise : null;
}

// Ждём drain → ACK от receiver → только потом transfer_done
if (dc.bufferedAmount > 0) await waitForBufferDrain(dc, ...);
await waitForAck(dc, 10_000).catch(() => { /* старый receiver */ });
dc.send(JSON.stringify({ type: 'transfer_done' }));

Этот код выглядит сложнее, чем «нарезать файл и отправить», и каждое усложнение – след конкретного бага.

Начнём с двойного порога: BUFFER_THRESHOLD (8 MB) – верхняя отметка, при которой отправка приостанавливается, и BUFFER_LOW (6 MB) – нижняя, при которой возобновляется. Зазор в 2 MB держит канал загруженным, но не даёт буферу переполниться. Это стандартный паттерн high/low water mark.

Дальше – overlapped I/O. Наивный send loop последовательный: прочитать чанк с диска, отправить, прочитать следующий. Пока идёт file.slice().arrayBuffer(), DataChannel простаивает. Overlapped версия читает следующий чанк параллельно с отправкой текущего – это убирает простой и заметно ускоряет передачу больших файлов.

Следующая проблема – таймаут на drain. Фиксированный таймаут не работает: 5 секунд на быстром P2P – нормально (мёртвое соединение обнаруживается быстро), но 5 секунд на медленном relay – ложная тревога (буфер просто медленно дренируется). Поэтому таймаут вычисляется из измеренной скорости:

function computeDrainTimeout(bufferedAmount, measuredSpeed) {
  const estimatedDrainMs = (bufferedAmount / measuredSpeed) * 1000;
  return Math.max(5_000, Math.min(60_000, estimatedDrainMs * 3));
}

Быстрый P2P (50 MB/s): таймаут ≈5 сек. Медленный relay (100 KB/s): до 60 сек. Одна формула, два экстремума.

Отдельная история – честный прогресс. Наивный offset показывает 100%, когда мегабайты данных ещё сидят в SCTP-буфере. Реальный прогресс – это offset − dc.bufferedAmount, с ограничением на 99% до полного drain. Иначе UI врёт пользователю, и именно это мы увидели в том первом баге.

И наконец – порядок завершения. После отправки всех чанков отправитель ждёт полного опустошения буфера, затем transfer_ack от receiver – подтверждение, что все байты получены и записаны. И только после этого – transfer_done. Это как fwrite() + fsync(): без ACK вы надеетесь, но не знаете.

На стороне получателя – своя механика. dc.onmessage получает ArrayBuffer, но DataChannel может переиспользовать внутренний буфер между вызовами. Без явного копирования – повреждение данных:

// Receiver: streaming запись на диск
const chunk = event.data.slice(0);  // копия – DC может переиспользовать buffer
writeChain = writeChain.then(() => writer.write(chunk));  // disk backpressure

if (bytesReceived === fileInfo.size) {
  dc.send(JSON.stringify({ type: 'transfer_ack' }));  // sender ждёт этого
}

writeChain – цепочка последовательных промисов для записи на диск. Если диск медленный (USB), чанки ставятся в очередь, но не накапливаются в RAM бесконтрольно. Давление распространяется по всей цепочке: диск не успевает → получатель медленнее забирает данные из канала → SCTP flow control тормозит отправителя → bufferedAmount растёт → отправитель ждёт drain.

Рис. 4. Наивная модель против реального пути. Данные текут слева направо, а backpressure и transfer_ack – справа налево. Передача – это система с обратной связью, а не односторонняя труба.*
Рис. 4. Наивная модель против реального пути. Данные текут слева направо, а backpressure и transfer_ack – справа налево. Передача – это система с обратной связью, а не односторонняя труба.*

Проблема №3: receiver должен быть готов ДО того, как sender начнёт слать

С маленьким файлом всё просто: принять чанки в память, собрать Blob, предложить скачать. Файл в 5 MB спокойно помещается в RAM, и задержка на save-диалог после приёма никого не волнует.

С большим файлом так нельзя. Файл в 2 GB не поместится в ArrayBuffer[] – браузер упадёт с OOM раньше, чем закончится передача. Единственный вариант – streaming: писать чанки на диск по мере получения, через File System Access API (showSaveFilePicker() → FileSystemWritableFileStream). Этот API доступен только в Chrome и Edge – в Firefox и Safari приходится использовать in-memory fallback, а значит, файлы больше гигабайта там рискуют уронить браузер. Но даже в Chrome для streaming нужно знать, куда писать, до первого чанка. А showSaveFilePicker() – это блокирующий диалог, который занимает секунды.

Вот проблема: если подтвердить передачу и потом показать save-диалог, sender уже начнёт слать. Чанки придут до того, как receiver установит обработчик – данные потеряны навсегда.

Поэтому transfer_confirm – отдельный протокольный шаг. Отправитель передаёт metadata (имя файла, размер, тип) через DataChannel (который создан с ordered: true – порядок чанков гарантирован) и ждёт. Получатель смотрит на размер: если больше 100 MB и браузер поддерживает FSAA – показывает save-диалог. Важный нюанс: showSaveFilePicker() требует user gesture – поэтому диалог вызывается по клику на кнопке подтверждения в UI, а не программно. И только после того, как destination готов, получатель подтверждает:

// Receiver: подготовить destination ПЕРЕД подтверждением.
// Sender must not start sending chunks while user picks save location
// (dialog takes seconds, chunks arriving without handler are lost).
const fileHandle = await prepareSaveFile(metadata.file, callbacks);

// ТЕПЕРЬ подтвердить – sender начнёт слать после этого
dc.send(JSON.stringify({ type: 'transfer_confirm', accepted: true }));

// Принять чанки
await doReceiveFile(dc, metadata.file, fileHandle, signal, callbacks);

Если файл меньше 100 MB или браузер не поддерживает FSAA (Firefox, Safari) – fallback на in-memory: ArrayBuffer[] → Blob → <a download>. Confirm уходит сразу, без диалога.

Ещё одна неочевидная деталь в in-memory пути: URL.revokeObjectURL() нельзя вызывать сразу после a.click(). Браузер пишет файлы на диск в фоне – click() лишь инициирует скачивание. Revoke сразу – тихая потеря данных на файлах, которые не успели записаться. Мы откладываем revocation на 60 секунд. Blob держится в памяти, но это безопаснее, чем обрезанный файл.

Для server relay есть дополнительный шаг перед всем этим: UI-подтверждение от пользователя. Receiver видит размер файла и оставшиеся квоты, принимает осознанное решение – стоит ли тратить relay-лимит.

Проблема №4: сеть ломается асимметрично

Режимы отказа не бинарные. Signaling WebSocket может умереть, а DataChannel ещё жить. ICE может уйти в failed и через секунду вернуться в checking. Мобильный оператор может убить бездействующий TCP за две минуты. А переподключение WebSocket может принести устаревшие события из прошлой сессии.

ICE grace period. При iceConnectionState === 'failed' – не падаем сразу. На мобильных сетях relay-кандидаты приходят медленно. Даём 20 секунд grace period. Если ICE переходит в checking или connected – таймер отменяется.

Но одного grace period мало. Safari + flaky TURN вызывают бесконечный цикл: failed → checking → failed → checking.... Grace period перезапускается при каждом recovery – и передача зависает навсегда. Решение – cumulative counter:

onICEStateChange: (iceState) => {
  if (iceState === 'failed') {
    if (this.iceFirstFailedAt === null) this.iceFirstFailedAt = Date.now();
    const cumulativeMs = Date.now() - this.iceFirstFailedAt;
    if (cumulativeMs > 60_000) reject('ICE failed');   // cumulative cap
    if (!this.iceFailTimer) {
      this.iceFailTimer = setTimeout(reject, 20_000);  // grace period
    }
  } else if (['checking', 'connected', 'completed'].includes(iceState)) {
    clearTimeout(this.iceFailTimer);                    // recovery
    this.iceFirstFailedAt = null;                       // reset cumulative
  }
}

Два таймера, разная семантика: grace period ловит одиночные сбои, cumulative cap – бесконечные циклы.

Ещё одна ловушка при переподключении – устаревшие события. WebSocket signaling может восстановить соединение и принести peer_joinedsdp_offerice_candidate из прошлой сессии. Если их воспроизвести – новый transfer flow получит мусор. Решение элементарное, но забыть его легко:

private async doReconnect() {
  this.eventBuffer = [];  // stale events из прошлой сессии – в мусор
  const url = await this.buildURL(this.shareId);
  await this.setupWebSocket(url);
}

К этому добавляются две вещи, которые не имеют отношения к WebRTC, но без них всё разваливается на мобильных. Keepalive – {"type":"ping"} каждые 15 секунд – нужен не серверу (он игнорирует), а мобильным операторам, которые убивают idle TCP за 2–5 минут. А при возврате из фонового режима (Visibility API) нужно немедленно сбросить backoff и переподключиться, иначе пользователь ждёт минуты, хотя сеть давно доступна.

Наконец, peer_left может означать успех. После отправки файла sender ждёт transfer_complete от сервера, но receiver может закрыть вкладку раньше. Если файл уже отправлен (fileSentSuccessfully === true), это не ошибка – файл доставлен. transfer_complete – серверный учёт, а не необходимое условие доставки.

В реальной системе signaling и data plane живут разной жизнью и ломаются независимо. Проектировать нужно с учётом того, что каждый из них может умереть в непредсказуемый момент.

Проблема №5: refresh убивает File, но не должен убивать передачу

File не сериализуется. Ни в IndexedDB, ни в sessionStorage, ни куда-то ещё. Это свойство браузерной платформы: File – это handle на данные в памяти процесса. Refresh = handle потерян. Точка.

Текст, URL и состояние шары можно восстановить из IndexedDB. Но файл – нет. Sender после refresh знает о файле (имя, размер, тип), но не имеет самого файла.

Наш протокол учитывает это. Sender шлёт metadata с available: false и fileHint – информацией о файле без самого файла. Receiver видит: «файл логически есть, но физически недоступен». Это не загадочная ошибка, а штатный сценарий с продуктовым термином – unhealthy share.

В UI отображается UnhealthySharePrompt – предложение перетащить файл заново. Пользователь переподключает файл → updateFile(). Если получатель уже подключён и ждёт – вызывается reannounceFile(): через существующий DataChannel отправляется обновлённый metadata с available: true, а за ним – чанки файла. Пересборка WebRTC-соединения не нужна.

Важная деталь: active-шары не переводятся в failed при отключении отправителя. FailShare() срабатывает только для matched и transferring. Active-шара переживает refresh – отправитель переподключается, цикл передачи продолжается.

Из одного несериализуемого File возник целый слой протокола: fileHintavailable: false, unhealthy share, reannounceFile(), защита статуса active-шар от преждевременного сбоя. Ограничения браузерной платформы напрямую диктуют архитектуру.

Проблема №6: передача завершена – но это ещё и серверная state machine

Содержимое файла идёт мимо сервера приложения, но вокруг него – серверная машина состояний с границами доверия. Потому что клиент может соврать.

Начнём с учёта relay-трафика. Клиент сообщает connection_type при завершении передачи – но сервер ему не верит. При выдаче TURN credentials ставится Redis-флаг turn_issued:{share_id}, и при transfer_complete сервер проверяет именно его:

// Сервер: граница доверия – учёт relay-трафика
turnIssued, _ := redis.Get(ctx, "turn_issued:"+shareID).Bool()
if turnIssued {
    relayRepo.IncrementUsage(ctx, share.AccountID) // серверная правда
}
// Reset шары для следующего получателя
shareRepo.ResetForReuse(ctx, shareID)
hub.RemoveClient(shareID, "receiver")
hub.PromoteNextReceiver(shareID) // DB-first CAS, затем room placement

Тот же принцип недоверия работает и на других уровнях. TURN credentials выдаются только верифицированным участникам конкретной шары: для active – только отправителю, для matched/transferring – обоим. Подсмотреть share_id и получить доступ к relay нельзя. Переходы между состояниями защищены CAS: UpdateStatus(expectedStatus, newStatus) отвергает конкурентные переходы, что предотвращает гонки при одновременном signaling от нескольких клиентов.

После завершения передачи шара возвращается в active через ResetForReuse, receiver удаляется из комнаты, и следующий из очереди промоутится через DB-first CAS: сначала запись в базу, потом размещение в комнате и отправка буферизованных ICE-кандидатов.

Даже если содержимое файла идёт мимо сервера, сервер доверяет только своим собственным записям. Клиент может потерять связь, быть модифицирован или просто соврать. Серверная машина состояний – гарантия целостности.

Бонус: Safari и DataChannel-only connection

Safari для DataChannel-only сценария не собирает host ICE candidates. Решение – фейковый audio transceiver:

if (isSafari && typeof pc.addTransceiver === 'function') {
  pc.addTransceiver('audio', { direction: 'recvonly' });
}

Не требует getUserMedia или доступа к микрофону – чисто транспортный обходной приём. Кроме этого: Safari может упасть на пустых URL в TURN-конфигурации (нужна фильтрация), getStats() не заполняется мгновенно (повтор 3 × 500 ms для определения типа соединения), а SCTP maxMessageSize может вернуть 64 KB, ломая размер чанка. Safari – это отдельный мир даже в 2026 году.

Trade-offs

Оба участника должны быть онлайн одновременно. Нет offline-передачи, нет буферизации на сервере – это осознанный выбор. Архитектура с нулевым хранением ценнее, чем поддержка оффлайн-сценария, который потребовал бы серверного хранилища и ключей шифрования.

Объект File не переживает refresh – это ограничение платформы, вокруг которого мы построили протокол (unhealthy share, re-attach), но пользователь всё равно должен перетащить файл заново. А streaming download через File System Access API работает только в Chrome и Edge – Firefox и Safari получают in-memory fallback, и файлы больше гигабайта могут уронить браузер.

Наконец, поучительная история про границу доверия на relay-квоте. Изначально дневная байтовая квота форсилась только клиентом: сервер всегда выдавал TURN credentials (они нужны и для text/URL-шар, где квот нет), а relay_limits_exceeded в ответе был подсказкой клиенту, а не запретом. Модифицированный клиент проигнорировал бы флаг и использовал бы квоту до конца.

Исправилось двумя касаниями на сервере. Первое — при создании шара помечается has_file. Второе — в обработчике GET /api/ice-servers проверка: если share.has_file && relay_limits_exceeded → в ответ идут только STUN-серверы, без TURN. Text/URL-шары (has_file=false) TURN получают как раньше — для них квот и не было. Модифицированный клиент больше не получит credentials для файлового relay при исчерпанной квоте. Узкое окно остаётся — in-flight allocation, полученное до пересечения квоты, может дорежать свою передачу; это закроется reactive kill-switch'ом через coturn admin CLI в следующей итерации.

Плюс hard-лимиты на инфраструктуре, которые работают независимо от клиента: coturn держит 5 Mbps на сессию (max-bps) и максимум 50 одновременных allocations (total-quota). Сервис клиентом не положить, как ни модифицируй.

Если вашей задаче нужны оффлайн-доставка, история передач, фоновая загрузка, дедупликация или мультидевайс – проще и честнее взять S3 с presigned URL, Google Drive API или даже Telegram-бота. P2P через WebRTC оправдан, когда принципиально важно не хранить чужие файлы на своём сервере, а оба участника готовы быть онлайн одновременно.

Что на самом деле означает «отправить файл через WebRTC»

«Отправка файла» в продакшене устроена сложнее, чем кажется из туториала. Сложность прячется не в одном месте, а распределена по всему стеку.

На уровне транспорта DataChannel даёт буфер и событие onmessage, но не даёт ни управления давлением, ни подтверждения доставки, ни адаптации под скорость канала. Всё это приходится строить поверх: drain-мониторинг, адаптивные таймауты, transfer_ack перед transfer_done.

На уровне браузера – свои ограничения, которые напрямую диктуют протокол: File не сериализуется, showSaveFilePicker() блокирует, URL.revokeObjectURL() нельзя вызывать сразу, а DataChannel может переиспользовать буфер между вызовами onmessage. Каждое из этих ограничений превращается в отдельный протокольный шаг.

На уровне сервера – координация. Содержимое файла идёт мимо сервера, но сервер управляет всем вокруг: машина состояний с CAS-переходами, очередь получателей, учёт relay-трафика через Redis, защита TURN credentials. Клиент может соврать – сервер не должен ему верить.

WebRTC даёт транспорт. Продуктовый протокол передачи приходится проектировать самому – и он оказывается сложнее самого WebRTC.


Этот протокол обкатан на реальном сервисе передачи файлов – komu.online.