WEBRTC REAL-TIME P2P SIGNALING NODEJS WEBSOCKET WEB-DEVELOPMENT BACKEND NETWORKING PEER-TO-PEER JAVASCRIPT SERVER-SIDE COMMUNICATION ARCHITECTURE

WebRTC Tanpa Drama: Membangun Signaling Server Anda Sendiri dengan Node.js dan WebSocket

⏱️ 33 menit baca
👨‍💻

WebRTC Tanpa Drama: Membangun Signaling Server Anda Sendiri dengan Node.js dan WebSocket

1. Pendahuluan

Bayangkan Anda sedang membangun aplikasi video call, game multiplayer real-time, atau bahkan aplikasi file sharing antar browser. WebRTC (Web Real-Time Communication) adalah teknologi superpower di balik semua itu, memungkinkan browser untuk berkomunikasi langsung satu sama lain (peer-to-peer) tanpa perlu server perantara untuk setiap aliran data. Kedengarannya fantastis, bukan? ✅

Namun, ada satu “misteri” kecil yang sering membingungkan developer saat pertama kali menyelami WebRTC: Bagaimana dua browser yang tidak saling kenal bisa “saling menemukan” dan menyepakati cara berkomunikasi? Di sinilah peran krusial Signaling Server muncul. Signaling server adalah “mak comblang” atau “resepsionis” yang membantu peer bertukar informasi awal sebelum mereka bisa berbicara langsung.

Tanpa signaling server, WebRTC tidak akan bisa berfungsi. Meskipun WebRTC API di browser tidak menyediakan bagian signaling, membangunnya sendiri adalah langkah fundamental. Artikel ini akan memandu Anda secara praktis, membangun signaling server sederhana menggunakan Node.js dan WebSocket, serta mengintegrasikannya dengan kode WebRTC di frontend Anda. Mari kita pecahkan misteri ini bersama! 💡

2. Memahami Peran Signaling di WebRTC

Pertama, mari kita tegaskan satu hal: Signaling bukan bagian dari spesifikasi WebRTC itu sendiri. Ini adalah proses di luar WebRTC API yang Anda harus implementasikan sendiri. Jadi, apa sebenarnya yang dilakukan signaling?

Tugas utama signaling adalah memfasilitasi pertukaran tiga jenis informasi penting antara peer:

  1. Session Description Protocol (SDP) Offer/Answer: Ini adalah “kartu nama” yang berisi detail teknis tentang bagaimana sebuah peer ingin berkomunikasi. Misalnya, codec audio/video apa yang didukung, resolusi yang diinginkan, dan kemampuan lainnya. Satu peer akan mengirim “Offer” dan peer lainnya akan membalas dengan “Answer” setelah menyepakati parameter.
  2. ICE Candidates (Interactive Connectivity Establishment): Ini adalah informasi tentang bagaimana peer bisa dijangkau di jaringan. Bisa berupa alamat IP lokal, alamat IP publik yang didapatkan dari STUN server, atau bahkan relay address dari TURN server (untuk mengatasi NAT traversal yang kompleks). Peer akan terus bertukar kandidat ini sampai menemukan jalur terbaik untuk koneksi langsung.
  3. Pengendalian Sesi: Informasi seperti siapa yang ingin terhubung dengan siapa, kapan sesi dimulai, dan kapan berakhir.

📌 Analogi: Anggap saja WebRTC adalah dua orang yang ingin berbicara langsung di telepon. Signaling server adalah operator telepon yang membantu mereka:

3. Arsitektur Signaling Server Sederhana

Untuk panduan ini, kita akan menggunakan arsitektur yang paling umum dan mudah diimplementasikan: Client-Server dengan WebSocket.

Mengapa WebSocket? Karena WebSocket menyediakan koneksi full-duplex (dua arah) yang persisten antara client dan server, sangat ideal untuk komunikasi real-time di mana server perlu mengirim pesan ke client kapan saja, bukan hanya sebagai respons terhadap permintaan client.

🎯 Tujuan kita: Membangun server Node.js yang bisa:

  1. Menerima koneksi WebSocket dari banyak client.
  2. Mengidentifikasi client (misalnya, dengan ID unik atau “ruangan” obrolan).
  3. Meneruskan pesan signaling (SDP Offer/Answer, ICE Candidates) dari satu client ke client target.

4. Membangun Backend Signaling Server dengan Node.js dan ws

Mari kita mulai dengan membangun servernya. Kita akan menggunakan Node.js dan library ws yang populer untuk WebSocket.

Persiapan Proyek

Buat direktori baru dan inisialisasi proyek Node.js:

mkdir webrtc-signaling-server
cd webrtc-signaling-server
npm init -y
npm install ws express # Express untuk server HTTP dasar, ws untuk WebSocket

Kode Signaling Server (server.js)

const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();
const port = 8080;

// Serve static files for the frontend (optional, but useful for testing)
app.use(express.static('public'));

// Create an HTTP server
const server = http.createServer(app);

// Create a WebSocket server instance
const wss = new WebSocket.Server({ server });

// Store connected clients and rooms
const clients = new Map(); // Map<WebSocket, { id: string, room: string }>
const rooms = new Map();   // Map<string, Set<WebSocket>> // Map<roomName, Set<clientWebSockets>>

wss.on('connection', (ws) => {
    const clientId = Math.random().toString(36).substring(2, 15); // Simple unique ID
    console.log(`📌 Client connected: ${clientId}`);

    // Store client info
    clients.set(ws, { id: clientId, room: null });

    ws.on('message', (message) => {
        const data = JSON.parse(message.toString());
        console.log(`✉️ Message from ${clientId}:`, data);

        switch (data.type) {
            case 'join':
                // Client wants to join a room
                const roomName = data.room;
                const clientInfo = clients.get(ws);
                clientInfo.room = roomName;

                if (!rooms.has(roomName)) {
                    rooms.set(roomName, new Set());
                }
                rooms.get(roomName).add(ws);
                console.log(`✅ Client ${clientId} joined room: ${roomName}`);

                // Notify other peers in the room about the new joiner
                rooms.get(roomName).forEach(clientWs => {
                    if (clientWs !== ws && clientWs.readyState === WebSocket.OPEN) {
                        clientWs.send(JSON.stringify({
                            type: 'peer-joined',
                            peerId: clientId
                        }));
                    }
                });

                // For simplicity, if there's another peer, automatically try to connect
                if (rooms.get(roomName).size > 1) {
                    const otherPeerWs = Array.from(rooms.get(roomName)).find(c => c !== ws);
                    if (otherPeerWs && otherPeerWs.readyState === WebSocket.OPEN) {
                        ws.send(JSON.stringify({
                            type: 'start-call',
                            targetId: clients.get(otherPeerWs).id // Tell new peer to initiate call
                        }));
                    }
                }
                break;

            case 'offer':
            case 'answer':
            case 'candidate':
                // Forward WebRTC signaling messages to the target peer in the same room
                const targetId = data.targetId;
                const senderId = clients.get(ws).id;
                let targetWs = null;

                // Find the target WebSocket connection
                for (let [clientWs, info] of clients.entries()) {
                    if (info.id === targetId) {
                        targetWs = clientWs;
                        break;
                    }
                }

                if (targetWs && targetWs.readyState === WebSocket.OPEN) {
                    // Send the signaling data to the target peer
                    targetWs.send(JSON.stringify({
                        type: data.type,
                        senderId: senderId,
                        payload: data.payload
                    }));
                    console.log(`➡️ Forwarded ${data.type} from ${senderId} to ${targetId}`);
                } else {
                    console.warn(`❌ Target client ${targetId} not found or not open.`);
                }
                break;

            default:
                console.warn(`⚠️ Unknown message type: ${data.type}`);
        }
    });

    ws.on('close', () => {
        console.log(`Bye Client: ${clientId}`);
        const clientInfo = clients.get(ws);
        const roomName = clientInfo?.room;

        // Remove client from room
        if (roomName && rooms.has(roomName)) {
            rooms.get(roomName).delete(ws);
            if (rooms.get(roomName).size === 0) {
                rooms.delete(roomName); // Clean up empty rooms
            } else {
                // Notify remaining peers that a peer left
                rooms.get(roomName).forEach(clientWs => {
                    if (clientWs.readyState === WebSocket.OPEN) {
                        clientWs.send(JSON.stringify({
                            type: 'peer-left',
                            peerId: clientId
                        }));
                    }
                });
            }
        }
        clients.delete(ws);
        console.log(`Total active clients: ${clients.size}`);
    });

    ws.on('error', (error) => {
        console.error(`Error on client ${clientId}:`, error);
    });
});

server.listen(port, () => {
    console.log(`🚀 Signaling server running on http://localhost:${port}`);
});

Penjelasan Kode:

Untuk menguji, buat folder public di root proyek Anda, dan di dalamnya buat index.html atau file frontend lainnya.

5. Mengintegrasikan dengan Frontend WebRTC

Sekarang, mari kita lihat bagaimana client di browser akan berinteraksi dengan signaling server ini. Untuk penyederhanaan, kita akan fokus pada bagian signaling dan mengasumsikan Anda sudah familiar dengan dasar-dasar RTCPeerConnection.

Buat file public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC Signaling Client</title>
    <style>
        body { font-family: sans-serif; margin: 20px; }
        #messages { border: 1px solid #ccc; padding: 10px; min-height: 150px; overflow-y: scroll; margin-bottom: 10px; }
        button { margin-right: 10px; padding: 8px 15px; cursor: pointer; }
        input { padding: 8px; margin-right: 10px; }
    </style>
</head>
<body>
    <h1>WebRTC Signaling Client</h1>
    <p>Your ID: <span id="myId"></span></p>
    <div>
        <input type="text" id="roomInput" placeholder="Enter room name (e.g., 'myroom')" value="test-room">
        <button id="joinButton">Join Room</button>
    </div>
    <br>
    <div>
        <button id="startButton" disabled>Start Call (Offer)</button>
        <button id="hangupButton" disabled>Hang Up</button>
    </div>
    <br>
    <div id="messages"></div>
    <video id="localVideo" autoplay muted style="width: 300px; height: 200px; border: 1px solid black;"></video>
    <video id="remoteVideo" autoplay style="width: 300px; height: 200px; border: 1px solid black;"></video>

    <script>
        const myIdSpan = document.getElementById('myId');
        const roomInput = document.getElementById('roomInput');
        const joinButton = document.getElementById('joinButton');
        const startButton = document.getElementById('startButton');
        const hangupButton = document.getElementById('hangupButton');
        const messagesDiv = document.getElementById('messages');
        const localVideo = document.getElementById('localVideo');
        const remoteVideo = document.getElementById('remoteVideo');

        let ws;
        let peerConnection;
        let localStream;
        let currentRoom = '';
        let myId = ''; // This will be assigned by the server
        let remotePeerId = '';

        const log = (msg) => {
            const p = document.createElement('p');
            p.textContent = msg;
            messagesDiv.appendChild(p);
            messagesDiv.scrollTop = messagesDiv.scrollHeight;
        };

        // --- WebSocket Setup ---
        const connectWebSocket = () => {
            ws = new WebSocket('ws://localhost:8080'); // Connect to our signaling server

            ws.onopen = () => {
                log('✅ Connected to signaling server');
                joinButton.disabled = false;
            };

            ws.onmessage = async (event) => {
                const data = JSON.parse(event.data);
                log(`✉️ Received from server: ${JSON.stringify(data)}`);

                switch (data.type) {
                    case 'peer-joined':
                        log(`Another peer ${data.peerId} joined the room!`);
                        // No action needed here, 'start-call' will initiate
                        break;
                    case 'start-call':
                        log(`Server requested to start call with ${data.targetId}`);
                        remotePeerId = data.targetId;
                        startButton.disabled = false; // Enable call button if there's a peer
                        break;
                    case 'offer':
                        if (!peerConnection) {
                            await createPeerConnection(); // Create PC if not exists
                        }
                        await peerConnection.setRemoteDescription(new RTCSessionDescription(data.payload));
                        const answer = await peerConnection.createAnswer();
                        await peerConnection.setLocalDescription(answer);
                        ws.send(JSON.stringify({
                            type: 'answer',
                            targetId: data.senderId,
                            payload: answer
                        }));
                        log(`➡️ Sent Answer to ${data.senderId}`);
                        break;
                    case 'answer':
                        await peerConnection.setRemoteDescription(new RTCSessionDescription(data.payload));
                        break;
                    case 'candidate':
                        if (peerConnection && data.payload) {
                            try {
                                await peerConnection.addIceCandidate(new RTCIceCandidate(data.payload));
                            } catch (e) {
                                console.error('Error adding received ICE candidate', e);
                            }
                        }
                        break;
                    default:
                        console.warn('Unknown message type:', data.type);
                }
            };

            ws.onclose = () => {
                log('❌ Disconnected from signaling server');
                joinButton.disabled = true;
                startButton.disabled = true;
                hangupButton.disabled = true;
            };

            ws.onerror = (error) => {
                log(`⚠️ WebSocket error: ${error.message}`);
                console.error('WebSocket error:', error);
            };
        };

        // --- WebRTC Setup ---
        const createPeerConnection = async () => {
            const configuration = {
                iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] // Public STUN server
            };
            peerConnection = new RTCPeerConnection(configuration);

            peerConnection.onicecandidate = (event) => {
                if (event.candidate) {
                    ws.send(JSON.stringify({
                        type: 'candidate',
                        targetId: remotePeerId, // IMPORTANT: Send to the specific remote peer
                        payload: event.candidate
                    }));
                    log(`➡️ Sent ICE candidate to ${remotePeerId}`);
                }
            };

            peerConnection.ontrack = (event) => {
                log('Received remote track!');
                remoteVideo.srcObject = event.streams[0];
            };

            peerConnection.oniceconnectionstatechange = () => {
                log(`ICE connection state: ${peerConnection.iceConnectionState}`);
            };

            // Add local stream tracks to peer connection
            if (localStream) {
                localStream.getTracks().forEach(track => {
                    peerConnection.addTrack(track, localStream);
                });
            }
        };

        const startLocalStream = async () => {
            try {
                localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
                localVideo.srcObject = localStream;
                log('✅ Local stream started.');
            } catch (e) {
                console.error('Error getting user media:', e);
                log('❌ Failed to get local media. Make sure camera/mic are allowed.');
            }
        };

        // --- Event Handlers ---
        joinButton.onclick = async () => {
            currentRoom = roomInput.value.trim();
            if (currentRoom) {
                ws.send(JSON.stringify({ type: 'join', room: currentRoom }));
                log(`Attempting to join room: ${currentRoom}`);
                joinButton.disabled = true;
                roomInput.disabled = true;
                await startLocalStream();
            } else {
                log('Please enter a room name.');
            }
        };

        startButton.onclick = async () => {
            if (!peerConnection) {
                await createPeerConnection();
            }
            const offer = await peerConnection.createOffer();
            await peerConnection.setLocalDescription(offer);
            ws.send(JSON.stringify({
                type: 'offer',
                targetId: remotePeerId,
                payload: offer
            }));
            log(`➡️ Sent Offer to ${remotePeerId}`);
            startButton.disabled = true;
            hangupButton.disabled = false;
        };

        hangupButton.onclick = () => {
            if (peerConnection) {
                peerConnection.close();
                peerConnection = null;
                log('Call hung up.');
            }
            if (localStream) {
                localStream.getTracks().forEach(track => track.stop());
                localStream = null;
                localVideo.srcObject = null;
            }
            remoteVideo.srcObject = null;
            startButton.disabled = false;
            hangupButton.disabled = true;
        };

        // Initial connection
        connectWebSocket();
    </script>
</body>
</html>

Cara Kerja Frontend:

  1. Koneksi WebSocket: Browser membuka koneksi WebSocket ke ws://localhost:8080.
  2. Bergabung ke Ruangan: Setelah terkoneksi, user bisa memasukkan nama ruangan dan menekan “Join Room”. Pesan join dikirim ke signaling server. Server akan menetapkan myId untuk client ini dan memberitahu client lain jika ada.
  3. Memulai Panggilan (Offer): Ketika startButton ditekan, fungsi createPeerConnection dipanggil. Ini menginisialisasi RTCPeerConnection dan menambahkan localStream (dari kamera/mic). Kemudian, createOffer() dipanggil untuk menghasilkan SDP Offer. Offer ini diatur sebagai localDescription dan dikirim ke signaling server dengan type: 'offer' dan targetId (ID peer lain di ruangan).
  4. Menerima Offer dan Mengirim Answer: Jika client menerima pesan offer dari signaling server, ia akan membuat RTCPeerConnection (jika belum ada), mengatur remoteDescription dengan Offer yang diterima, lalu membuat answer. Answer ini diatur sebagai localDescription dan dikirim kembali ke pengirim Offer melalui signaling server.
  5. ICE Candidates: Selama proses koneksi, RTCPeerConnection akan menghasilkan ICE Candidates (melalui event onicecandidate). Setiap kandidat ini dikirim ke signaling server, yang kemudian meneruskannya ke peer yang dituju. Peer yang menerima kandidat akan menambahkannya ke RTCPeerConnection mereka dengan addIceCandidate().
  6. Remote Stream: Setelah koneksi WebRTC berhasil dibuat, ontrack event di RTCPeerConnection akan aktif ketika ada stream dari peer lain. Stream ini kemudian diatur sebagai srcObject dari remoteVideo.

⚠️ Penting: Untuk demo ini, kita menggunakan STUN server publik Google. Untuk aplikasi produksi, Anda mungkin memerlukan TURN server untuk mengatasi firewall dan NAT yang lebih kompleks.

6. Tips dan Best Practices untuk Signaling Server

Meskipun server di atas berfungsi untuk demo, ada beberapa hal yang perlu dipertimbangkan untuk aplikasi produksi: