Skip to content
meefikPublic

About

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

Topics

Resources

Stars

7 stars

Watchers

1 watching

Forks

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, Nostr, 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
  • Encryption & compression of signaling messages between peers (enabled by default)
  • Zero dependencies, fully typed, cross-browser compatible
  • 95% branch coverage enforced in CI

For AI Agents

Install the Peerix skill to get tailored help when coding with this library:

npx skills add meefik/peerix

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 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)
  • NostrDriver — decentralized relays, bring your own relay list (Nostr)
  • 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 subscribe, unsubscribe, and publish. This example wraps a generic pub/sub client:

import { Driver } from "peerix";

class MyDriver extends Driver {
  #client;
  #handlers = new Map(); // namespace -> Set of handlers
  #onConnect = () => (this.active = true);
  #onDisconnect = () => (this.active = false);

  constructor({ client }) {
    super();
    this.#client = client;
    client.on("connect", this.#onConnect);
    client.on("disconnect", this.#onDisconnect);
    this.active = client.connected;
  }

  async subscribe(namespace, handler) {
    super.subscribe(namespace, handler);
    let handlers = this.#handlers.get(namespace);
    if (!handlers) {
      handlers = new Set();
      this.#handlers.set(namespace, handlers);
      // One client subscription per namespace, shared by its handlers.
      await this.#client.subscribe(namespace, (data) => {
        this.#handlers.get(namespace)?.forEach((h) => h(data));
      });
    }
    handlers.add(handler);
  }

  async unsubscribe(namespace, handler) {
    super.unsubscribe(namespace, handler);
    const handlers = this.#handlers.get(namespace);
    if (!handlers?.delete(handler) || handlers.size) return;
    // Another room on this driver may still use the namespace.
    this.#handlers.delete(namespace);
    await this.#client.unsubscribe(namespace);
  }

  async publish(namespace, data) {
    super.publish(namespace, data);
    await this.#client.publish(namespace, data);
  }

  destroy() {
    super.destroy();
    this.#client.off("connect", this.#onConnect);
    this.#client.off("disconnect", this.#onDisconnect);
    this.#handlers.clear();
  }
}
  • Set this.active to reflect the connection. Messages are only published while the driver is active, and switching back to active makes joined rooms announce themselves again. A namespace that fails on its own shouldn't turn the driver inactive: retry it, and call this.emit("active") once it is subscribed again, so that rooms announce themselves to the peers they missed.
  • data is the encoded message as an array of byte values (number[]), so it survives JSON-based transports. Deliver the same array to the handler on the receiving side.
  • The super calls emit the driver's subscribe, unsubscribe, and publish events, as the built-in drivers do.
  • Several handlers may subscribe to the same namespace, for example two rooms with the same ID on one driver, so keep the transport subscription until the last one unsubscribes.
  • Release the client's listeners in destroy().

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();

join() resolves with the local peer ({ id, metadata }), also available as room.me. Calling it again while joined or joining resolves with the same peer.

In TypeScript, pass the metadata type to type join(), room.me, verify, and the metadata of every peer:

const room = new Room<{ name: string }>({ id: "my-room", driver });
await room.join({ name: "Alice" });
room.on("connection:connected", (e) => console.log(e.peer.metadata?.name));

Remote metadata comes from the network, so validate it in verify before relying on its type.

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 — milliseconds before disconnecting failed peers, 0 disables (default: 15000)
  • 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 between peers with AES-GCM (default: true). This hides messages from the signaling backend, but peer IDs are not authenticated, so it doesn't stop a backend that actively impersonates peers
  • verify — callback to accept or reject incoming peers
  • webrtc — RTCPeerConnection and MediaStream classes to use instead of the globals (see Node.js)

Peerix handles collision resolution internally when two peers initiate connection simultaneously, using perfect negotiation (polite/impolite peers).

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. e.type tells which one arrived ("text", "json", "blob", or "bytes"), so you can handle each kind before reading e.data:

room.on("channel:message", async (e) => {
  if (e.type === "blob") showFile(await e.data, e.info);
  else console.log(await e.data);
});

Use to to target specific peers instead of broadcasting:

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

A broadcast goes to every peer whose channel is open and skips peers that are still connecting. A send with to fails before anything is sent if any target has no open channel. A peer that fails mid-transfer doesn't stop the others, and the send rejects with its failure once every transfer ends. A transfer to a peer fails once the channel buffer stops draining for 10 seconds, however slow the link. Send failures are PeerixError instances with the DATACHANNEL_ERROR code.

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. Use info to describe the message, for example with a file name: it arrives with the first chunk, so the receiver can read it before the data. It is sent as JSON and must be smaller than one chunk (about 16 KB). Peer metadata, in contrast, describes a peer for its whole connection.

// 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);
});

Realtime Data (Pointer Positions)

A channel opened with a protocol is raw. Messages skip chunking and acknowledgments, so pairing it with ordered: false and maxRetransmits: 0 suits updates where only the latest value matters, such as shared cursors. send() takes only strings and binary data, and on an unreliable channel it drops a message while the buffer is backed up instead of delaying a fresher one. Receivers get e.raw === true and e.data as the payload itself (string or ArrayBuffer), not a stream:

await room.open({
  label: "pointer",
  protocol: "pointer-v1",
  ordered: false,
  maxRetransmits: 0,
});

// sender — normalized coordinates, 8 bytes per message
addEventListener("pointermove", (e) => {
  room.send(new Float32Array([e.clientX / innerWidth, e.clientY / innerHeight]), {
    label: "pointer",
  });
});

// receiver
room.on("channel:message", (e) => {
  if (e.label !== "pointer" || !e.raw) return;
  const [x, y] = new Float32Array(e.data);
  moveCursor(e.peer.id, x * innerWidth, y * innerHeight);
});

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 stream changes. The room also fires these events for its own streams, with an undefined peer, so skip those when handling remote streams:

room.on("stream:add", (e) => {
  if (!e.peer) return; // a local stream
  document.getElementById("remote-video").srcObject = e.stream;
});

room.on("stream:remove", (e) => {
  if (!e.peer) return;
  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

Attaching an addon twice, or detaching one that isn't attached, does nothing. In TypeScript, pass an event map to type the addon's own events, as in class Presence extends Addon<{ online: [id: string] }> {}.

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", ...).

on() and once() return a function that removes the handler. You can also pass a signal so that one AbortController removes several handlers at once:

const off = room.on("channel:message", onMessage);
off();

const controller = new AbortController();
room.on("stream:add", onStreamAdd, { signal: controller.signal });
room.on("stream:remove", onStreamRemove, { signal: controller.signal });
controller.abort();

Handlers run asynchronously, in a microtask after the event is emitted. If a handler throws, the error is reported as an error event with the HANDLER_ERROR code.

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.

Node.js

Node.js has no built-in WebRTC, so pass the classes from @roamhq/wrtc through the webrtc option to run a peer there:

// npm install @roamhq/wrtc
import { RTCPeerConnection, MediaStream } from "@roamhq/wrtc";
import { Room } from "peerix";

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

Without MediaStream, the peer handles data only: share() rejects and incoming tracks are ignored. Pick a driver whose client runs in Node.js. The node-datachannel polyfill does not work, because it never fires negotiationneeded.

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. Check that your signaling client can reach its backend; the room announces itself again when the driver reconnects. 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.

A peer doesn't receive a broadcast. — room.send() skips peers whose channel isn't open yet. Wait for the channel:open event for that peer. A targeted send (peer.send(...) or room.send(..., { to })) fails with DATACHANNEL_ERROR instead of skipping.

Peers disconnect during negotiation. — Increase connectionTimeout (default 15000 ms), 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

7 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages