Status: Implemented (JS), cross-language specification.
Canonical JS implementation: packages/core/src/webrtc/signaling.js,
packages/core/src/interfaces/webrtc.js.
(tracking).
This document specifies the WebRTC transport upgrade for Reticulum: a mechanism that bridges Reticulum's low-bandwidth, MTU-constrained discovery protocol with a high-bandwidth, NAT-traversing WebRTC data channel between two peers. It exists so that implementations in JavaScript, Python, Node.js and other languages can interoperate.
This transport is not part of the Python Reticulum reference
implementation. The JavaScript implementation (reticulum-js) is the first and
defines the wire format recorded here; other implementations MUST match it.
Reticulum's base MTU is 500 bytes and discovery is built for low-bandwidth,
high-latency links (LoRa, packet radio). Real-time, high-throughput
applications — e.g. Yjs CRDT synchronisation between collaborative editors —
need far more than that. WebRTC gives two peers a direct, DTLS-encrypted
RTCDataChannel with ~16 KiB message sizes and sub-second latency, while
Reticulum still handles discovery, routing, and the encrypted signaling of the
WebRTC session itself.
The result is a two-stage connection lifecycle:
Both peers own a SINGLE Reticulum destination sharing the same application name:
| Constant | Value | Notes |
|---|---|---|
| Destination name | rns.webrtc |
Configurable; both peers MUST agree. |
| Destination type | SINGLE |
Has an identity; announces are signed. |
app_data (capability flag) |
0x01 |
One byte. Identifies a WebRTC peer. |
The destination name is a normal Reticulum dotted aspect string. The
application may override it (e.g. "noflo_editor.webrtc_peer") to run multiple
isolated WebRTC peer meshes on one node; peers using different names never see
each other.
The app_data byte is a bitmask. 0x01 currently means "WebRTC peer,
non-trickle ICE". Higher bits are reserved for future capability signalling
(e.g. trickle ICE). A receiver tests (app_data[0] & 0x01) == 0x01. Unknown
bits MUST be ignored, and an app_data that does not start with byte 0x01
(set) MUST NOT be treated as a WebRTC peer.
Implementation detail: peers filter announces by matching the destination name's 10-byte
name_hash(SHA-256(name)[:10]), then by the capability flag inapp_data. This avoids a separate announce-handler registry.
WebRTC session descriptions (SDP) are too large for a single Reticulum packet (typically 1–4 KiB). They are transferred as Reticulum Resources over an encrypted Link, which transparently fragment, sequence, checksum, and re-assemble the payload across the 500-byte MTU.
Each Resource carries a tiny framed envelope so the receiver can distinguish message types unambiguously (the signaling Link is dedicated, but self- describing bytes survive future additions like trickle ICE without protocol changes):
byte 0 : message type
bytes 1.. : UTF-8 SDP text (for OFFER / ANSWER)
| Message type | Byte value | Contents after the type byte | Direction |
|---|---|---|---|
OFFER |
0x01 |
Full SDP offer (with ICE candidates) | initiator → responder |
ANSWER |
0x02 |
Full SDP answer (with ICE candidates) | responder → initiator |
CANDIDATE |
0x03 |
Reserved — a trickle-ICE candidate | future |
First cut is non-trickle. Each side waits for ICE gathering to reach
complete and ships its full local description (SDP including all gathered
candidates) in a single Resource. Trickle ICE (incremental 0x03 candidate
messages) is a future optimisation; reserving the type byte now keeps that
addition wire-compatible.
Resources carrying SDP SHOULD be sent uncompressed (no bz2): SDP is small
and this keeps the signaling path free of any compression dependency. A
receiver SHOULD cap the accepted Resource size at 64 KiB (MAX_SDP_SIZE) as a
§10.4 bomb defense — WebRTC SDP is never that large.
Initiator (A) Responder (B)
───────────── ────────────
Destination "rns.webrtc", SINGLE Destination "rns.webrtc", SINGLE
app_data = 0x01 app_data = 0x01
announce() ──────────── announce ────────► (hears A; path table updated)
◄────────── announce ───────── announce()
(application decides to connect)
Destination.OUT(B) ──── LINKREQUEST ───────► acceptLink()
createLink() ◄──── LRPROOF ─── (responder signs with identity)
◄──── LRRTT ────────── (link ACTIVE on both sides)
RTCPeerConnection.createDataChannel("reticulum")
createOffer() → setLocalDescription(offer)
(wait for ICE gathering == complete)
Resource(OFFER) ──── RESOURCE_ADV/parts ─► resource.whenComplete()
setRemoteDescription(offer)
→ fires "datachannel"
createAnswer()
→ setLocalDescription(answer)
(wait for ICE gathering == complete)
◄──── RESOURCE_ADV/parts ─── Resource(ANSWER)
resource.whenComplete()
setRemoteDescription(answer)
... RTCDataChannel reaches "open" on both sides ...
wrap channel → WebRTCInterface wrap channel → WebRTCInterface
rns.addInterface(iface) rns.addInterface(iface)
link.teardown() link.teardown()
Destination.recall).Link.initiate / createLink) and wait for
it to become ACTIVE.RTCPeerConnection and a data channel (label "reticulum").createOffer() → setLocalDescription(offer) → wait for ICE gathering to
complete (non-trickle).0x01 + the offer SDP.0x02).setRemoteDescription({ type: "answer", sdp }).LINKREQUEST on the signaling destination, accept the link.0x01 (OFFER).RTCPeerConnection and register a handler for its datachannel
event (the negotiated channel arrives there once the offer is applied).setRemoteDescription({ type: "offer", sdp }).createAnswer() → setLocalDescription(answer) → wait for ICE gathering.0x02 + the answer SDP.Once a data channel is open, it is wrapped as an ordinary Reticulum interface
and registered with the node's transport/routing layer (rns.addInterface).
From that point, RNS traffic between the two peers flows over the WebRTC
channel; the signaling Link has done its job and is torn down.
The interface is message-oriented with raw framing: one RNS packet (in
its raw wire form, no HDLC 0x7E byte-stuffing) per binary message, because an
RTCDataChannel is already a reliable, ordered, message-bounded transport.
Each inbound binary message is parsed into a Packet and dispatched; each
outbound Packet is serialized and sent as one binary message. Messages that
are not binary, or no larger than the RNS header minimum (19 bytes), are
silently dropped (defensive floor, matching every reference interface).
The interface advertises a nominal bitrate of 50 000 000 (~50 Mbit/s): the channel is a direct peer link, far above any RF transport. (Note: at present the JS transport selects outbound interfaces by hop count, not by bitrate — a direct 1-hop WebRTC path wins over N mesh hops that way. Full bitrate-based prioritisation is a separate, deferred task; see work doc #19.)
The interface is not a reconnecting dialer. Re-establishing a WebRTC session requires re-running signaling, so a closed channel is terminal: the application builds a fresh interface by initiating a new connection.
These are the canonical values from the JS implementation; other languages MUST use the same values.
| Constant | Value |
|---|---|
| Default destination name | "rns.webrtc" |
| Capability flag (app_data byte) | 0x01 |
| SDP type — OFFER | 0x01 |
| SDP type — ANSWER | 0x02 |
| SDP type — CANDIDATE (reserved) | 0x03 |
| Max accepted SDP Resource size | 65536 bytes (64 * 1024) |
| Data channel label | "reticulum" |
| WebRTC interface nominal bitrate | 50000000 bits/s |
| ICE mode (first cut) | non-trickle (full local description per Resource) |
An implementation needs:
app_data, signed
announces, the transport "announce" event (or equivalent announce-handler
registry), encrypted Links, and the Resource transfer protocol
(advertisement + windowed parts + proof).RTCPeerConnection /
RTCDataChannel API (createOffer, createAnswer, setLocalDescription,
setRemoteDescription, createDataChannel, the datachannel event, and
ICE gathering state).The JS core is dependency-injection-first: it never imports a WebRTC
runtime. Browsers use the global RTCPeerConnection automatically; Node.js
has no native WebRTC and obtains one from a companion package; tests inject a
mock. Implementations in other languages should follow the same seam so the
signaling state machine is testable without a live network.
Suggested runtimes:
RTCPeerConnection / RTCDataChannel.aiortc (BSD-3-Clause —
compatible with this project's EUPL-1.2).node-datachannel
or wrtc (MIT), via a companion package.For NAT traversal, pass STUN/TURN servers through the WebRTC configuration
(iceServers) when constructing the peer connection; this is the application's
responsibility, not the protocol's.
Illustrative only — names follow the Python Reticulum reference (RNS.*):
# --- Both peers: announce capability ---
identity = RNS.Identity()
dest = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, "rns.webrtc")
dest.set_default_app_data(bytes([0x01])) # capability flag
dest.announce()
# Filter announces for the "rns.webrtc" name_hash + the 0x01 app_data flag.
class WebRTCAnnounceHandler(RNS.Transport.AnnounceHandler):
received_aspect = "rns.webrtc"
def received_announce(self, destination_hash, announced_identity, app_data):
if not (app_data and len(app_data) >= 1 and (app_data[0] & 0x01)):
return
# surface peer (destination_hash, announced_identity) to the app
# --- Initiator ---
out = RNS.Destination(announced_identity, RNS.Destination.OUT,
RNS.Destination.SINGLE, "rns.webrtc")
link = RNS.Link(out); link.activate() # LINKREQUEST / LRPROOF / LRRTT
pc = RTCPeerConnection(configuration=rtc_config) # aiortc
dc = pc.createDataChannel("reticulum")
offer = await pc.createOffer(); await pc.setLocalDescription(offer)
await wait_for_ice_complete(pc) # non-trickle
await send_resource(link, 0x01, pc.localDescription.sdp.encode()) # OFFER
answer_sdp = await receive_resource(link, expect=0x02) # ANSWER
await pc.setRemoteDescription(SessionDescription(sdp=answer_sdp, type="answer"))
await dc_open(dc)
transport.add_interface(WebRTCInterface(dc, pc)) # raw framing, bitrate 50_000_000
await link.teardown()
# --- Responder: on link + first Resource ---
def on_incoming_link_request(...): link = dest.accept_request(...)
def on_resource(resource):
data = resource.data # assembled bytes
assert data[0] == 0x01 # OFFER
pc = RTCPeerConnection(configuration=rtc_config)
@pc.on("datachannel")
def _(channel): adopt_when_open(channel, pc)
await pc.setRemoteDescription(SessionDescription(sdp=data[1:].decode(), type="offer"))
answer = await pc.createAnswer(); await pc.setLocalDescription(answer)
await wait_for_ice_complete(pc)
await send_resource(link, 0x02, pc.localDescription.sdp.encode()) # ANSWER
send_resource frames and ships a Resource:
def send_resource(link, type_byte, sdp_bytes):
framed = bytes([type_byte]) + sdp_bytes
RNS.Resource(framed, link, auto_compress=False).advertise()
def receive_resource(link, expect):
# await the link's first Resource; return its SDP bytes (after type check)
...
MAX_SDP_SIZE (64 KiB) as a
denial-of-service guard.0x03 candidate messages) — type reserved, not yet used.bitrate property exists
on every interface; selection by bitrate is a separate task.