Skip to content

meefik/peerix

Repository files navigation

Peerix

Peerix is a TypeScript library for peer-to-peer room-based media and data sharing over WebRTC. It abstracts WebRTC complexity into a simple API, handling peer discovery and connection management through pluggable signaling drivers without vendor lock-in.

Try it out: Peerix Talk | Source

Read the full documentation and access the source code:

Features

  • Pluggable signaling drivers — MQTT, NATS, SSE, SocketIO, Centrifuge, Supabase, BroadcastChannel, Memory — or write your own
  • Room & state management out of the box for complex peer-to-peer applications
  • Single peer connection — multiplexed media streams and data channels over one RTCPeerConnection
  • End-to-end encryption & compression for all signaling data (enabled by default)
  • Zero dependencies, fully typed, cross-browser compatible

Quick Start

Install the library:

npm install peerix

Create a room, listen for messages, and join:

import { Room, BroadcastChannelDriver } from "peerix";

const driver = new BroadcastChannelDriver();
const room = new Room({ id: "my-room", driver });

room.on("channel:message", async (e) => {
  const message = await e.data;
  console.log(`Message from ${e.peer.id}:`, message);
});

await room.join({ name: "Alice" });

room.on("channel:open", (e) => {
  e.peer.send("Hello!", { label: e.label });
});

await room.open({ label: "chat" });

The room id must be the same for all peers that want to connect with each other. BroadcastChannelDriver works across tabs in the same browser; see Signaling Drivers for cross-device options.

Signaling Drivers

Drivers handle peer discovery and initial connection setup. After the handshake, each peer connection negotiates its own data channel for signaling, so the external backend is only needed during discovery. Messages use a compact binary format with compression and end-to-end encryption.

Built-in Drivers

  • MemoryDriver — single-page testing (no backend required)
  • BroadcastChannelDriver — multi-tab communication (same browser origin)
  • NatsDriver — distributed / performance (NATS)
  • MqttDriver — lightweight broker (MQTT broker)
  • CentrifugeDriver — real-time messaging (Centrifuge)
  • SseDriver — Server-Sent Events + POST (Mercure)
  • SupabaseDriver — database + real-time (Supabase)
  • SocketIoDriver — WebSocket-based signaling (Socket.IO server)

If no driver is specified, the default is MemoryDriver, which only works within a single page. Drivers require their respective client libraries to be installed by you — Peerix itself has zero runtime dependencies.

Custom Driver

Extend Driver and implement three methods:

import { Driver } from "peerix";

class MyDriver extends Driver {
  async subscribe(namespace, handler) {
    /* listen for messages */
  }
  async unsubscribe(namespace, handler) {
    /* stop listening */
  }
  async publish(namespace, message) {
    /* send binary message as number array */
  }
}

Example: NATS Driver

import { NatsDriver } from "peerix";
import { wsconnect } from "@nats-io/nats-core";

const nc = await wsconnect({
  servers: ["wss://demo.nats.io:8443"],
  noEcho: true,
});

const driver = new NatsDriver({ nc });

Room Configuration

Create a Room and configure connection behavior:

import { Room } from "peerix";

const room = new Room({
  id: "my-room",
  driver,
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});

await room.join();

Options

  • id — room identifier (default: "default")
  • driver — signaling driver instance (default: MemoryDriver)
  • iceServers — STUN/TURN servers for NAT traversal (default: [])
  • iceTransportPolicy — set to "relay" to enforce TURN-only connectivity (default: "all")
  • connectionTimeout — seconds before disconnecting failed peers, 0 disables (default: 15)
  • iceCandidateDebounce — milliseconds to batch ICE candidates (default: 50)
  • namespaceHashing — hash room namespaces in signaling for privacy (default: true)
  • signalingCompression — compress signaling messages (default: true)
  • signalingEncryption — encrypt signaling with AES-GCM (default: true)
  • verify — callback to accept or reject incoming peers

Peerix handles collision resolution internally when two peers initiate connection simultaneously, using a polite/rude negotiation strategy.

Accessing Peers via room.peers

After joining, connected peers are available through room.peers, a read-only Map<string, Peer> indexed by peer ID. Each Peer exposes:

  • id — unique identifier (the Map key)
  • metadata — optional metadata shared at join time
  • state — connection state (new, connecting, connected, disconnected, failed, closed)
  • connection — the underlying RTCPeerConnection
  • send() — direct messaging to that peer (bypasses broadcast)
  • share() / unshare() — per-peer stream control
  • on() / off() — subscribe to peer-specific events
// iterate over all connected peers
for (const [id, peer] of room.peers) {
  if (peer.state === "connected") {
    console.log(`Peer ${id} is online`);
  }
}

// look up a specific peer by ID and send directly
const target = room.peers.get(somePeerId);
if (target && target.metadata?.name) {
  await target.send(`Hello, ${target.metadata.name}!`, { label: "chat" });
}

Data Channels

Data channels provide reliable messaging between peers. Open a channel with room.open(), send messages with room.send() (broadcast) or peer.send() (direct), and close with room.close():

await room.open({ label: "chat" });

room.on("channel:message", async (e) => {
  const message = await e.data;
  console.log(`Msg from ${e.peer.id}:`, message);
});

Messages are automatically decoded based on the sender's type: strings, JSON objects, Blobs, or raw bytes. Use to to target specific peers instead of broadcasting:

await room.send("Hello!", { label: "chat", to: "peer.id" });

File Transfer with Progress Tracking

send() returns a stream you can iterate over to track upload progress. On the receiving side, binary data arrives as chunks:

// sender
const transfer = room.send(file, {
  label: "chat",
  info: { name: file.name, size: file.size },
  signal: AbortSignal.timeout(60_000),
});

for await (const p of transfer) {
  console.log(`${Math.round((p.current / p.total) * 100)}%`);
}
// receiver — reassemble file from chunks
room.on("channel:message", async (e) => {
  const chunks = [];
  for await (const chunk of e.data) chunks.push(chunk);
  const file = new File(chunks, e.info?.name);
  console.log(file);
});

Media Streams

Share camera, microphone, screen capture, or any MediaStream with peers using room.share(). Each stream is identified by a label:

const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true,
});
await room.share({ label: "camera", stream });

Listen for remote stream changes:

room.on("stream:add", (e) => {
  document.getElementById("remote-video").srcObject = e.stream;
});

room.on("stream:remove", (e) => {
  console.log(`${e.peer.id} stopped sharing`);
});

Call share() again with the same label to replace a stream. Old tracks are removed and new ones added automatically across all connected peers. Call unshare() to stop sharing entirely:

await room.unshare({ label: "camera" });

Encoding Parameters

Control codec settings per stream:

await room.share({
  label: "camera",
  stream,
  audioParameters: { maxBitrate: 16_000 },
  videoParameters: { maxBitrate: 128_000, maxFramerate: 15 },
});

Addons

Addons are modular extensions that attach to a Room for additional functionality. Create custom addons by extending Addon:

import { Addon } from "peerix";

class MyAddon extends Addon {
  async attach(room) {
    /* setup */
  }
  async detach(room) {
    /* cleanup */
  }
}

const addon = new MyAddon();
await room.attach(addon);
// await room.detach(addon); // later, if needed

Lifecycle Events

Peerix emits typed events for all state changes. Subscribe using room.on() with a group prefix (e.g., "connection" matches any connection event) or a specific name:

Connection Events (connection:*)

  • :new — new peer detected
  • :connecting — negotiation in progress
  • :connected — peer connection established
  • :disconnected — peer disconnected (may reconnect)
  • :failed — connection attempt failed
  • :closed — connection permanently closed

Channel Events (channel:*)

  • :new — data channel created
  • :open — channel ready for messaging
  • :close — channel closed
  • :message — message received
  • :error — channel error occurred

Stream Events (stream:*)

  • :add — peer shared a media stream
  • :remove — peer stopped sharing a stream

Track Events (track:*)

  • :add — track added to a shared stream
  • :remove — track removed from a stream

The "error" event catches general signaling and connection failures. Subscribe with room.on("error", ...).

ICE Servers

STUN servers help peers discover their public address, while TURN servers relay traffic when direct connection is not possible. Use TURN for better connectivity in restrictive networks:

const room = new Room({
  id: "my-room",
  driver,
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" },
    {
      urls: "turn:turn.example.com:3478",
      username: "user",
      credential: "pass",
    },
  ],
});

Set iceTransportPolicy to "relay" to force traffic through TURN servers only, enforcing privacy in sensitive environments. No ICE servers limits connections to the local network.

Troubleshooting

Peers cannot connect across networks. — Add STUN/TURN servers via iceServers.

I get a SIGNALING_ERROR. — The driver failed to send or receive messages. Verify your signaling client is connected before creating the Room. Listen on room.on("error", ...) for details.

Camera/microphone access fails. — Ensure the page is served over HTTPS (or localhost). Check that no other tab holds an exclusive lock on the device.

send() throws before I can message a peer. — Wait for the channel:open event before calling peer.send(...).

Peers disconnect during negotiation. — Increase connectionTimeout (default 15 seconds), or set it to 0 to disable.

Two peers in the same room don't see each other. — Verify both use the same driver connected to the same backend, call room.join() on both sides, and that the room id matches exactly. BroadcastChannel only works within the same origin.

Signaling messages fail after disabling encryption. — Encryption is enabled by default. If you disable it (signalingEncryption: false), all peers in the room must use the same setting.

License

Copyright (C) 2026 Anton Skshidlevsky (aka meefik)

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

About

A JavaScript library for creating peer-to-peer WebRTC apps with puggable signaling drivers and add-ons

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages