TL;DR — py-libp2p tracks the gap.
Why care? Three reasons the ecosystem keeps coming back to:
NAT traversal for real users. Most consumer machines sit behind at least one NAT, often more. WebRTC's ICE + STUN + TURN dance is the only widely-deployed answer that browsers speak natively. If your Python peer wants to talk to a browser peer — or two NATed Python peers want to talk directly with only a light-touch relay — WebRTC is the door.
Native multiplexing. WebRTC data channels give you real per-stream framing over SCTP. That means the transport itself carries a "muxer," so you can skip the Yamux / Mplex upgrade step that a TCP-based libp2p connection needs.
Server-to-browser without an HTTP server. With/webrtc-direct, a Python node can accept an inbound connection from a browser peer purely via a UDP socket + a certificate hash embedded in the multiaddr. No signaling server, no HTTPS termination, no TURN unless traversal forces you there.
That's the "why." Now for the "how" — and specifically, why "how" turned into 6,700 lines of Python that took four review rounds to get right.
What actually landed
The PR body says it, but here it is in one paragraph so you know the surface area:
WebRTCDirectTransport(/webrtc-direct) andWebRTCPrivateTransport(/webrtc, relay-signaled).
- Spec-compliant data-channel framing — uvarint length-prefixed protobuf, with the full FIN / FIN_ACK / STOP_SENDING / RESET state machine.
- In-band data channels for application streams; the Noise handshake channel (
id=0) stays pre-negotiated per spec.
- Signaling with the bilateral
ICE_DONEfix from ) is where browser dial actually happens, and we wanted the v1 skeleton in the tree so it stops being vaporware.
Architecture in one picture
Two flavors of WebRTC show up in py-libp2p, and it took me a while to keep them straight. Here they are, side by side:
CODEflowchart LR
subgraph Direct["/webrtc-direct (server-reachable)"]
A[Client] -- SDP offer built<br/>from server multiaddr --> B[Server<br/>publishes cert hash]
A <-. DTLS + data channels .-> B
end
subgraph Private["/webrtc (both peers NATed)"]
C[Peer A] --- R[Relay v2]
D[Peer B] --- R
C -. signaling over relay:<br/>SDP + ICE + ICE_DONE .-> D
C <-. direct DTLS + data channels .-> D
end
Two transports, one shared substrate: certs, SDP, Noise binding, stream framing, and the trio↔asyncio bridge. Only signaling differs.
Inside a single peer, the runtime layout looks like this:
CODEflowchart TB
subgraph Trio["Trio world (py-libp2p)"]
T1[Host / Swarm]
T2[WebRTCConnection]
T3[WebRTCStream × N]
T4[Application handlers]
end
subgraph Bridge["AsyncioBridge<br/>(background daemon thread)"]
BR[asyncio event loop]
end
subgraph Asyncio["Asyncio world (aiortc)"]
A1[RTCPeerConnection]
A2[RTCDataChannel × N]
A3[DTLS / ICE / SCTP]
end
T1 --> T2 --> T3
T2 -- run_coro --> BR
BR -- runs on --> A1
A1 --> A2 --> A3
A2 -. on_data via trio_token .-> T3
The interesting seam is the arrow labelled trio_token: aiortc calls into us from an asyncio thread, and we need those callbacks to hand data to trio memory channels safely. If we skip the token, we get
RunFinishedErrorand worse. That code lives instream.py:382-410— I'll come back to it.
Deep dive #1 — framing, or "why bare protobuf was the wrong wire"
If you read the first draft of this PR, application-level writes were serialized as bare protobuf
Messagebytes and shoved straight intodata_channel.send(...). That "works" on the happy path for small payloads. It's also wrong for two independent reasons — and once you understand both, you understand why the spec is written the way it is.
Reason 1: SCTP has a 16 KiB message ceiling
The fixed it by adding a bilateral
ICE_DONEmessage — each side signals it's done, and neither closes until bothICE_DONEs are seen:
CODEInitiator Responder (via relay)
──── SDP_OFFER ─────────────────────────>
<─── SDP_ANSWER ─────────────────────────
<──> ICE_CANDIDATE (trickle, both ways) <>
──── ICE_DONE ───────────────────────────>
<─── ICE_DONE ───────────────────────────
(both sides close signaling stream)
That's what
signaling.pyimplements. It's not the most complicated code in the PR, but it's the piece that cannot be handwaved with "we'll add ICE_DONE later" — it changes the state machine of when both peers agree the signaling phase has ended.
The seam that will save us a rewrite:_apply_ice_credentials()
There's one more piece of "spec is in flight" that we chose to handle architecturally rather than by writing a TODO.
The are the map. The seams are ready. The wires need connecting.
References
- — structured concurrency
Written by Yash Kumar Saini — reflections on a merged py-libp2p PR. Corrections and pushback welcome; I'll update this post in place if I got anything wrong. The PR itself is the source of truth.
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
Adding WebRTC to py-libp2p: Two Runtimes, Three Specs, and One Unforgiving Private Slot
- ▸ A quick honest note before we start
- ▸ The problem: why a Python libp2p wants WebRTC at all
- ▸ What actually landed
- ▸ Architecture in one picture
- ▸ Deep dive #1 — framing, or "why bare protobuf was the wrong wire"
- ↳ Reason 1: SCTP has a 16 KiB message ceiling
- ↳ Reason 2: bare protobuf isn't self-delimiting inside a stream frame
- ▸ Deep dive #2 — the certificate pin that actually pins
- ↳ Setting the scene
- ↳ The naïve pin
- ↳ The fix
- ↳ The canary
- ▸ Deep dive #3 — bridging two runtimes without blowing up
- ▸ Deep dive #4 — Noise XX, but bound to DTLS
- ▸ Deep dive #5 — signaling, ICE_DONE, and a spec fix in flight
- ▸ The seam that will save us a rewrite: _apply_ice_credentials()
- ▸ Testing: 153 tests, but only one of them mattered
- ▸ What the review looked like from the inside
- ▸ Where it goes next
- ▸ The one takeaway I want you to leave with
- ▸ Try it, and honest expectations
- ▸ References
SOCIAL SHARE CARD GENERATOR