rtc-video-chat
v0.4.0
Published
A small, dependency-free WebRTC library for mesh video calls in a shared room, with pluggable signaling and a flex-grid UI out of the box.
Maintainers
Readme
rtc-video-chat
A small, framework-free WebRTC library for putting a few users into a video call keyed off a room code you already have (a UUID, a session id, whatever). Mesh topology — fine for small groups (roughly up to 4–6 people); it doesn't scale to large rooms since every participant connects directly to every other participant.
Install
npm install rtc-video-chatimport { RTCVideoChat, BroadcastChannelSignalingAdapter } from 'rtc-video-chat';
import 'rtc-video-chat/style.css';Quick start
<div id="video-stage" style="width: 800px; height: 500px;"></div>
<script type="module">
import { RTCVideoChat } from 'rtc-video-chat';
import { MySignalingAdapter } from './my-signaling-adapter.js'; // see below
const chat = new RTCVideoChat({
container: document.getElementById('video-stage'),
roomCode: currentSession.roomCode, // your UUID
userName: currentUser.name,
userId: currentUser.id, // stable, persistent id -- see note below
signalingAdapter: new MySignalingAdapter(currentSession.roomCode),
});
chat.addEventListener('peer-joined', (e) => console.log(e.detail.userName, 'joined'));
chat.addEventListener('peer-left', (e) => console.log(e.detail.userId, 'left'));
await chat.join();
// later
chat.toggleMic();
chat.toggleVideo();
chat.leave();
</script>Why you have to plug in signaling
WebRTC peers need to exchange an SDP offer/answer and ICE candidates before a direct connection exists — and that handshake has to travel over some channel that isn't WebRTC itself. There's no standard for this by design; every app already has a backend suited to relaying a few small JSON messages (websockets, Firebase Realtime DB, Supabase Realtime, Pusher, Ably, etc.). Rather than pick one and lock you in, the library talks to a tiny adapter interface:
class MySignalingAdapter {
// Deliver `message` to every other client currently in the same room.
send(message) { /* ... */ }
// Register a handler to be called for every message from other clients
// in the room (your own messages don't need to be filtered out — the
// library ignores messages where `from` is its own id).
onMessage(handler) { /* ... */ }
// Optional cleanup when the call ends.
disconnect() { /* ... */ }
}message looks like { id, roomCode, from, to, type, payload }. to is
either a specific user's id (targeted) or null/omitted (broadcast to the
room). id is a fresh random id per outgoing message that the library uses
to silently drop a message your adapter happens to deliver more than once —
e.g. because of a duplicate underlying connection, or a server that
double-emits. Your adapter doesn't need to do anything special with it, just
relay it like every other field. Your adapter doesn't need to understand any
of these fields — just make sure messages are scoped to roomCode and
relayed to the other participants.
Example: a real WebSocket adapter
Assuming a server that puts each socket into a "room" matching roomCode and
relays any JSON it receives to the other sockets in that room:
export class WebSocketSignalingAdapter {
constructor(roomCode, wsUrl) {
this.roomCode = roomCode;
this.ws = new WebSocket(`${wsUrl}?room=${encodeURIComponent(roomCode)}`);
this.ready = new Promise((resolve) => (this.ws.onopen = resolve));
}
async send(message) {
await this.ready;
this.ws.send(JSON.stringify(message));
}
onMessage(handler) {
this.ws.onmessage = (event) => handler(JSON.parse(event.data));
}
disconnect() {
this.ws.close();
}
}The included BroadcastChannelSignalingAdapter works the same way but uses
the browser's BroadcastChannel API, which only relays messages between tabs
of the same browser — great for local development, useless across two
users' machines. Swap it for a real adapter before shipping.
Production notes
- TURN server: the default only configures a public STUN server
(
stun:stun.l.google.com:19302), which is enough to discover a peer's public address but won't help users behind symmetric NATs or strict corporate firewalls. For reliable connections in the wild, add a TURN server via theiceServersoption:
Twilio, Cloudflare, and Xirsys all sell hosted TURN if you don't want to run one.new RTCVideoChat({ /* ... */ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'turn:your-turn-server.example.com:3478', username: '...', credential: '...' }, ], }); - Permissions: if
getUserMediafails (denied permission, no camera), the library still joins in listen/watch-only mode and fires anerrorevent rather than throwing, so one user's hardware issue doesn't block the room. - Room size: mesh means each participant opens N-1 peer connections and uploads their stream N-1 times. Fine for small groups; if you expect larger groups you'd want an SFU (e.g. LiveKit, mediasoup) instead — a bigger lift and out of scope for this library.
API reference
new RTCVideoChat(options)
| option | type | required | description |
|---|---|---|---|
| container | HTMLElement | yes | Element the grid + controls render into. |
| roomCode | string | yes | Shared code identifying the group. |
| userName | string | yes | Display name, also used for the avatar placeholder. |
| userId | string | yes | Stable identifier for this user (e.g. your app's persistent user/account id). Must not change across reconnects — see note below. |
| signalingAdapter | object | yes | See interface above. |
| iceServers | RTCIceServer[] | no | Defaults to public STUN only. |
| audio | boolean | no | Request microphone (default true). |
| video | boolean | no | Request camera (default true). |
Methods
await chat.join()— requests media, connects, renders own tile.chat.leave()— disconnects, stops tracks, notifies peers.chat.toggleMic()→boolean— mutes/unmutes; returns new state.chat.toggleVideo()→boolean— camera on/off, swaps to avatar placeholder; returns new state.chat.destroy()—leave()plus removes all DOM this instance created.chat.userId— this session's generated id (readonly getter).
Events (via chat.addEventListener(name, e => ...), e.detail has the data)
joined—{ userId }left—{}peer-joined—{ userId, userName }peer-left—{ userId }mic-toggled—{ enabled }video-toggled—{ enabled }error—{ message, error }
Behavior notes
- Each user has a tile that shows live video when a camera stream is present and enabled, and otherwise a colored circle with their initials and name — including for users who never granted camera access at all.
- Your own tile is mirrored (flipped horizontally) for a natural selfie-view; this is display-only and doesn't affect the stream other users receive.
- Muting audio/video is track-level (
track.enabled), so it's instant with no renegotiation; a lightweightmedia-statebroadcast tells other peers to swap that user's tile to the avatar rather than show a frozen frame. - Duplicate message delivery is handled automatically. Every outgoing
message carries a unique id; if your adapter ever delivers the same
message more than once — a duplicate underlying connection, a server that
double-emits, network-level retries — the repeat is silently dropped
rather than reprocessed. This matters more than it might sound: a
double-processed
answer, for instance, can throwInvalidStateError: ... Called in wrong state: stable, or in a worse case silently apply the wrong answer to a connection that's still legitimately waiting for one. If you're seeing connection errors like that, it's worth checking whether the client actually has two live signaling connections open at once (e.g. a websocket reconnect that didn't clean up the previous connection) — the dedup here makes the library resilient to it, but a duplicate transport connection is usually worth fixing at the source too. - Simultaneous joins are resolved deterministically. If two users join
within the same short window, each can hear the other's
joinand momentarily think of themselves as "already here, the other is new" — which could otherwise lead both sides to send an offer at once (real glare, not a duplicate message, so the id-based dedup above doesn't catch it). The library breaks the tie by userId: whichever user has the lexicographically loweruserIdalways initiates; the other silently defers and waits for their offer instead. This is one more reasonuserIdneeds to be a real, stable value rather than something regenerated per attempt — the tie-break only works if both sides agree on it consistently. - Peer connections clean up automatically on
leave, on the underlyingRTCPeerConnectionfailing/closing, and after a short grace period ondisconnected(to ride out brief network blips without dropping the tile). userIdmust be stable across reconnects. It's how the library tells "this is the same person reconnecting" apart from "this is a new participant": if ajoin/offerarrives for an id it already has an entry for, it tears down the stale connection and rebuilds fresh rather than creating a duplicate. This only works ifuserIdis sourced from something durable on your side — a persistent account/user id. Do not generate it fresh inside your component/constructor logic (e.g.crypto.randomUUID()called each time you build the options object); any time your app then recreates itsRTCVideoChatinstance — a reactive re-render, a retry after a dropped connection, anything — the library would see what looks like a brand-new person and produce duplicate tiles for the same physical user. This was common enough in practice that the library now requiresuserIdand throws if it's missing, rather than silently generating one for you. It's still worth also making sure your own integration only ever has one live instance at a time (calldestroy()on any previous instance before creating a new one, and tear down on unmount) — a stable id makes the library resilient to slip-ups there, it doesn't replace clean lifecycle management on the consuming side.
TypeScript
The library ships as plain JavaScript with a hand-written .d.ts shim
(rtc-video-chat.d.ts), so no @types/ package is needed — import just
works with full autocomplete and typed events:
import { RTCVideoChat, type SignalingAdapter } from 'rtc-video-chat';
chat.addEventListener('peer-joined', (e) => {
e.detail.userName; // typed as string
});Try it locally
demo/demo.html is a working example using BroadcastChannelSignalingAdapter
(no backend required — open it in two browser tabs). It's excluded from the
published npm package; clone the repo to use it. Since it uses ES module
imports, serve it over HTTP rather than opening the file directly:
npx serve .
# then open http://localhost:<port>/demo/demo.html in two tabsPublishing checklist (for maintainers)
Before running npm publish, fill in the placeholders in package.json
(author, homepage, repository.url, bugs.url) and in LICENSE
([Your Name]). Then:
npm login
npm publish