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
What it is
Section titled “What it is”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-hostskill.
Install
Section titled “Install”dependencies: dart_libp2p: ^4.9.0 dart_libp2p_webrtc: ^0.1.0Needs 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'; // unawaitedimport 'dart:convert'; // utf8import 'dart:typed_data'; // Uint8Listimport '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.
Core workflow
Section titled “Core workflow”A host that accepts and dials WebRTC
Section titled “A host that accepts and dials WebRTC”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);}Listener: book a reservation on a relay
Section titled “Listener: book a reservation on a relay”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 peerstoreawait dialer.connect(AddrInfo(peerId, [MultiAddr('$relayAddr/p2p-circuit')]));final conn = await webrtc.upgrade(peerId); // direct from here onprint(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.
Which path did ICE pick?
Section titled “Which path did ICE pick?”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();The browser side (js-libp2p 3.x)
Section titled “The browser side (js-libp2p 3.x)”import '@stephanfeb/libp2p-udx/multiaddr' // 0.1.1+: only if the relay speaks UDXimport { 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).
Raw data channels, own signalling
Section titled “Raw data channels, own signalling”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 sectionfinal offer = await alice.createOffer(); // waits for ICE gatheringbob.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 orderawait chat.opened;await chat.send(Uint8List.fromList(utf8.encode('hello'))); // awaits backpressureawait 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']);Key concepts / API map
Section titled “Key concepts / API map”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 beforestart().WebRtcTransport.upgrade(PeerId) -> Future<Conn>: move a relayed peer onto a direct connection.WebRtcTransport.canDial: true when/webrtcdirectly 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();highWaterMark1 MiB,lowWaterMark256 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.
Gotchas
Section titled “Gotchas”attach(host)beforehost.start(). Without itdial/upgradethrowStateError('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/webrtclistener is open; without it inbound signalling streams are reset. Only a bare/webrtcis 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/upgradethrowStateError('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-circuitfirst. - Connect to the relay before dialing through it. dart_libp2p’s circuit client looks the relay up in the peerstore.
upgradecan 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/webrtconly to avoid this.- No automatic upgrade. Relayed peers stay relayed until you call
upgrade. If ICE fails,upgradethrows aftersignalingTimeout(30 s) and the relayed connection stays up; it may be limited (go-libp2p defaults: 2 min, 128 KiB; seeconn.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 iniceServers. 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, noturns:/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.relayOnlyis 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/createAnswerwait 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
createDataChannelbeforecreateOffer, or the SDP has no application section. (initiateWebRtccreates and later closes theinitchannel.) - 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 unknownin js-libp2p means the relay’s reservation lists a/udxaddress. Import@stephanfeb/libp2p-udx/multiaddr(the browser entry, not the package root, which needs Node.js) orregistry.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: hoston Linux; never shipallow-loopback-peers. - iOS local network prompt. Add
NSLocalNetworkUsageDescriptiontoios/Runner/Info.plist; if the user declines, same-Wi-Fi peers cannot use local addresses. Android needsandroid.permission.INTERNET. - Background and network changes are untested. Expect the peer
connection to go
failedwhen the app is suspended or switches Wi-Fi and cellular; theWebRtcConncloses and leaves the swarm. Listen for it (WebRtcConn.done) andupgradeagain 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.txtkeepsBUILD_SHARED_LIBSa 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 onewrite(). closeWritewaits up to 10 s for FIN_ACK, then continues silently.writeaftercloseWrite, after the remote sent STOP_SENDING, or after reset throws.
Testing
Section titled “Testing”- Loopback raw WebRTC: two
RtcPeerConnections withRtcConfig(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 asignalingProtocolstream, and runinitiateWebRtc/answerWebRtc+serveStreams(host.mux)(test/libp2p/webrtc_conn_test.dart). - Through a relay in-process: a third dart_libp2p host with
Libp2p.relay(true)andLibp2p.forceReachability(Reachability.public)listening on/ip4/127.0.0.1/tcp/0; reserve on it, connect via/p2p-circuit, thenupgrade. The relay starts its hop service only once it sees itself as public, so retryreserveuntil it succeeds (test/libp2p/webrtc_transport_test.dart). - TURN:
docker compose -f docker/coturn/compose.yaml up -d(usertest/teston127.0.0.1:3478) or setTURN_URL; tests taggedturnskip when nothing answers. - Browsers: tests tagged
browserdrive js-libp2p via Playwright ininterop/browser(Node 22,npm installthere 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.
Red lines
Section titled “Red lines”- 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: trueas hiding a user’s IP address. - Never register handlers on
/webrtc-signaling/0.0.1yourself whileWebRtcTransportis attached; it owns that protocol. - Never change the signalling or framing wire format (varint-prefixed
protobuf,
typealways encoded, 16 KiB frames,initchannel 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.dartby hand; regenerate with ffigen.
Reference
Section titled “Reference”- Guides: https://cryptopeer.net/libp2p/webrtc/
- https://cryptopeer.net/libp2p/webrtc/getting-started/
- https://cryptopeer.net/libp2p/webrtc/browsers/
- https://cryptopeer.net/libp2p/webrtc/flutter/
- https://cryptopeer.net/libp2p/webrtc/networks/
- https://cryptopeer.net/libp2p/webrtc/turn/
- https://cryptopeer.net/libp2p/webrtc/data-channels/
- https://cryptopeer.net/libp2p/webrtc/how-it-works/
- https://cryptopeer.net/libp2p/webrtc/interop/
- Repository: https://github.com/stephanfeb/dart-libp2p-webrtc (
DESIGN.md,example/,test/) - Package: https://pub.dev/packages/dart_libp2p_webrtc
- Spec: https://github.com/libp2p/specs/blob/master/webrtc/webrtc.md
- Host setup: the
dart-libp2p-hostskill.