webrtc-call-kit
v0.2.0
Published
Developer-friendly abstraction layer over WebRTC for 1:1 audio/video calls with screen sharing and chat
Maintainers
Readme
CallKit - WebRTC Abstraction Library
A developer-friendly abstraction layer over WebRTC for 1:1 audio/video calls in the browser. Built on PeerJS with a clean, type-driven API that models calls as first-class domain concepts.
Features
- 🎯 Type-driven design - Illegal states are unrepresentable
- 🔄 Call as state machine - Explicit states with valid transitions
- 📸 Immutable state snapshots - Consumers receive readonly state
- ✅ Fully typed events - Pattern-matchable event system
- 🎛️ Media management - Decoupled from call lifecycle
- 🖥️ Screen sharing - Built-in screen capture with seamless switching
- 💬 In-call chat - Real-time messaging via WebRTC data channels
- 🐛 Debug friendly - Comprehensive logging and error types
- ⚡ 1:1 calls only - Focused scope, no group call complexity
- 🌐 Browser-only - Optimized for web applications
Installation
npm install @yourscope/call-kit peerjsQuick Start
import { CallClient, createMediaController } from '@yourscope/call-kit'
// Initialize the call client
const client = await CallClient.create({ debug: true })
console.log('My peer ID:', client.peerId)
// Set up media
const mediaController = createMediaController(true)
const mediaResult = await mediaController.acquire({ audio: true, video: true })
if (mediaResult.ok) {
// Make a call
const call = client.call('remote-peer-id', mediaResult.value)
call.on('state-changed', (state) => {
console.log('Call state:', state.type)
})
} else {
console.error('Failed to acquire media:', mediaResult.error)
}
// Handle incoming calls
client.on('incoming-call', (call) => {
console.log('Incoming call from:', call.remotePeerId)
// Auto-accept (or show UI to user)
call.accept(mediaResult.value)
// Enable chat when connected
call.on('state-changed', (state) => {
if (state.type === 'connected') {
const chat = call.enableChat()
if (chat) {
chat.sendMessage('Hello from the call!')
chat.on('message-received', (message) => {
console.log('Received message:', message.content)
})
}
}
})
})
// Cleanup when done
client.destroy()
mediaController.release()API Reference
CallClient
The main entry point for managing peer connections and creating calls.
// Create client
const client = await CallClient.create({
peerId: 'my-custom-id', // Optional: custom peer ID
debug: true, // Optional: enable debug logging
timeout: 30000, // Optional: call timeout in ms
peerOptions: {} // Optional: PeerJS options
})
// Properties
client.peerId // Your peer ID for sharing
// Methods
client.call(remotePeerId, localMedia) // Create outgoing call
client.destroy() // Cleanup all resources
// Events
client.on('incoming-call', (call) => {})
client.on('error', (error) => {})
client.on('signaling-state-changed', (state) => {})Call
Represents a single 1:1 call with state machine management.
// State inspection
call.getState() // Get current call state
call.remotePeerId // Remote peer identifier
// Actions (state-dependent)
call.accept(localMedia) // Accept incoming call
call.reject() // Reject incoming call
call.cancel() // Cancel outgoing call
call.hangup() // End active call
// Media controls (when connected)
call.muteAudio()
call.unmuteAudio()
call.enableVideo()
call.disableVideo()
// Screen sharing (when connected)
call.startScreenShare(screenMedia) // Replace camera with screen
call.stopScreenShare(cameraMedia) // Switch back to camera
// Chat functionality
call.enableChat() // Enable chat for this call
call.getChatController() // Get existing chat controller
// Events
call.on('state-changed', (state) => {})
call.on('remote-track-added', (track) => {})
call.on('remote-track-removed', (track) => {})
call.on('screen-share-started', (track) => {})
call.on('screen-share-stopped', () => {})
call.on('remote-screen-share-started', (track) => {})
call.on('remote-screen-share-stopped', () => {})
call.on('error', (error) => {})MediaController
Standalone media management for local audio/video streams.
const mediaController = createMediaController(debug)
// Acquire media
const result = await mediaController.acquire({
audio: true,
video: { width: 640, height: 480 }
})
// Device management
const devices = await mediaController.getDevices()
await mediaController.setAudioDevice(deviceId)
await mediaController.setVideoDevice(deviceId)
// Screen sharing
const screenResult = await mediaController.acquireScreen({
video: true,
audio: true // Include system audio
})
// Switching between media types
await mediaController.switchToCamera()
await mediaController.switchToScreen()
// Media type detection
const currentType = mediaController.getCurrentMediaType() // 'camera' | 'screen' | 'audio-only'
const isScreenSharing = mediaController.isScreenSharing()
// Controls
mediaController.muteAudio()
mediaController.unmuteAudio()
mediaController.enableVideo()
mediaController.disableVideo()
// Cleanup
mediaController.release()
// Events
mediaController.on('device-changed', (devices) => {})
mediaController.on('screen-share-ended', () => {})
mediaController.on('media-type-changed', (type) => {})ChatController
Real-time messaging during calls via WebRTC data channels.
// Get chat controller from a call
const chat = call.enableChat()
// Send messages
chat.sendMessage('Hello!')
// Typing indicators
chat.startTyping()
chat.stopTyping()
// Message history and state
const state = chat.getState()
console.log(state.messages) // All messages
console.log(state.unreadCount) // Unread message count
console.log(state.isTyping) // Is remote user typing
// Mark messages as read
chat.markAsRead()
// Events
chat.on('message-received', (message) => {})
chat.on('message-sent', (message) => {})
chat.on('message-status-changed', (messageId, status) => {})
chat.on('typing-changed', (event) => {})
chat.on('connection-state-changed', (state) => {})
// Cleanup
chat.destroy()Call State Machine
Calls follow a strict state machine with typed states:
┌─────────┐
│ idle │
└─────────┘
│ call()
▼
┌─────────────┐ timeout/ ┌─────────┐
│ outgoing │ ───cancelled──► │ ended │
└─────────────┘ └─────────┘
│ answered ▲
▼ │
┌─────────────┐ failed │
│ connecting │ ───────────────────►│
└─────────────┘ │
│ connected │
▼ │
┌─────────────┐ hangup │
│ connected │ ───────────────────►│
└─────────────┘ │
│
┌─────────────┐ reject │
│ incoming │ ───────────────────►│
└─────────────┘ │
│ accept │
└─────────────────────────────►│State Types
type CallState =
| { type: 'idle' }
| { type: 'outgoing'; remotePeerId: string; startedAt: number }
| { type: 'incoming'; remotePeerId: string; receivedAt: number }
| { type: 'connecting'; remotePeerId: string }
| { type: 'connected'; remotePeerId: string; connectedAt: number; remoteStream: MediaStream }
| { type: 'ended'; remotePeerId: string; reason: CallEndReason; endedAt: number; duration?: number }Error Handling
All errors are typed and can be pattern-matched:
import { isResultError } from '@yourscope/call-kit'
const result = await mediaController.acquire({ audio: true, video: true })
if (isResultError(result)) {
switch (result.error.type) {
case 'permission-denied':
console.log('User denied camera/microphone permission')
break
case 'device-not-found':
console.log('Requested device not available')
break
case 'media-error':
console.log('Media system error:', result.error.reason)
break
default:
console.log('Unknown error:', result.error)
}
}
// Call errors
call.on('error', (error) => {
switch (error.type) {
case 'peer-unavailable':
console.log('Remote peer not reachable')
break
case 'connection-failed':
console.log('WebRTC connection failed')
break
case 'timeout':
console.log('Call timed out')
break
// ... handle other error types
}
})Common Patterns
Screen Sharing Implementation
import { CallClient, createMediaController } from '@yourscope/call-kit'
const client = await CallClient.create({ debug: true })
const mediaController = createMediaController(true)
// Start with camera
const cameraResult = await mediaController.acquire({ audio: true, video: true })
const call = client.call('remote-peer-id', cameraResult.value)
call.on('state-changed', async (state) => {
if (state.type === 'connected') {
// User clicks "Share Screen" button
document.getElementById('shareScreen').onclick = async () => {
const screenResult = await mediaController.switchToScreen()
if (screenResult.ok) {
call.startScreenShare(screenResult.value)
}
}
// User clicks "Stop Sharing" button
document.getElementById('stopSharing').onclick = async () => {
const cameraResult = await mediaController.switchToCamera()
if (cameraResult.ok) {
call.stopScreenShare(cameraResult.value)
}
}
}
})
// Handle remote screen sharing
call.on('remote-screen-share-started', (track) => {
const remoteVideo = document.getElementById('remote-video')
remoteVideo.srcObject = new MediaStream([track])
// Show screen share indicator
document.getElementById('screen-indicator').style.display = 'block'
})
call.on('remote-screen-share-stopped', () => {
// Hide screen share indicator
document.getElementById('screen-indicator').style.display = 'none'
})Chat Integration
// Enable chat when call connects
call.on('state-changed', (state) => {
if (state.type === 'connected') {
const chat = call.enableChat()
if (!chat) return
// Handle incoming messages
chat.on('message-received', (message) => {
displayMessage(message.content, message.senderId)
// Show notification if user is not focused on chat
if (!isChatFocused()) {
showNotification(`New message: ${message.content}`)
}
})
// Handle typing indicators
chat.on('typing-changed', (event) => {
if (event.isTyping) {
showTypingIndicator(event.senderId)
} else {
hideTypingIndicator(event.senderId)
}
})
// Send message on Enter key
const messageInput = document.getElementById('message-input')
messageInput.onkeypress = (e) => {
if (e.key === 'Enter') {
const message = messageInput.value.trim()
if (message) {
chat.sendMessage(message)
displayMessage(message, 'You')
messageInput.value = ''
}
}
}
// Handle typing events
messageInput.oninput = () => {
chat.startTyping()
}
}
})
function displayMessage(content: string, sender: string) {
const chatContainer = document.getElementById('chat-messages')
const messageElement = document.createElement('div')
messageElement.innerHTML = `<strong>${sender}:</strong> ${content}`
chatContainer.appendChild(messageElement)
chatContainer.scrollTop = chatContainer.scrollHeight
}Building a Call UI
interface CallUIState {
status: 'idle' | 'calling' | 'incoming' | 'connected'
remotePeerId?: string
duration?: number
localVideo?: HTMLVideoElement
remoteVideo?: HTMLVideoElement
}
function updateUI(callState: CallState): CallUIState {
switch (callState.type) {
case 'idle':
return { status: 'idle' }
case 'outgoing':
return {
status: 'calling',
remotePeerId: callState.remotePeerId
}
case 'incoming':
return {
status: 'incoming',
remotePeerId: callState.remotePeerId
}
case 'connecting':
return {
status: 'calling',
remotePeerId: callState.remotePeerId
}
case 'connected':
return {
status: 'connected',
remotePeerId: callState.remotePeerId,
duration: Date.now() - callState.connectedAt
}
case 'ended':
return { status: 'idle' }
}
}Handling Remote Media
call.on('remote-track-added', (track) => {
const remoteVideo = document.getElementById('remote-video') as HTMLVideoElement
if (!remoteVideo.srcObject) {
remoteVideo.srcObject = new MediaStream()
}
(remoteVideo.srcObject as MediaStream).addTrack(track)
})
call.on('remote-track-removed', (track) => {
const remoteVideo = document.getElementById('remote-video') as HTMLVideoElement
const stream = remoteVideo.srcObject as MediaStream
if (stream) {
stream.removeTrack(track)
}
})Device Selection UI
const devices = await mediaController.getDevices()
const cameras = devices.filter(d => d.kind === 'videoinput')
const microphones = devices.filter(d => d.kind === 'audioinput')
// Populate select elements
cameras.forEach(camera => {
const option = document.createElement('option')
option.value = camera.deviceId
option.text = camera.label
cameraSelect.appendChild(option)
})
// Handle device changes
cameraSelect.onchange = async () => {
const result = await mediaController.setVideoDevice(cameraSelect.value)
if (!result.ok) {
console.error('Failed to change camera:', result.error)
}
}Debugging
Enable debug mode to see detailed logging:
const client = await CallClient.create({ debug: true })
const mediaController = createMediaController(true)This will log:
- State transitions
- PeerJS events
- ICE connection states
- Media acquisition/release
- Device changes
- Error details
Browser Support
- Chrome 80+ (recommended)
- Firefox 75+
- Safari 13+
- Edge 80+
WebRTC requires HTTPS in production (except localhost).
Troubleshooting
Common Issues
"Permission denied" errors
- Ensure HTTPS in production
- Check browser permission settings
- Verify user interaction before requesting media
"Peer unavailable" errors
- Verify both peers are connected to same PeerJS server
- Check firewall/NAT configuration
- Ensure peer IDs are correct
ICE connection failures
- May need STUN/TURN servers for production
- Check network connectivity
- Corporate firewalls may block WebRTC
Audio/video not flowing
- Verify media constraints
- Check device permissions
- Ensure tracks are enabled
- Check for track ended events
Debug Checklist
- Enable debug mode
- Check browser console for errors
- Verify network connectivity
- Test with different browsers
- Check WebRTC internals (chrome://webrtc-internals/)
License
MIT
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Ensure all tests pass and follow the existing code style.
