@decentnetwork/peer-webrtc
Audio/video call signaling over Carrier, wire-compatible with the iOS and Android Beagle apps. Bring your own RTCPeerConnection and transport.
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
RtcSignalprotocol 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 weriftQuickstart
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: declinedremoteRinging 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'sended("declined"). The script passed on every run. - Verified in a browser: a full audio call between two
CallEngineinstances on one page, in Chromium 152, usingBroadcastChannelSignalingand the browser'sRTCPeerConnection. A WebAudio tone stood in for the microphone. Both sides reachedconnected, both got aremoteStreamwith one audio track, andhangup()ended both withnormal. - 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 calloniceconnectionstatechange. To accept calls in Node you need anRTCPeerConnectionthat 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 path | Exports |
|---|---|
@decentnetwork/peer-webrtc | everything below |
@decentnetwork/peer-webrtc/carrier | CarrierSignaling, decodeSignal, encodeSignalBytes |
@decentnetwork/peer-webrtc/socketio | SocketIoSignaling, decodeSignal, encodeSignal |
@decentnetwork/peer-webrtc/push | BeaglePushClient |
Class CallEngine
new CallEngine(opts: CallEngineOptions). It extends Emitter<CallEngineEvents>.
| Member | Description |
|---|---|
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): void | Mute, or turn the camera off. |
shareScreen(callId): Promise<void> / stopScreenShare(callId): Promise<void> / isSharingScreen(callId): boolean | Screen share (needs getDisplayMedia). |
discard(callId): void | Drop a call locally without signaling the peer. |
dispose(): void | Tear 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)
| Event | Handler |
|---|---|
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.
| Class | Constructor | Notes |
|---|---|---|
CarrierSignaling | new 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. |
BroadcastChannelSignaling | new BroadcastChannelSignaling(selfId: string, channelName?: string) | Browser-only, between tabs or within a page. Also has close(). |
SocketIoSignaling | new 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.
| Member | Description |
|---|---|
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
| Function | Description |
|---|---|
encodeSignal(signal: RtcSignal): string / encodeSignalBytes(signal): Uint8Array | Compact JSON as the native apps expect it. Undefined fields are left out. |
decodeSignal(input: string | Uint8Array): RtcSignal | Parse. Accepts a trailing NUL from C senders. Throws on bad input. |
decodeSignalBytesGuard(input): RtcSignal | undefined | Like decodeSignal, but returns undefined instead of throwing. |
looksLikeSignal(input): boolean | Cheap 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 }): string | The 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): number | SDP 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:
| Option | Default | Notes |
|---|---|---|
signaling | required | A SignalingChannel. |
createPeerConnection | required | (config: RTCConfiguration) => RTCPeerConnection. |
getLocalMedia | none | (kinds) => Promise<MediaStream>. Leave it out for receive-only calls. |
getDisplayMedia | none | Enables shareScreen(). |
iceServers | Google STUN | Add TURN for calls across symmetric NATs. Peer.getIceServers() returns Carrier bootstrap STUN and TURN entries with per-identity credentials. |
selfId | none | For logs. |
generateCallId | crypto.randomUUID() | Custom callId generator. |
ringTimeoutMs | 60000 | An outgoing call with no answer ends with noAnswer. 0 turns it off. |
sendRinging | true | The callee replies event: "ringing" to each new offer. |
leanVideoCodecs | true | Limit video to H264/VP8 (plus rtx/red/ulpfec) so offers fit the 8192-byte invite limit. |
maxSignalBytes | 8192 | Hard cap for one encoded signal. |
iceRestartOffers | false | Allow 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. |
logger | none | (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).CallEngineand the signaling classes run in Node, but you must inject anRTCPeerConnection. Node has none built in.BroadcastChannelSignalingneedsBroadcastChannel, and receiving media in Node needs a globalMediaStream. - Native interop: wire-compatible with the Beagle apps'
RtcSignalover 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
- Verified version: 0.2.18 (npm
latest, published 2026-11-05). - npm: https://www.npmjs.com/package/@decentnetwork/peer-webrtc
- Source: the source repository is not public yet. The published code is in the npm package: https://www.npmjs.com/package/@decentnetwork/peer-webrtc?activeTab=code
- Changelog:
CHANGELOG.mdships in the package (entries from 0.2.17 on): https://www.npmjs.com/package/@decentnetwork/peer-webrtc?activeTab=code - License: GPL-3.0-or-later.