Initiate, accept, and manage real-time audio and video calls with WebRTC signaling, all built on the LumenJS Communication module.
Initiate, accept, reject, and hang up calls. The server manages call state and relays events between participants.
Initiating a call:
// Start a video call with one or more users this.emit('call:initiate', { conversationId: 'conv-123', type: 'video', // 'audio' | 'video' calleeIds: ['user-456'], // one or more user IDs to ring });
Responding to an incoming call:
// push() sends: { event: 'call:incoming', data: Call } acceptCall(callId: string) { this.emit('call:respond', { callId, action: 'accept' }); } rejectCall(callId: string) { this.emit('call:respond', { callId, action: 'reject' }); }
Hanging up and toggling media:
// End the call this.emit('call:hangup', { callId: 'call-789', reason: 'completed' }); // Toggle audio/video/screen share this.emit('call:media-toggle', { callId: 'call-789', audio: false, // mute mic video: true, // keep camera on screenShare: false, // not sharing screen });
Call state machine:
idle → initiating → ringing → connecting → connected → ended | | | | | +→ reconnecting → connected | | | +-- (busy) → ended +-- (rejected) → ended +→ ended
End reasons:
| Reason | Description |
|---|---|
completed | Normal hangup after a connected call |
rejected | Callee declined the call |
missed | Callee did not answer in time |
busy | Callee is already in another call |
failed | Connection failure (network, ICE, etc.) |
cancelled | Caller hung up before callee answered |
Server events for calls:
| Event | Payload | When |
|---|---|---|
call:incoming | Call | Callee receives an incoming call |
call:state-changed | { callId, state, endReason? } | Call transitions to a new state |
call:participant-joined | { callId, participant } | A user accepted and joined the call |
call:participant-left | { callId, userId } | A participant hung up |
call:media-changed | { callId, userId, audio?, video?, screenShare? } | A participant toggled media tracks |
After a call is accepted, clients must exchange SDP offers/answers and ICE candidates to establish the peer-to-peer media connection. The server acts as a relay, it never inspects the media payloads.
Sending an SDP offer (caller side):
// 1. Create RTCPeerConnection const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); // 2. Add local media tracks const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); stream.getTracks().forEach(track => pc.addTrack(track, stream)); // 3. Create and send offer const offer = await pc.createOffer(); await pc.setLocalDescription(offer); this.emit('signal:offer', { callId: 'call-789', fromUserId: myUserId, toUserId: remoteUserId, type: 'offer', sdp: offer.sdp, }); // 4. Send ICE candidates as they are gathered pc.onicecandidate = (e) => { if (e.candidate) { this.emit('signal:ice-candidate', { callId: 'call-789', fromUserId: myUserId, toUserId: remoteUserId, candidate: e.candidate.candidate, sdpMLineIndex: e.candidate.sdpMLineIndex, sdpMid: e.candidate.sdpMid, }); } };
Receiving an offer and sending an answer (callee side):
// Listen for the offer (event + data are spread as properties) if (this.event === 'signal:offer') { const { sdp, fromUserId, callId } = this.data; await pc.setRemoteDescription({ type: 'offer', sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); this.emit('signal:answer', { callId, fromUserId: myUserId, toUserId: fromUserId, type: 'answer', sdp: answer.sdp, }); } // Apply incoming ICE candidates if (this.event === 'signal:ice-candidate') { const { candidate, sdpMLineIndex, sdpMid } = this.data; await pc.addIceCandidate({ candidate, sdpMLineIndex, sdpMid }); }
config.iceServers in createCommunicationHandler().
Signaling events reference:
| Client Event | Payload | Server Relays To |
|---|---|---|
signal:offer | SignalOffer | toUserId |
signal:answer | SignalOffer | toUserId |
signal:ice-candidate | SignalIceCandidate | toUserId |