---
name: dart-libp2p-webrtc
description: 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.
triggers: dart_libp2p_webrtc, WebRtcTransport, RtcConfig, RtcPeerConnection, RtcDataChannel, WebRtcConn, webrtc-signaling, /p2p-circuit/webrtc, libdatachannel, js-libp2p webrtc
---

# dart_libp2p_webrtc

## 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-host` skill.

## Install

```yaml
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.

```dart
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.

## Core workflow

### A host that accepts and dials WebRTC

```dart
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

```dart
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

```dart
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.

### Which path did ICE pick?

```dart
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'));
}
```

### TURN

```dart
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.

```dart
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)

```js
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)`.

### Raw data channels, own signalling

```dart
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)

```dart
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

`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.

## Gotchas

- **`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.

## Testing

- **Loopback raw WebRTC:** two `RtcPeerConnection`s 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.

## 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: 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.

## 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-host` skill.
