callway
v1.1.0
Published
A lightweight WebRTC call engine for building real-time audio and video calls with flexible, pluggable signaling.
Maintainers
Readme
Callway
A lightweight, framework-agnostic WebRTC engine with pluggable signaling. Zero runtime dependencies; you bring your own signaling backend.
Overview
- Mesh-ready multi-peer engine with perfect-negotiation-style collision handling.
- Pluggable signaling adapters (interface-driven). Works with WebSocket, Firebase, Supabase, Redis, custom APIs, etc.
- Optional React hooks.
- TypeScript-first, ESM.
- No bundled runtime deps.
Installation
npm install callway
# optional React hooks
npm install callway reactCore API (quick start, vanilla JS style)
import { PeerManager, MediaManager } from 'callway';
import { SignalingAdapter } from 'callway/src/signaling/SignalingAdapter'; // interface shape
// Minimal adapter (fill with your backend wiring: WebSocket/Firebase/etc.)
const signaling = {
registerPeer(peerId, handler, roomId) {
// TODO: subscribe to your backend and call handler(message) on incoming
},
unregisterPeer(peerId) {},
async sendMessage(message) {
// TODO: send via your backend transport
},
cleanup() {}
};
const peerManager = new PeerManager('peer-a');
const mediaManager = new MediaManager();
peerManager.setSignalingAdapter(signaling, 'room-1');
// Simple DOM helpers
const remoteContainer = document.getElementById('remotes');
function renderRemote(remoteId, stream) {
let video = remoteContainer.querySelector(`[data-peer="${remoteId}"]`);
if (!video) {
video = document.createElement('video');
video.setAttribute('data-peer', remoteId);
video.autoplay = true;
video.playsInline = true;
video.muted = false;
remoteContainer.appendChild(video);
}
video.srcObject = stream;
}
// Handle remote media
peerManager.onRemoteStream((remoteStream, remoteId) => {
console.log('remote stream from', remoteId);
renderRemote(remoteId, remoteStream);
});
// Get local media, attach, and start call
const localStream = await mediaManager.getUserMedia({ audio: true, video: true });
document.getElementById('local').srcObject = localStream;
// Add a peer and connect (assume peer-b exists on the same signaling backend)
await peerManager.addPeer('peer-b');
mediaManager.attachToPeer(peerManager, 'peer-b');
await peerManager.createOffer('peer-b');Signaling adapter interface
Implement to use any backend (WebSocket, Firebase, etc.):
import type { SignalingAdapter } from 'callway';
interface SignalingAdapter {
registerPeer(peerId: string, handler: SignalingHandler, roomId?: string): void;
unregisterPeer(peerId: string): void;
sendMessage(message: SignalingMessage): Promise<void>;
broadcastToRoom?(from: string, roomId: string, messageType: string, data: any): Promise<void>;
cleanup(): void;
}Swapping signaling backends
- Custom backend: implement
SignalingAdapterand pass topeerManager.setSignalingAdapter(adapter, roomId?). - Example scaffolds (external; add deps yourself):
examples/FirebaseSignalingAdapter.tsexamples/test-group-firebase.ts
- Sample app (Next.js + Firebase signaling): https://github.com/forexlord/callway-firebase.git
Multi-peer (mesh) usage tips
- Each remote peer gets its own
RTCPeerConnection. - Lower peerId initiates offers; higher peerId acts “polite” and rolls back on collisions.
- ICE candidates are queued until remote descriptions are set; duplicates are ignored.
React usage (optional)
import { useEffect, useMemo, useState } from 'react';
import { PeerManager, MediaManager } from 'callway';
import type { SignalingAdapter } from 'callway/src/signaling/SignalingAdapter';
import { useIsCameraOff, useIsMicrophoneOff, useMediaState } from 'callway/react';
// Your adapter
class MySignalingAdapter implements SignalingAdapter {
registerPeer() {}
unregisterPeer() {}
async sendMessage() {}
cleanup() {}
}
export function CallApp() {
const [stream, setStream] = useState<MediaStream | null>(null);
const peerManager = useMemo(() => new PeerManager('peer-a'), []);
const mediaManager = useMemo(() => new MediaManager(), []);
useEffect(() => {
const adapter = new MySignalingAdapter();
peerManager.setSignalingAdapter(adapter, 'room-1');
peerManager.onRemoteStream((remote, id) => {
console.log('remote', id, remote);
});
mediaManager.getUserMedia({ audio: true, video: true })
.then((s) => {
setStream(s);
mediaManager.attachToPeer(peerManager, 'peer-b');
return peerManager.addPeer('peer-b');
})
.then(() => peerManager.createOffer('peer-b'))
.catch(console.error);
return () => {
peerManager.cleanup();
mediaManager.cleanup();
adapter.cleanup();
};
}, []);
const isCamOff = useIsCameraOff(stream);
const isMicOff = useIsMicrophoneOff(stream);
const state = useMediaState(stream);
return (
<div>
<div>Camera: {isCamOff ? 'Off' : 'On'}</div>
<div>Mic: {isMicOff ? 'Off' : 'On'}</div>
<div>Tracks: A{state.microphone.available ? 1 : 0} / V{state.camera.available ? 1 : 0}</div>
</div>
);
}Production readiness
- Core engine: dependency-free, ESM, TypeScript, mesh-ready with perfect negotiation, ICE queuing, and collision handling.
- Signaling: pluggable; you must supply a production signaling adapter (WebSocket/Firebase/Supabase/custom).
- Media: uses native WebRTC (getUserMedia/RTCPeerConnection). No media servers included.
- Testing: use your own lightweight harness or the external examples; swap in your adapter for end-to-end testing.
License
ISC
