All docs / npm packages

@decentnetwork/peer-webrtc

Audio/video call signaling over Carrier, wire-compatible with the iOS and Android Beagle apps. Bring your own RTCPeerConnection and transport.

Version
0.2.18 · npm
Platform
Node.js 20+ · browser
Source
Source repository not public yet
npm install @decentnetwork/peer-webrtc

@decentnetwork/peer-webrtc is the WebRTC call layer for Decent Network. It owns the call state machine (offer, answer, ICE candidates, ringing, hang-up) and the RtcSignal JSON protocol that the iOS and Android Beagle apps use. It does not bundle a WebRTC stack or a network transport: you inject an RTCPeerConnection factory (the browser's own, or a Node implementation) and a signaling channel. The bundled CarrierSignaling sends signals as Carrier friend-invites through @decentnetwork/peer, so a JS client can call, and be called by, the native Beagle apps.

When to use it

  • Use it to add audio, video or screen-share calls between Carrier friends in a browser app, an Electron/desktop app, or a Node service.
  • Pair it with @decentnetwork/peer, which carries the signaling (Peer.sendInvite / Peer.onInvite) and provides TURN credentials (Peer.getIceServers()).
  • To reach a phone whose app is in the background, use the socket.io channel (SocketIoSignaling) and the push client (BeaglePushClient) that ship with this package.
  • On Android and iOS, use the native WebRTC SDKs (Android, iOS). They speak the same RtcSignal protocol over the same Carrier invite channel.
  • For text, files and friends only, you do not need this package.

Install

npm install @decentnetwork/peer-webrtc @decentnetwork/peer

@decentnetwork/peer is an optional peer dependency (>=0.1.84), so npm does not install it for you. You need it for CarrierSignaling. The package is ESM only. It has no runtime dependencies.

In a browser, the RTCPeerConnection and getUserMedia come from the browser. In Node you need a WebRTC implementation. The quickstart uses werift, a pure-JavaScript one:

npm install werift

Quickstart

This Node script creates two Carrier peers, makes them friends, and runs one CallEngine on each side over CarrierSignaling. The caller places an audio call. The callee gets incomingCall and rejects it, and the caller sees the call ring and end with declined. Every signal crosses the live Carrier network as a friend-invite.

// quickstart.mjs — ring a Carrier friend with CallEngine, entirely in Node.
// npm install @decentnetwork/peer-webrtc @decentnetwork/peer werift
// Run: node quickstart.mjs
import { Peer } from "@decentnetwork/peer";
import { CallEngine } from "@decentnetwork/peer-webrtc";
import { CarrierSignaling } from "@decentnetwork/peer-webrtc/carrier";
import { RTCPeerConnection } from "werift"; // any RTCPeerConnection works; werift is a pure-JS one for Node

const bootstrapNodes = [
  { host: "47.100.103.201", port: 33445, pk: "CX1XH419p4xJ5SV4KvDxBeKYSRdMJW9QpdWJY8owUxHd" },
  { host: "154.64.235.176", port: 33445, pk: "GdNtV2N74fZnLjhH7NhQ18nGdxb1k8jRM9dQaK7WnxmL" },
  { host: "45.207.220.155", port: 33445, pk: "37PXijTXkyX9rUcRFjGo7PYpwf732KXQJgMtcKuMu3ym" },
  { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
];
const expressNodes = [
  { host: "lens.beagle.chat", port: 443, pk: "ECbs4GxwGzxGerNkmqDJFibEmevu8jAXqAZtikccvD95" },
];
const watchdog = setTimeout(() => { console.error("timed out"); process.exit(1); }, 180_000);

// 1. Two Carrier peers that are friends (call signaling needs a live friend session).
const caller = await Peer.create({ keyFile: "./caller/peer.save", bootstrapNodes, expressNodes });
const callee = await Peer.create({ keyFile: "./callee/peer.save", bootstrapNodes, expressNodes });
callee.onFriendRequest((req) => void callee.acceptFriendRequest(req.pubkey));
await caller.start();
await callee.start();
await Promise.all([caller.joinNetwork(), callee.joinNetwork()]);
await caller.sendFriendRequest(callee.address(), "let's talk");
const online = await caller.waitForFriendConnected(callee.userid(), 120_000);
console.log(`carrier session caller -> callee: ${online ? "connected" : "not connected"}`);

// 2. One CallEngine per side. Signals ride Carrier friend-invites ("carrier" extension).
const engine = (peer) => new CallEngine({
  selfId: peer.userid(),
  signaling: new CarrierSignaling(peer),
  createPeerConnection: (config) => new RTCPeerConnection(config),
  iceServers: peer.getIceServers({ limit: 1 }), // Carrier bootstrap STUN + TURN
});
const callerEngine = engine(caller);
const calleeEngine = engine(callee);

calleeEngine.on("incomingCall", (info) => {
  console.log(`callee <- incomingCall from ${info.peerId} audio=${info.audio} video=${info.video}`);
  void calleeEngine.reject(info.callId); // a UI would ask the user, then accept(callId)
});
callerEngine.on("stateChanged", (info, state) => console.log(`caller state: ${state}`));
const ended = new Promise((resolve) => callerEngine.on("ended", (callId, reason) => resolve(reason)));

const callId = await callerEngine.call(callee.userid(), { audio: true, video: false });
console.log(`caller -> call() sent offer, callId ${callId}`);
console.log(`caller <- ended: ${await ended}`);

callerEngine.dispose();
calleeEngine.dispose();
await caller.stop();
await callee.stop();
clearTimeout(watchdog);
process.exit(0);

Verified output (npm @decentnetwork/peer-webrtc@0.2.18 with @decentnetwork/peer@0.1.172 and werift@0.22.2, Node.js 24.3.0, 2026-11-06):

carrier session caller -> callee: connected
caller state: ringing
caller -> call() sent offer, callId 27f2540f-9fdb-4eee-87b1-0f44529a4693
callee <- incomingCall from 6DevUHjrkd4NatwhS6akDFDy3MwAF4i5FSNyhYtzwMNf audio=true video=false
caller state: remoteRinging
caller state: declined
caller <- ended: declined

remoteRinging is the callee's automatic event: "ringing" reply, which proves the offer reached it. declined is the callee's bye with reason declined.

What was verified, and what was not:

  • Verified over the live network (Node): offer delivery over Carrier invites, incomingCall, the automatic ringing reply, reject(), and the caller's ended("declined"). The script passed on every run.
  • Verified in a browser: a full audio call between two CallEngine instances on one page, in Chromium 152, using BroadcastChannelSignaling and the browser's RTCPeerConnection. A WebAudio tone stood in for the microphone. Both sides reached connected, both got a remoteStream with one audio track, and hangup() ended both with normal.
  • Not verified: connected media over Carrier signaling (browser to browser, or to a native Beagle app), video, screen sharing, and the socket.io and push paths. With werift 0.22.2, ICE connected but the engine never left connecting, because werift does not call oniceconnectionstatechange. To accept calls in Node you need an RTCPeerConnection that fires the standard browser events.

Wiring it in a browser

This is the same API, with browser media:

import { Peer } from "@decentnetwork/peer";
import { CallEngine } from "@decentnetwork/peer-webrtc";
import { CarrierSignaling } from "@decentnetwork/peer-webrtc/carrier";

const engine = new CallEngine({
  signaling: new CarrierSignaling(peer),                        // peer: a started Peer
  createPeerConnection: (config) => new RTCPeerConnection(config),
  getLocalMedia: (kinds) => navigator.mediaDevices.getUserMedia(kinds),
  getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c), // enables shareScreen()
  iceServers: peer.getIceServers(),
});

engine.on("incomingCall", (info) => { /* show UI, then */ engine.accept(info.callId); });
engine.on("localStream", (callId, stream) => { localVideo.srcObject = stream; });
engine.on("remoteStream", (callId, stream) => { remoteVideo.srcObject = stream; });
engine.on("ended", (callId, reason) => { /* tear down UI */ });

const callId = await engine.call(friendUserid, { audio: true, video: true });
// later
await engine.hangup(callId);

The package also ships demo/index.html, a two-tab call demo over BroadcastChannelSignaling. It imports ../dist/index.js, so serve the installed package directory (node_modules/@decentnetwork/peer-webrtc) with any static server and open demo/index.html in two tabs.

API reference

Entry points (from package.json exports):

Import pathExports
@decentnetwork/peer-webrtceverything below
@decentnetwork/peer-webrtc/carrierCarrierSignaling, decodeSignal, encodeSignalBytes
@decentnetwork/peer-webrtc/socketioSocketIoSignaling, decodeSignal, encodeSignal
@decentnetwork/peer-webrtc/pushBeaglePushClient

Class CallEngine

new CallEngine(opts: CallEngineOptions). It extends Emitter<CallEngineEvents>.

MemberDescription
call(peerId: string, kinds?: { audio?: boolean; video?: boolean; data?: boolean }): Promise<string>Gets local media, sends an offer, and returns the new callId. Defaults: audio: true, video: false, data: false. Rejects after emitting ended(callId, "unreachable") when the offer cannot be sent.
accept(callId: string): Promise<void>Answer an incoming call.
reject(callId: string): Promise<void>Decline (sends bye with declined).
hangup(callId: string, reason?: HangupType): Promise<void>Hang up (default normal).
setLocalTrackEnabled(callId, kind: "audio" | "video", enabled: boolean): voidMute, or turn the camera off.
shareScreen(callId): Promise<void> / stopScreenShare(callId): Promise<void> / isSharingScreen(callId): booleanScreen share (needs getDisplayMedia).
discard(callId): voidDrop a call locally without signaling the peer.
dispose(): voidTear down every call.
calls: CallInfo[] (getter)Calls in progress.
isBusy: boolean (getter)true while any call is active.
on(event, handler): () => void, once(...), off(...), emit(...), removeAll()From Emitter. on returns an unsubscribe function.

Events (CallEngineEvents)

EventHandler
incomingCall(info: CallInfo) => void
stateChanged(info: CallInfo, state: CallState) => void
localStream(callId: string, stream: MediaStream) => void
remoteStream(callId: string, stream: MediaStream) => void. Emitted again with a new stream object when a track is added.
ended(callId: string, reason: CallEndReason) => void
negotiationFailed(callId: string, error: Error) => void. A mid-call re-offer or answer from the peer could not be applied. The call stays up.

CallState is one of "connecting" | "ringing" | "connected" | "disconnected" | "ended" | "failed" | "busy" | "declined" | "remoteRinging" | "remoteOnline" | "noAnswer" | "unreachable". CallEndReason is one of "declined" | "busy" | "normal" | "close" | "failed" | "noAnswer" | "unreachable". CallInfo is { callId, peerId, direction: "outgoing" | "incoming", audio, video, data, state }.

Signaling channels

All of these implement SignalingChannel: { send(peerId, signal: RtcSignal): void | Promise<void>; onSignal(handler: (peerId, signal) => void): void }. Implement the same two methods to bring your own transport, for example an IPC bridge between a daemon that holds the Peer and a browser UI that holds the RTCPeerConnection.

ClassConstructorNotes
CarrierSignalingnew CarrierSignaling(peer: CarrierInviteTransport, opts?: { ext?: string })peer is any object with sendInvite(pubkey, data, opts?) and onInvite(cb). A Peer from @decentnetwork/peer fits. ext defaults to "carrier", the extension the native apps listen on. Invites that are not signals are ignored.
BroadcastChannelSignalingnew BroadcastChannelSignaling(selfId: string, channelName?: string)Browser-only, between tabs or within a page. Also has close().
SocketIoSignalingnew SocketIoSignaling(socket: SocketIoLike, opts: { userId: string })Beagle socket.io gateway. Each call is a room keyed by callId. Also has joinCall(callId) (join before answering a call that arrived by push) and leaveCall(callId). Bring your own socket.io-client socket.

Class BeaglePushClient

new BeaglePushClient(opts: BeaglePushOptions) with { appKey: string; appName: string; endpoints?: string[]; fetch?: typeof fetch }. The default endpoints are the public Beagle push gateways.

MemberDescription
ring(calleeUserId, { callerUserId, callId, callerName, hasVideo, channel? }): Promise<boolean>Wake an offline phone for a call.
cancel(calleeUserId, { … same … }): Promise<boolean>Cancel the ring.
sendCallPush(calleeUserId, payload: PushPayload): Promise<boolean>Send a raw PushPayload: { userId, hasVideo: "true" | "false", callId, callName, action: "call" | "decline", channel: "socketio" | "carrier" }.

Each call tries the endpoints in order and resolves false if they all fail. appKey and appName must be registered with the push gateway; they were not tested for this page.

Signal and SDP helpers

FunctionDescription
encodeSignal(signal: RtcSignal): string / encodeSignalBytes(signal): Uint8ArrayCompact JSON as the native apps expect it. Undefined fields are left out.
decodeSignal(input: string | Uint8Array): RtcSignalParse. Accepts a trailing NUL from C senders. Throws on bad input.
decodeSignalBytesGuard(input): RtcSignal | undefinedLike decodeSignal, but returns undefined instead of throwing.
looksLikeSignal(input): booleanCheap check before a full decode.
fitSignalForInvite(signal, budget?): { signal, bytes, shrunk }Shrink an offer to fit the Carrier invite limit (CARRIER_MAX_INVITE_DATA_LEN = 8192 bytes).
shrinkSdpForInvite(sdp, budget?, opts?: { codecs?: boolean }): stringThe SDP-level shrink used by fitSignalForInvite.
sanitizeSdp(sdp): string / orphanPayloadTypeLines(sdp): string[]Remove codec lines whose payload type is not on their m= line (Firefox leaves these; Chrome rejects them). A clean SDP comes back unchanged.
mediaDirection(sdp, kind: "audio" | "video"): SdpDirection | undefined, videoIsSending(sdp): boolean, byteLength(text): numberSDP inspection.

RtcSignal is { type: "offer" | "answer" | "candidate" | "remove-candidates" | "prAnswer" | "bye" | "action" | "event"; callId: string; sdp?; candidates?: RtcCandidateSignal[]; reason?: HangupType; options?: MediaKind[]; action?: "reject" | "accept"; event?: "ringing" | "online" | "connectToRoom" }.

Exported types: RtcSignal, RtcCandidateSignal, SdpType, HangupType, SignalEvent, SignalAction, MediaKind, SdpDirection, SignalingChannel, PeerConnectionFactory, LocalMediaProvider, CallEngineOptions, CallEngineEvents, CallState, CallEndReason, CallDirection, CallInfo, CarrierInviteTransport, SocketIoLike, PushPayload, BeaglePushOptions.

Configuration

CallEngineOptions:

OptionDefaultNotes
signalingrequiredA SignalingChannel.
createPeerConnectionrequired(config: RTCConfiguration) => RTCPeerConnection.
getLocalMedianone(kinds) => Promise<MediaStream>. Leave it out for receive-only calls.
getDisplayMedianoneEnables shareScreen().
iceServersGoogle STUNAdd TURN for calls across symmetric NATs. Peer.getIceServers() returns Carrier bootstrap STUN and TURN entries with per-identity credentials.
selfIdnoneFor logs.
generateCallIdcrypto.randomUUID()Custom callId generator.
ringTimeoutMs60000An outgoing call with no answer ends with noAnswer. 0 turns it off.
sendRingingtrueThe callee replies event: "ringing" to each new offer.
leanVideoCodecstrueLimit video to H264/VP8 (plus rtx/red/ulpfec) so offers fit the 8192-byte invite limit.
maxSignalBytes8192Hard cap for one encoded signal.
iceRestartOffersfalseAllow ICE-restart re-offers. Turn it on only when every peer runs this JS engine: the native apps treat any new offer as a new call.
loggernone(msg: string) => void.

There are no environment variables and no CLI.

Platforms

  • Browsers: any browser with RTCPeerConnection. A full audio call was verified in Chromium 152 (see above). The package contains fixes for Firefox-generated SDP.
  • Node.js 20+ (engines.node >= 20.0.0). CallEngine and the signaling classes run in Node, but you must inject an RTCPeerConnection. Node has none built in. BroadcastChannelSignaling needs BroadcastChannel, and receiving media in Node needs a global MediaStream.
  • Native interop: wire-compatible with the Beagle apps' RtcSignal over Carrier invites (ext "carrier"), according to the package. A call to a native app was not tested for this page.
  • No native dependencies, and no runtime dependencies at all.

Version, source and changelog