Referensi cepat WebRTC API. getUserMedia, RTCPeerConnection, signaling, ICE, DataChannel, dan STUN/TURN. Perfect buat developer yang bangun real-time P2P apps.
Browser A Browser B
| |
|--- Signaling (WebSocket) ------|
| (SDP offer/answer, ICE) |
| |
|=== Direct P2P (Media/Data) ====|
| (setelah connection established) |Komponen utama:
Akses kamera dan mikrofon browser.
// Basic: kamera + mikrofon
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// Spesifik: HD video
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user', // 'user' (front) atau 'environment' (back)
frameRate: { ideal: 30, max: 60 },
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// Tampilkan di video element
const video = document.querySelector('video');
video.srcObject = stream;
await video.play();
// Audio only (voice call)
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});// Share seluruh screen
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always', // 'always' | 'motion' | 'hidden'
displaySurface: 'monitor', // 'monitor' | 'window' | 'browser' | 'application'
},
audio: true, // system audio
});
// Share dengan preferensi
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { max: 1920 },
height: { max: 1080 },
frameRate: { max: 30 },
},
audio: false,
preferCurrentTab: true, // Chrome: prefer tab saat ini
selfBrowserSurface: 'include', // 'include' | 'exclude'
});
// Handle stop sharing
displayStream.getVideoTracks()[0].addEventListener('ended', () => {
console.log('User stopped sharing');
});const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
// Get individual tracks
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
// Track properties
console.log(videoTrack.kind); // 'video'
console.log(videoTrack.label); // 'Default - Camera'
console.log(videoTrack.enabled); // true
console.log(videoTrack.muted); // false
console.log(videoTrack.readyState); // 'live' | 'ended'
// Toggle mute
videoTrack.enabled = false; // black screen, track tetap aktif
// Stop track (release camera)
videoTrack.stop();
// Track constraints (apply runtime)
await videoTrack.applyConstraints({
width: 640,
height: 480,
frameRate: 15,
});
// Listen untuk track events
videoTrack.addEventListener('ended', () => console.log('Camera stopped'));
videoTrack.addEventListener('mute', () => console.log('Track muted'));
videoTrack.addEventListener('unmute', () => console.log('Track unmuted'));// List semua media devices
const devices = await navigator.mediaDevices.enumerateDevices();
devices.forEach(device => {
console.log(`${device.kind}: ${device.label} [${device.deviceId}]`);
});
// videoinput: Front Camera [abc123]
// audioinput: Default Microphone [def456]
// audiooutput: Speakers [ghi789]
// Switch camera
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: targetDeviceId } },
});const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass',
},
],
iceCandidatePoolSize: 10,
};
const pc = new RTCPeerConnection(configuration);
// Add local tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
// Handle ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
// Kirim candidate ke remote peer via signaling server
signaling.send({ type: 'ice', candidate: event.candidate });
}
};
// Handle remote tracks
pc.ontrack = (event) => {
const remoteVideo = document.querySelector('#remote');
remoteVideo.srcObject = event.streams[0];
};
// Handle connection state
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState);
// 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'
};
// Create offer
const offer = await pc.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
});
await pc.setLocalDescription(offer);
// Kirim offer ke remote peer via signaling
signaling.send({ type: 'offer', sdp: offer });const pc = new RTCPeerConnection(configuration);
// Add local tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Handle remote tracks
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
pc.onicecandidate = (event) => {
if (event.candidate) {
signaling.send({ type: 'ice', candidate: event.candidate });
}
};
// Receive offer from signaling
signaling.onmessage = async (msg) => {
if (msg.type === 'offer') {
await pc.setRemoteDescription(msg.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signaling.send({ type: 'answer', sdp: answer });
}
if (msg.type === 'ice') {
await pc.addIceCandidate(msg.candidate);
}
};// Di caller side, setelah kirim offer
signaling.onmessage = async (msg) => {
if (msg.type === 'answer') {
await pc.setRemoteDescription(msg.sdp);
}
if (msg.type === 'ice') {
await pc.addIceCandidate(msg.candidate);
}
};pc.onicecandidate = (event) => {
if (event.candidate) {
const c = event.candidate.candidate;
// host: direct local address
// srflx: STUN server reflexive (public address via NAT)
// relay: TURN relay (traffic through TURN server)
console.log(`ICE: ${c}`);
}
};
// ICE gathering state
pc.onicegatheringstatechange = () => {
console.log('ICE gathering:', pc.iceGatheringState);
// 'new' | 'gathering' | 'complete'
};
// ICE connection state
pc.oniceconnectionstatechange = () => {
console.log('ICE connection:', pc.iceConnectionState);
// 'new' | 'checking' | 'connected' | 'completed' | 'failed' | 'disconnected' | 'closed'
};
// Trickle ICE: kirim candidate begitu tersedia (lebih cepat)
// Non-trickle: tunggu semua candidate terkumpul, baru kirim SDP
// Force ICE restart
await pc.restartIce();// Caller creates channel
const pc = new RTCPeerConnection(configuration);
const channel = pc.createDataChannel('chat', {
ordered: true, // reliable, in-order delivery (TCP-like)
maxRetransmits: 3, // atau maxPacketLifeTime: 3000 (UDP-like)
});
channel.onopen = () => {
console.log('DataChannel open');
channel.send('Hello via DataChannel!');
};
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
channel.onclose = () => console.log('DataChannel closed');
channel.onerror = (err) => console.error('DataChannel error:', err);
// Answerer receives channel
pc.ondatachannel = (event) => {
const channel = event.channel;
channel.onmessage = (e) => console.log('Received:', e.data);
channel.onopen = () => channel.send('Hi back!');
};// Send text
channel.send('Hello World');
// Send JSON
channel.send(JSON.stringify({ type: 'message', text: 'hi' }));
// Send binary (ArrayBuffer)
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setInt32(0, 42);
channel.send(buffer);
// Send Blob (large files)
channel.send(blob);
// Check channel state
console.log(channel.readyState); // 'connecting' | 'open' | 'closing' | 'closed'
console.log(channel.bufferedAmount); // bytes queued, belum terkirim
// Flow control (jangan overflow buffer)
function sendChunk(data) {
if (channel.bufferedAmount > 1024 * 1024) { // 1MB limit
setTimeout(() => sendChunk(data), 100);
return;
}
channel.send(data);
}
// File transfer dengan chunking
const CHUNK_SIZE = 16384; // 16KB
function sendFile(file) {
let offset = 0;
const reader = new FileReader();
reader.onload = (e) => {
channel.send(e.target.result);
offset += e.target.result.byteLength;
if (offset < file.size) {
readSlice(offset);
}
};
function readSlice(o) {
const slice = file.slice(o, o + CHUNK_SIZE);
reader.readAsArrayBuffer(slice);
}
readSlice(0);
}Pattern yang mencegah glare (kedua peer offer bersamaan).
let makingOffer = false;
let ignoreOffer = false;
const polite = true; // satu side polite, lainnya impolite
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signaling.send({ description: pc.localDescription });
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
signaling.onmessage = async ({ description, candidate }) => {
try {
if (description) {
const offerCollision = description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable');
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
signaling.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) throw err;
}
}
} catch (err) {
console.error(err);
}
};// Add a track to existing connection
async function addScreenShare() {
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
// Add to existing peer connection
screenStream.getTracks().forEach(track => {
const sender = pc.addTrack(track, screenStream);
// Track sender untuk remove nanti
screenSender = sender;
});
// Renegotiation otomatis dipicu oleh onnegotiationneeded
}
// Remove a track
function stopScreenShare() {
if (screenSender) {
pc.removeTrack(screenSender);
// onnegotiationneeded akan trigger lagi
}
}
// Replace track (switch camera tanpa renegotiation)
async function switchCamera(newStream) {
const newVideoTrack = newStream.getVideoTracks()[0];
const sender = pc.getSenders().find(s => s.track.kind === 'video');
await sender.replaceTrack(newVideoTrack);
// Tidak butuh renegotiation!
}// Get connection statistics
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp') {
console.log('Received:', {
packetsLost: report.packetsLost,
jitter: report.jitter,
bytesReceived: report.bytesReceived,
});
}
if (report.type === 'outbound-rtp') {
console.log('Sent:', {
packetsSent: report.packetsSent,
bytesSent: report.bytesSent,
targetBitrate: report.targetBitrate,
});
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
console.log('Connection:', {
currentRoundTripTime: report.currentRoundTripTime,
availableOutgoingBitrate: report.availableOutgoingBitrate,
});
}
});
// Monitor periodically
setInterval(async () => {
const stats = await pc.getStats();
// Process stats...
}, 1000);// STUN: discovery public IP address (gratis)
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
],
};
// TURN: relay server untuk NAT traversal (berbayar, self-hosted)
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:5349?transport=tcp', // TLS
],
username: 'username',
credential: 'password',
},
],
iceTransportPolicy: 'relay', // force TURN only (testing)
};# Install coturn
sudo apt install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
realm=example.com
server-name=turn.example.com
user=username:password
lt-cred-mech
fingerprint
cert=/etc/ssl/certs/turn_cert.pem
pkey=/etc/ssl/private/turn_key.pem
no-cliWebRTC P2P terbatas pada beberapa participant. Buat group calls, pakai SFU (Selective Forwarding Unit).
// Pattern: setiap participant punya RTCPeerConnection ke SFU
// SFU (mediasoup, Janus, LiveKit, Twilio) relay tracks antar participant
// Client connects ke SFU
const sfuSocket = new WebSocket('wss://sfu.example.com');
// SFU memberiin offer, client jawab dengan answer
// SFU handle routing tracks antar participant
// Library populer: LiveKit, mediasoup, Janus, Jitsi// Feature detection
if (!navigator.mediaDevices?.getUserMedia) {
alert('Browser tidak mendukung WebRTC');
}
// Adapter.js untuk cross-browser compat
import adapter from 'webrtc-adapter';
// Polyfill check
if (!window.RTCPeerConnection) {
console.error('WebRTC not supported');
}
// Safari quirks
// - video element harus inline (playsinline attribute)
// <video playsinline autoplay></video>
// Firefox-specific
// - Simulcast perlu SDP munging
// - DataChannel: binaryType harus 'arraybuffer'
// Mobile
// - iOS requires user gesture untuk getUserMedia
// - Background tab: tracks mungkin pause
// - Multiple cameras: facingMode untuk switchLogin atau daftar akun gratis untuk membaca cheat sheet ini.