Реалізація WebRTC для P2P-обміну даними на сайті
WebRTC не обмежується відео. RTCDataChannel — це низкоуровневий канал даних між браузерами з задержками порядку 20–50 мс, що працює поверх SCTP/DTLS без серверного посередника для передачи даних. Файлообменник, спільний редактор, ігровий бекенд, mesh-синхронізація — все це будується на DataChannel.
RTCDataChannel проти WebSocket
| Параметр | WebSocket | RTCDataChannel |
|---|---|---|
| Маршрут даних | Клієнт → Сервер → Клієнт | Клієнт → Клієнт (P2P) |
| Задержка | 50–200 мс (через сервер) | 20–60 мс (прямий канал) |
| Надійність | TCP (упорядкований, надійний) | Настраиваемая |
| Шифрування | 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 канал (~20 мс задержка), операції рисування через reliable. Дельта-синхронізація раз у 100 мс.
Ігровий матч 1v1 — стан гравця (позиція, дія) через unordered канал 60 разів на секунду. Критичні события (попадання, смерть) через ordered reliable.
Peer-to-peer чат з файлами — текст через ordered reliable, файли чанками з контролем буфера, превью зображень як base64 у JSON.
Screenshare + annotation — відеопоток через RTCPeerConnection, анотації (координати кліків) через DataChannel.
Обмеження та підводні камені
-
Safari не підтримує
bufferedAmountLowThresholdу старих версіях — потрібен polling черезsetInterval - Firefox максимальний розмір повідомлення 256 KB; Chrome варіюється залежно від версії
- Мобільні браузери можуть закривати DataChannel при уходе додатку в фон — потрібна логіка переподключення
- При втраті з'єднання
iceConnectionState === 'failed'— автоматичного восстановлення нема, потрібен явний reconnect
Терміни
- Базовий файлообменник P2P (2 учасники) — 3–4 дні
- Многопользовательський mesh з кількома каналами — 1–2 тижні
- E2E-шифрування + ключевий обмін — плюс 3–4 дні
- Спільний редактор / whiteboard поверх DataChannel — 2–4 тижні (включаючи логіку CRDT/OT)







