Skip to content

dart-libp2p-webrtc

Skill dart-libp2p-webrtc. Use when a Dart or Flutter dart_libp2p host must connect directly to js-libp2p browser apps or other Dart hosts over WebRTC (/p2p-circuit/webrtc) with package:dart_libp2p_webrtc: WebRtcTransport, relay signalling, upgrade(), STUN/TURN via RtcConfig, raw data channels, the native libdatachannel build.

SKILL.md (Claude Code, Codex, Cursor) · dart-libp2p-webrtc.md (Boubou) · install: curl -fsSL https://cryptopeer.net/skills/install.sh | sh -s -- dart-libp2p-webrtc

The libp2p /webrtc (private-to-private) transport for dart_libp2p. Two peers that both reach a circuit relay exchange SDP over a /webrtc-signaling/0.0.1 stream on their relayed connection, then talk directly over WebRTC data channels (DTLS + SCTP). It is how a Dart or Flutter app and a js-libp2p web app in Chrome, Firefox or WebKit connect peer to peer. Native code is libdatachannel v0.24.6 with Mbed TLS 3.6.7, built from source by the package’s build hook and called through dart:ffi.

Use something else when:

  • Both peers are Dart and one has a public address: plain TCP or UDX is simpler.
  • You need /webrtc-direct (browser to a public server, no relay): not implemented.
  • You target Windows or the web: not supported (in a browser use js-libp2p’s own @libp2p/webrtc).
  • Host setup, transports, relays and AutoRelay in general: see the dart-libp2p-host skill.
dependencies:
dart_libp2p: ^4.9.0
dart_libp2p_webrtc: ^0.1.0

Needs Dart 3.10+ (Flutter 3.38+), and on the build machine CMake 3.21+ and Ninja on PATH plus a C/C++ toolchain (Xcode on Apple, the NDK Flutter uses on Android, GCC or Clang on Linux). brew install cmake ninja or apt install cmake ninja-build g++. The first build compiles the native library (a few minutes), then it is cached; about 3-4 MB per ABI.

import 'dart:async'; // unawaited
import 'dart:convert'; // utf8
import 'dart:typed_data'; // Uint8List
import 'package:dart_libp2p/core/network/context.dart'; // Context, for newStream
// libp2p transport (WebRtcTransport, RtcConfig, WebRtcConn, signalling)
import 'package:dart_libp2p_webrtc/libp2p.dart';
// raw WebRTC without libp2p (RtcPeerConnection, RtcDataChannel, RtcRuntime)
import 'package:dart_libp2p_webrtc/dart_libp2p_webrtc.dart';

package:dart_libp2p_webrtc/interop.dart is a test harness (Playwright driver, hub, NAT check) that lives in the repository; it is not part of the published package. Do not depend on it in apps.

import 'package:dart_libp2p/config/config.dart';
import 'package:dart_libp2p/core/host/host.dart';
import 'package:dart_libp2p/core/multiaddr.dart';
import 'package:dart_libp2p/p2p/transport/connection_manager.dart';
import 'package:dart_libp2p/p2p/transport/tcp_transport.dart';
import 'package:dart_libp2p_webrtc/libp2p.dart';
Future<(Host, WebRtcTransport)> makeHost() async {
final connManager = ConnectionManager();
final webrtc = WebRtcTransport(
rtcConfig: const RtcConfig(iceServers: ['stun:stun.l.google.com:19302']),
);
final host = await Libp2p.new_([
Libp2p.connManager(connManager),
Libp2p.transport(webrtc), // first
Libp2p.transport(TCPTransport(connManager: connManager)), // or UDX, to reach the relay
Libp2p.listenAddrs([MultiAddr('/webrtc')]), // accept WebRTC
Libp2p.relay(true), // circuit relay client
]);
webrtc.attach(host); // before start: the signalling handler needs the host
await host.start();
return (host, webrtc);
}
import 'package:dart_libp2p/core/peer/addr_info.dart';
import 'package:dart_libp2p/p2p/host/basic/basic_host.dart';
import 'package:dart_libp2p/p2p/protocol/circuitv2/client/reservation.dart';
final relay = AddrInfo.fromMultiaddr(MultiAddr('/ip4/203.0.113.5/tcp/4001/p2p/<relay-id>'));
await host.connect(relay);
await (host as BasicHost).circuitV2Client!.reserve(relay.id);
// Reachable at <relay addr>/p2p-circuit/webrtc/p2p/<host id>.
// With AutoRelay the host advertises those addresses itself.

Dialer: relayed connection first, then upgrade

Section titled “Dialer: relayed connection first, then upgrade”
await dialer.connect(relay); // the circuit client looks the relay up in the peerstore
await dialer.connect(AddrInfo(peerId, [MultiAddr('$relayAddr/p2p-circuit')]));
final conn = await webrtc.upgrade(peerId); // direct from here on
print(conn.remoteMultiaddr); // e.g. /ip6/2001:db8::7/udp/53756/webrtc
// New host streams to peerId now prefer the WebRTC connection.
final stream = await dialer.newStream(peerId, ['/my/proto/1.0.0'], Context());

upgrade adds <relay>/p2p-circuit/webrtc for the peer to the peerstore (temp TTL) and calls network.dialPeer with Context().withForceDirectDial(...), as a hole punch does. Incoming WebRTC connections join the swarm like any other: your setStreamHandler handlers, identify and ping serve them.

import 'package:dart_libp2p/p2p/network/swarm/swarm_conn.dart';
final conn = await webrtc.upgrade(peer) as SwarmConn;
final rtc = conn.conn;
if (rtc is WebRtcConn) {
final pair = rtc.selectedCandidatePair; // SDP candidate: lines
final viaTurn = pair != null && (pair.local.contains('typ relay') || pair.remote.contains('typ relay'));
}
WebRtcTransport(
rtcConfig: RtcConfig(iceServers: [
'stun:stun.l.google.com:19302',
'turn:${Uri.encodeComponent(user)}:${Uri.encodeComponent(pass)}@turn.example.com:3478',
]),
)

Give the browser the same server, un-encoded: webRTC({ rtcConfiguration: { iceServers: [{ urls: 'turn:turn.example.com:3478', username, credential }] } }). For coturn use-auth-secret, the username is <expiry unix time>:<label> and the password base64(HMAC-SHA1(secret, username)); mint them on a backend (example/turn_credentials.dart in the repo shows it).

Check credentials: a relay-only offer must contain relay candidates.

import 'package:dart_libp2p_webrtc/dart_libp2p_webrtc.dart';
final pc = RtcPeerConnection(RtcConfig(iceServers: [turnUrl], relayOnly: true));
pc.createDataChannel('probe');
final offer = await pc.createOffer();
print(offer.contains('typ relay') ? 'TURN works' : 'no relay candidates');
await pc.close();
import '@stephanfeb/libp2p-udx/multiaddr' // 0.1.1+: only if the relay speaks UDX
import { createLibp2p } from 'libp2p'
import { webRTC } from '@libp2p/webrtc'
import { webSockets } from '@libp2p/websockets'
import { circuitRelayTransport } from '@libp2p/circuit-relay-v2'
import { noise } from '@chainsafe/libp2p-noise'
import { yamux } from '@chainsafe/libp2p-yamux'
import { identify } from '@libp2p/identify'
import { multiaddr } from '@multiformats/multiaddr'
const node = await createLibp2p({
addresses: { listen: [`${relayWss}/p2p-circuit`, '/webrtc'] },
transports: [webSockets(), webRTC({ rtcConfiguration }), circuitRelayTransport()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
services: { identify: identify() }
})
const conn = await node.dial(multiaddr(`${relayWss}/p2p-circuit/webrtc/p2p/${dartPeerId}`))

Dart dials the browser the same way it dials a Dart host: connect to the relay, connect to the browser at <relay>/p2p-circuit, upgrade(browserId).

const config = RtcConfig(bindAddress: '127.0.0.1');
final alice = RtcPeerConnection(config);
final bob = RtcPeerConnection(config);
final chat = alice.createDataChannel('chat'); // offer needs an application section
final offer = await alice.createOffer(); // waits for ICE gathering
bob.setRemoteDescription(offer, 'offer');
final answer = await bob.createAnswer();
alice.setRemoteDescription(answer, 'answer');
await Future.wait([alice.connected(), bob.connected()]);
final bobChat = await bob.onDataChannel.first;
bobChat.messages.listen((m) => bobChat.send(m)); // Uint8List in order
await chat.opened;
await chat.send(Uint8List.fromList(utf8.encode('hello'))); // awaits backpressure
await alice.close();
await bob.close();

Signalling over any authenticated stream (no swarm)

Section titled “Signalling over any authenticated stream (no swarm)”
b.setStreamHandler(signalingProtocol, (stream, peer) async {
final conn = await answerWebRtc(stream);
unawaited(conn.serveStreams(b.mux)); // serve b's protocol handlers
});
final s = await a.newStream(b.id, [signalingProtocol], Context());
final conn = await initiateWebRtc(s);
final echo = await conn.newProtocolStream(['/test/echo/1.0.0']);

package:dart_libp2p_webrtc/libp2p.dart:

  • WebRtcTransport({RtcConfig rtcConfig, Duration signalingTimeout = 30 s, TransportConfig config}): the transport.
  • WebRtcTransport.attach(Host): give it the host whose relayed conns carry signalling; call before start().
  • WebRtcTransport.upgrade(PeerId) -> Future<Conn>: move a relayed peer onto a direct connection.
  • WebRtcTransport.canDial: true when /webrtc directly follows /p2p-circuit; canListen: only bare /webrtc.
  • WebRtcConn: the connection (UpgradedTransportConn); remoteAddress, selectedCandidatePair, done, serveStreams(mux), newProtocolStream(protocols).
  • WebRtcStream: one data channel per stream with FIN / FIN_ACK / STOP_SENDING / RESET_STREAM framing; finAckTimeout = 10 s.
  • initiateWebRtc(stream, {config, timeout}) / answerWebRtc(...): the two signalling roles; signalingProtocol = /webrtc-signaling/0.0.1.
  • WebRtcSignalingException: SDP exchange or ICE failed or timed out.
  • webRtcMuxerProtocol = /webrtc (reported as the muxer); initChannelLabel = init.
  • RtcConfig({iceServers, relayOnly, bindAddress, portRangeBegin, portRangeEnd, maxMessageSize}).

package:dart_libp2p_webrtc/dart_libp2p_webrtc.dart:

  • RtcPeerConnection([RtcConfig]): createOffer, createAnswer, setRemoteDescription(sdp, 'offer'|'answer'), addRemoteCandidate(candidate, [mid]), createDataChannel(label, {protocol, negotiated, stream}), connected({timeout}), onStateChange, onDataChannel, state, remoteAddress, selectedCandidatePair, localDescription, close().
  • RtcPeerState: initial, connecting, connected, disconnected, failed, closed.
  • RtcDataChannel: send(Uint8List), messages, opened, done, isOpen, label, streamId, maxMessageSize, bufferedAmount, close(), dispose(); highWaterMark 1 MiB, lowWaterMark 256 KiB.
  • RtcRuntime.enableNativeLogging(RtcLogLevel.debug): libdatachannel’s log on stdout. RtcException: a native call failed.

Loggers (package:logging): WebRtcTransport, WebRtcSignaling, WebRtcConn, WebRtcStream.

Native build: hook/build.dart runs CMake + Ninja on native/CMakeLists.txt (a superbuild of third_party/mbedtls and third_party/libdatachannel, both git submodules) and ships the code asset package:dart_libp2p_webrtc/libdatachannel. lib/src/ffi/bindings.g.dart is generated by dart run ffigen --config ffigen.yaml (its llvm-path points at Xcode’s libclang). dart run tool/build_native.dart just builds and loads the library.

  • attach(host) before host.start(). Without it dial/upgrade throw StateError('WebRtcTransport is not attached to a host'), and inbound signalling is never answered.
  • Listen on /webrtc, or nothing accepts. The signalling handler is only installed while the /webrtc listener is open; without it inbound signalling streams are reset. Only a bare /webrtc is listenable.
  • Add the WebRTC transport first among transports. The swarm dials with the first transport that accepts an address.
  • A dial needs an existing relayed connection. dial/upgrade throw StateError('no relayed connection to <peer> ...') otherwise. The transport does not open the relayed connection itself on purpose: the swarm joins concurrent dials to one peer, so it would wait on itself. Connect to <relay>/p2p-circuit first.
  • Connect to the relay before dialing through it. dart_libp2p’s circuit client looks the relay up in the peerstore.
  • upgrade can return a non-WebRTC connection. The swarm dials every direct address it knows for the peer; a reachable TCP or UDX address can win. Check (conn as SwarmConn).conn is WebRtcConn. The tests listen on /webrtc only to avoid this.
  • No automatic upgrade. Relayed peers stay relayed until you call upgrade. If ICE fails, upgrade throws after signalingTimeout (30 s) and the relayed connection stays up; it may be limited (go-libp2p defaults: 2 min, 128 KiB; see conn.stat.stats.limited).
  • Remote address is the ICE candidate, /ip4|ip6/<ip>/udp/<port>/webrtc, never /p2p-circuit (the swarm would treat that as relayed). If libdatachannel reports nothing it is plain /webrtc.
  • No STUN servers = local addresses only. Default RtcConfig() has no ICE servers; phones and NATed hosts need STUN in iceServers. js-libp2p has public STUN by default, Dart does not.
  • Symmetric NAT (carrier IPv4) defeats direct paths. Measured: iPhone on cellular IPv4 to a browser on IPv4-only Wi-Fi, 0/7 scenarios connected. Works when both sides have IPv6 (carrier IPv6 is usually not NATed). Plan for TURN.
  • TURN on the Dart side is UDP only (libjuice). No turn:?transport=tcp, no turns:/443. On networks that block UDP, Dart peers cannot do WebRTC at all; keep the relayed libp2p connection.
  • TURN URL user and password must be percent-encoded. coturn time-limited usernames contain : and base64 passwords contain /+=. Unencoded, or wrong, or expired credentials raise no error: the peer just gathers no relay candidates and ICE fails later. Browsers take them un-encoded.
  • RtcConfig.relayOnly is not a privacy guarantee. It only leaves non-relay candidates out of the SDP; ICE still uses host sockets if the remote sends host candidates. Only when both sides are relay-only is TURN forced. Browsers’ iceTransportPolicy: 'relay' is strict.
  • No trickle from our side. createOffer/createAnswer wait for ICE gathering to complete; unreachable STUN/TURN servers slow every connection. Remove dead ICE servers.
  • An offer needs a data channel first. In raw use, call createDataChannel before createOffer, or the SDP has no application section. (initiateWebRtc creates and later closes the init channel.)
  • Browsers block ws:// from HTTPS pages. The relay needs a WSS listener for browsers, and TCP or UDX for Dart (dart_libp2p has no WebSocket transport).
  • Protocol 768 was unknown in js-libp2p means the relay’s reservation lists a /udx address. Import @stephanfeb/libp2p-udx/multiaddr (the browser entry, not the package root, which needs Node.js) or registry.addProtocol({ code: 0x0300, name: 'udx', size: 0 }) before creating the node. App and package must share one @multiformats/multiaddr.
  • Browser connection gater refuses loopback/private addresses. Tests override connectionGater: { denyDialMultiaddr: async () => false }; production apps on public addresses do not need it.
  • Firefox will not use a TURN server on loopback; Chrome and WebKit do.
  • Docker published ports break TURN (Docker Desktop NAT rewrites relay ports): run coturn with network_mode: host on Linux; never ship allow-loopback-peers.
  • iOS local network prompt. Add NSLocalNetworkUsageDescription to ios/Runner/Info.plist; if the user declines, same-Wi-Fi peers cannot use local addresses. Android needs android.permission.INTERNET.
  • Background and network changes are untested. Expect the peer connection to go failed when the app is suspended or switches Wi-Fi and cellular; the WebRtcConn closes and leaves the swarm. Listen for it (WebRtcConn.done) and upgrade again on resume; the relayed connection is the fallback.
  • iOS debug builds die when the debugger detaches; test off-cable with flutter run --release. A USB-tethered iPhone gives ICE an extra fast link; unplug before measuring. Turn Wi-Fi off to test cellular.
  • Build failures: missing CMake/Ninja on PATH, or (Android) no NDK found from the configured C compiler. iOS once failed to load because Mbed TLS’s everest/p256m were built shared; native/CMakeLists.txt keeps BUILD_SHARED_LIBS a plain variable for that reason; do not turn it into a cache variable.
  • Stream writes are framed in 16 KiB chunks and the reader parses a byte stream, so frames may span data-channel messages; do not assume one read() returns one write().
  • closeWrite waits up to 10 s for FIN_ACK, then continues silently. write after closeWrite, after the remote sent STOP_SENDING, or after reset throws.
  • Loopback raw WebRTC: two RtcPeerConnections with RtcConfig(bindAddress: '127.0.0.1'), SDP passed in memory (as above). No network or STUN needed.
  • Two hosts without a relay: connect them over TCP on 127.0.0.1, open a signalingProtocol stream, and run initiateWebRtc / answerWebRtc + serveStreams(host.mux) (test/libp2p/webrtc_conn_test.dart).
  • Through a relay in-process: a third dart_libp2p host with Libp2p.relay(true) and Libp2p.forceReachability(Reachability.public) listening on /ip4/127.0.0.1/tcp/0; reserve on it, connect via /p2p-circuit, then upgrade. The relay starts its hop service only once it sees itself as public, so retry reserve until it succeeds (test/libp2p/webrtc_transport_test.dart).
  • TURN: docker compose -f docker/coturn/compose.yaml up -d (user test/test on 127.0.0.1:3478) or set TURN_URL; tests tagged turn skip when nothing answers.
  • Browsers: tests tagged browser drive js-libp2p via Playwright in interop/browser (Node 22, npm install there first); go relay tests need Go. Each group skips when its tools are missing.
  • Native code is built by the hook on dart test; the first run is slow. Use timeouts of at least 30 s for anything that waits on ICE.
  • Never put a TURN shared secret (static-auth-secret) in an app or web page; mint time-limited credentials on a backend.
  • Never present relayOnly: true as hiding a user’s IP address.
  • Never register handlers on /webrtc-signaling/0.0.1 yourself while WebRtcTransport is attached; it owns that protocol.
  • Never change the signalling or framing wire format (varint-prefixed protobuf, type always encoded, 16 KiB frames, init channel label, empty labels for stream channels): js-libp2p interop depends on it.
  • Always close() peer connections and data channels you create directly; the isolate stays alive while any exist.
  • Do not rely on WebRTC DTLS certificates for identity: identity comes from the Noise-authenticated relayed connection that carried signalling.
  • Do not edit bindings.g.dart by hand; regenerate with ffigen.

Built by Stephan Februarygithub.com/stephanfebLinkedIn