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

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.

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-chat
import { 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 the iceServers option:
    new RTCVideoChat({
      /* ... */
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'turn:your-turn-server.example.com:3478', username: '...', credential: '...' },
      ],
    });
    Twilio, Cloudflare, and Xirsys all sell hosted TURN if you don't want to run one.
  • Permissions: if getUserMedia fails (denied permission, no camera), the library still joins in listen/watch-only mode and fires an error event 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 lightweight media-state broadcast 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 throw InvalidStateError: ... 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 join and 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 lower userId always initiates; the other silently defers and waits for their offer instead. This is one more reason userId needs 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 underlying RTCPeerConnection failing/closing, and after a short grace period on disconnected (to ride out brief network blips without dropping the tile).
  • userId must be stable across reconnects. It's how the library tells "this is the same person reconnecting" apart from "this is a new participant": if a join/offer arrives 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 if userId is 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 its RTCVideoChat instance — 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 requires userId and 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 (call destroy() 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 tabs

Publishing 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