npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

webrtc-call-kit

v0.2.0

Published

Developer-friendly abstraction layer over WebRTC for 1:1 audio/video calls with screen sharing and chat

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 peerjs

Quick 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

  1. Enable debug mode
  2. Check browser console for errors
  3. Verify network connectivity
  4. Test with different browsers
  5. Check WebRTC internals (chrome://webrtc-internals/)

License

MIT

Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

Ensure all tests pass and follow the existing code style.