@decentnetwork/peer-webrtc
v0.2.10
Published
Realtime audio/video call signaling over Elastos Carrier — a reusable WebRTC signaling layer wire-compatible with the iOS/Android Beagle apps. Bring your own RTCPeerConnection (browser or node) and signaling transport (Carrier invite via @decentnetwork/pe
Readme
@decentnetwork/peer-webrtc
Realtime audio / video call signaling over Elastos Carrier — a small, reusable WebRTC signaling layer that is wire-compatible with the iOS / Android Beagle apps. Bring your own RTCPeerConnection (browser-native or a Node wrtc/werift) and your own signaling transport; this package owns the call state machine and the RtcSignal protocol.
- Interops with native Beagle — same
RtcSignalJSON, carried over the Carrier"carrier"friend-invite extension, offer-first handshake. - No hard WebRTC dependency — the
RTCPeerConnectionis injected, so the core runs in a browser (window.RTCPeerConnection) or in Node (a wrtc lib). - No hard Carrier dependency — the signaling transport is injected too. Use the bundled
CarrierSignaling(wraps@decentnetwork/peer) for real calls, orBroadcastChannelSignalingfor a zero-infra two-tab demo, or write your own.
Install
npm i @decentnetwork/peer-webrtc
# for real cross-network calls you also want the Carrier peer:
npm i @decentnetwork/peerThe call engine
import { CallEngine } from "@decentnetwork/peer-webrtc";
import { CarrierSignaling } from "@decentnetwork/peer-webrtc/carrier";
import { Peer } from "@decentnetwork/peer";
const peer = await Peer.create({ /* ... */ });
await peer.start();
const engine = new CallEngine({
signaling: new CarrierSignaling(peer), // Carrier friend-invite bridge
createPeerConnection: (config) => new RTCPeerConnection(config),
getLocalMedia: (kinds) => navigator.mediaDevices.getUserMedia(kinds),
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
],
});
// Receiving
engine.on("incomingCall", (info) => {
if (confirm(`Call from ${info.peerId} — accept?`)) engine.accept(info.callId);
else engine.reject(info.callId);
});
engine.on("remoteStream", (callId, stream) => { remoteVideo.srcObject = stream; });
engine.on("localStream", (callId, stream) => { localVideo.srcObject = stream; });
engine.on("ended", (callId, reason) => { /* teardown UI */ });
// Placing a call to a Carrier friend (their carrier id / user id)
const callId = await engine.call(friendCarrierId, { audio: true, video: true });
// ... later
engine.hangup(callId);That's the whole surface: call(), accept(), reject(), hangup(), plus the
incomingCall / stateChanged / localStream / remoteStream / ended events.
Browser demo (no server, no Carrier)
demo/index.html places a real audio/video call between two browser tabs using
BroadcastChannelSignaling — it proves the engine + media path end-to-end with
zero infrastructure:
npm run build
npx serve packages/peer-webrtc # or any static server
# open demo/index.html in two tabs; copy one tab's id into the other and Call.How it maps to the native protocol
| RtcSignal type | meaning | who sends |
|---|---|---|
| offer (with options: ["audio","video"]) | start a call | caller (call()) |
| answer | accept + SDP | callee (accept()) |
| candidate | trickle ICE | both, after the remote SDP is applied |
| remove-candidates | prune ICE | either (ignored by browsers) |
| bye (with reason) | hang up / decline / busy | either |
| event (ringing/online) · action (accept/reject) | optional ring-first hints | either |
Signals are JSON, sent as the data of a Carrier friend-invite under the
"carrier" extension — exactly what CarrierExtension.sendInviteFriendRequest /
registerExtension do on iOS/Android. On the JS side that is
peer.sendInvite(id, data, { ext: "carrier" }) / peer.onInvite(...) (added in
@decentnetwork/peer ≥ 0.1.84).
Local ICE candidates are buffered until the remote description is applied
(hasReceivedSdp), matching the native SDK so candidates never race ahead of the
call setup.
Daemon ↔ browser split
The AgentNet desktop app runs Peer in a daemon and the UI (and therefore
RTCPeerConnection) in a browser. Wire it by running CallEngine in the
browser with a thin SignalingChannel that relays RtcSignals to the daemon over
your existing IPC, and let the daemon hold the CarrierSignaling/Peer. The
signaling payloads are plain JSON, so the bridge is just two postMessage-style
hops — the same pattern used for text and inline files.
Offline calls (socket.io + push)
Carrier signaling needs the peer online on the Carrier network. To reach a peer whose app is backgrounded/offline (a phone), Beagle uses a second path — a socket.io signaling gateway plus a push notification that wakes the app. Both are provided here, byte-compatible with the Beagle apps:
SocketIoSignaling(@decentnetwork/peer-webrtc/socketio) — aSignalingChannelover the Beagle socket.io gateway (io.beagle.chat). Each call is a room keyed bycallId; it emitsnew-channelto join andmessageto signal, exactly asWebRtcManager+SocketIO.swiftdoes.BeaglePushClient(@decentnetwork/peer-webrtc/push) — POSTs/push-api/push-messageto ring an offline peer (VoIP push → CallKit / FCM).
import { io } from "socket.io-client";
import { CallEngine } from "@decentnetwork/peer-webrtc";
import { SocketIoSignaling } from "@decentnetwork/peer-webrtc/socketio";
import { BeaglePushClient } from "@decentnetwork/peer-webrtc/push";
const socket = io("https://io.beagle.chat", { transports: ["websocket"], reconnection: false });
const signaling = new SocketIoSignaling(socket, { userId: myCarrierUserId });
const engine = new CallEngine({ signaling, createPeerConnection, getLocalMedia });
// To call an OFFLINE peer: join the room, wake them, then place the call.
const push = new BeaglePushClient({ appKey: "<yourAppKey>", appName: "<yourAppName>" });
const callId = crypto.randomUUID();
signaling.joinCall(callId);
await push.ring(calleeUserId, { callerUserId: myCarrierUserId, callId, callerName: "Me", hasVideo: true });
await engine.call(calleeUserId, { audio: true, video: true, callId });To RECEIVE an offline call, the app is woken by the push (payload carries the
callId); call signaling.joinCall(pushedCallId) before answering so inbound
signals for that room arrive.
Prefer Carrier when the peer is online (direct, no server); fall back to
socket.io+push when it isn't. A simple strategy: try CarrierSignaling, and if
the invite isn't acknowledged within a short timeout, ring over socket.io+push.
License
GPL-3.0-or-later
