Реализация WebRTC для P2P-обмена данными на сайте
WebRTC не ограничивается видео. RTCDataChannel — это низкоуровневый канал данных между браузерами с задержками порядка 20–50 мс, который работает поверх SCTP/DTLS без серверного посредника для передачи данных. Файлообменник, совместный редактор, игровой бэклоб, mesh-синхронизация — всё это строится на DataChannel.
RTCDataChannel vs WebSocket
| Параметр | WebSocket | RTCDataChannel |
|---|---|---|
| Маршрут данных | Клиент → Сервер → Клиент | Клиент → Клиент (P2P) |
| Задержка | 50–200 мс (через сервер) | 20–60 мс (прямой канал) |
| Надёжность | TCP (ordered, reliable) | Настраиваемая |
| Шифрование | TLS | DTLS (обязательно) |
| Нагрузка на сервер | Весь трафик | Только сигнализация |
Главное преимущество — данные не проходят через ваши серверы. Это критично для файлообмена, E2E-шифрованных чатов, приватных игровых сессий.
Создание DataChannel
// Инициатор (offerer)
const pc = new RTCPeerConnection({ iceServers: [/* ... */] });
const channel = pc.createDataChannel('files', {
ordered: true, // TCP-семантика (гарантированный порядок)
// maxRetransmits: 0, // UDP-семантика (без ретрансмиссий)
// maxPacketLifeTime: 100, // Ограничение времени жизни пакета (мс)
});
channel.binaryType = 'arraybuffer';
channel.bufferedAmountLowThreshold = 65536; // 64 KB
channel.onopen = () => console.log('DataChannel open');
channel.onclose = () => console.log('DataChannel closed');
channel.onmessage = (e) => handleMessage(e.data);
// Получатель (answerer)
pc.ondatachannel = (e) => {
const remoteChannel = e.channel;
remoteChannel.onmessage = (e) => handleMessage(e.data);
};
Режимы надёжности
SCTP под DataChannel позволяет настраивать семантику доставки:
- ordered + reliable (по умолчанию) — гарантированный порядок, ретрансмиссия. Для файлов, сообщений чата.
-
unordered + unreliable (
maxRetransmits: 0) — UDP-like. Для игровых позиций, курсоров, где нужна скорость, а не гарантия. - ordered + maxPacketLifeTime — пакет живёт N мс, после выбрасывается. Для голосовых команд, ввода с клавиатуры.
Передача файлов через DataChannel
Браузерный DataChannel ограничен размером сообщения ~256 KB (зависит от реализации). Файлы нужно чанковать:
const CHUNK_SIZE = 64 * 1024; // 64 KB
async function sendFile(channel, file) {
const metadata = JSON.stringify({
name: file.name,
size: file.size,
type: file.type,
chunks: Math.ceil(file.size / CHUNK_SIZE),
});
channel.send(metadata);
const buffer = await file.arrayBuffer();
let offset = 0;
function sendNextChunk() {
while (offset < buffer.byteLength) {
// Контроль перегрузки буфера
if (channel.bufferedAmount > channel.bufferedAmountLowThreshold * 2) {
channel.onbufferedamountlow = () => {
channel.onbufferedamountlow = null;
sendNextChunk();
};
return;
}
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
channel.send(chunk);
offset += CHUNK_SIZE;
}
channel.send(JSON.stringify({ type: 'transfer-complete' }));
}
sendNextChunk();
}
Получатель собирает чанки:
let receivedSize = 0;
let receivedChunks = [];
let fileMetadata = null;
channel.onmessage = (e) => {
if (typeof e.data === 'string') {
const msg = JSON.parse(e.data);
if (msg.name) {
fileMetadata = msg;
} else if (msg.type === 'transfer-complete') {
const blob = new Blob(receivedChunks);
triggerDownload(blob, fileMetadata.name);
}
} else {
receivedChunks.push(e.data);
receivedSize += e.data.byteLength;
updateProgress(receivedSize / fileMetadata.size);
}
};
Прогресс и скорость передачи
const startTime = Date.now();
let lastSize = 0;
function updateProgress(ratio) {
const elapsed = (Date.now() - startTime) / 1000;
const speed = (receivedSize - lastSize) / 1024; // KB/s за последний тик
lastSize = receivedSize;
progressBar.style.width = `${ratio * 100}%`;
speedLabel.textContent = `${(receivedSize / elapsed / 1024).toFixed(1)} KB/s`;
etaLabel.textContent = `${((fileMetadata.size - receivedSize) / (receivedSize / elapsed)).toFixed(0)} сек`;
}
Многоканальная архитектура
Для разных типов данных — разные каналы с разными настройками надёжности:
const channels = {
control: pc.createDataChannel('control', { ordered: true }),
files: pc.createDataChannel('files', { ordered: true }),
cursor: pc.createDataChannel('cursor', { ordered: false, maxRetransmits: 0 }),
chat: pc.createDataChannel('chat', { ordered: true }),
};
E2E-шифрование поверх DTLS
DataChannel уже шифрован DTLS. Для дополнительного E2E (где ключи не видны даже серверу) используют Web Crypto API:
// Обмен ключами через ECDH в процессе сигнализации
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, ['deriveKey']
);
// Публичный ключ отправляется через сигнальный сервер
const publicKeyExported = await crypto.subtle.exportKey('raw', keyPair.publicKey);
// После получения публичного ключа партнёра
const sharedKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: partnerPublicKey },
keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
);
Mesh P2P для нескольких участников
До 4–6 участников допустима full-mesh топология (каждый с каждым):
class MeshNetwork {
constructor(signalSocket) {
this.peers = new Map(); // userId -> RTCPeerConnection
this.channels = new Map();
this.signal = signalSocket;
}
async connectTo(userId) {
const pc = new RTCPeerConnection(ICE_CONFIG);
this.peers.set(userId, pc);
const channel = pc.createDataChannel('mesh');
this.channels.set(userId, channel);
channel.onmessage = (e) => this.onData(userId, e.data);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.signal.emit('offer', { to: userId, offer });
}
broadcast(data) {
const message = JSON.stringify(data);
this.channels.forEach(ch => {
if (ch.readyState === 'open') ch.send(message);
});
}
}
Типичные применения
Совместный whiteboard — позиции курсора через unreliable channel (задержка ~20 мс), операции рисования через reliable. Дельта-синхронизация раз в 100 мс.
Игровой матч 1v1 — состояние игрока (позиция, действие) через unordered channel 60 раз в секунду. Критические события (попадание, смерть) через ordered reliable.
Peer-to-peer чат с файлами — текст через ordered reliable, файлы чанками с контролем буфера, превью изображений как base64 в JSON.
Screenshare + annotation — видеопоток через RTCPeerConnection, аннотации (координаты кликов) через DataChannel.
Ограничения и подводные камни
-
Safari не поддерживает
bufferedAmountLowThresholdв старых версиях — нужен polling черезsetInterval - Firefox имеет максимальный размер сообщения 256 KB; Chrome — 256 KB в некоторых версиях, но может варьироваться
- Мобильные браузеры могут закрывать DataChannel при уходе приложения в фон — нужна логика переподключения
- При потере соединения
iceConnectionState === 'failed'— автоматического восстановления нет, нужен явный reconnect
Сроки
- Базовый файлообменник P2P (2 участника) — 3–4 дня
- Многопользовательский mesh с несколькими каналами — 1–2 недели
- E2E-шифрование + ключевой обмен — плюс 3–4 дня
- Совместный редактор / whiteboard поверх DataChannel — 2–4 недели (включая логику CRDT/OT)







