
Zero-copy, deterministic sans-io WebRTC data-channel stack: caller-clocked ICE/DTLS/SCTP cores, SDP/STUN codecs, virtual-time tests, timeline fixtures, seeded fuzz and zero-copy buffers.
WebRTC data channels for Kotlin Multiplatform — zero-copy, sans-io, and deterministic under test.
com.ditchoom:webrtc is a WebRTC stack written in common Kotlin on top of the DitchOoM
buffer and socket
libraries. ICE, DTLS, SCTP and the JSEP/SDP machinery are ours, in commonMain — this is not a
wrapper around libwebrtc, and there is no native blob to ship. The one exception is the browser, where
raw UDP does not exist and the implementation delegates to the platform's own RTCPeerConnection.
It establishes and carries data channels against Chrome, Firefox, WebKit, Pion and werift over real NAT kernels in CI, on IPv4, IPv6 and dual-stack.
dependencies {
implementation("com.ditchoom:webrtc:0.20.0")
}Media (RTP/SRTP) is not implemented. This is the data-channel half of WebRTC — the part that carries bytes, and the part with no codec or hardware dependencies.
One thing has to be decided before a session exists, and the library asks rather than guessing: which
sockets to bind. Everything else — interface enumeration, STUN/TURN gathering, the DTLS identity, mDNS
candidate privacy — is defaulted by nativePeerConnection.
The binder stays a parameter on purpose. A factory that bound its own UDP socket could never share one with a QUIC-P2P connection, which is the arrangement this stack is built for, so the constraint is structural rather than documented.
fun peerConnection(
scope: CoroutineScope,
seed: Long,
iceServers: List<IceServer> = emptyList(), // listOf(IceServer("stun:stun.example.org"))
) = nativePeerConnection(
scope = scope,
// The one seam between a virtual-time test and a real kernel — and a *parameter*, never
// something the factory binds for itself, so one demuxed UDP socket can carry more than
// this session.
binder = udpDatagramBinder(),
// `stun:` / `turn:` URLs. Parsed, resolved, and gathered on per address family; whatever is
// unusable (a `turns:` URL, a TURN server with no credential) is reported, never dropped.
iceServers = iceServers,
// Off here ONLY because both peers share this process: mDNS publishes host candidates as a
// `<uuid>.local` name the peer must resolve over real multicast. Leave it on in an app —
// that is the default, and what a browser does unconditionally.
mdns = false,
random = Random(seed),
)That block is not an illustration — it is compiled and run on every build, as
ReadmeQuickstartTest, which
stands two peers up over real loopback UDP and echoes a message between them.
Then run the ordinary offer/answer dance and ship the results over your signaling:
val pc = peerConnection(scope, seed = Random.nextLong(), iceServers = listOf(IceServer("stun:stun.example.org")))
val chat = pc.createDataChannel(DataChannelConfig(label = "chat"))
val offer = pc.createOffer()
pc.setLocalDescription(SdpType.Offer, offer)
signaling.send(offer) // your transport, your protocol
pc.setRemoteDescription(SdpType.Answer, signaling.awaitAnswer())
// Trickle both ways (RFC 8838)
scope.launch { pc.localIceCandidates.collect { signaling.send(it) } }
scope.launch { signaling.remoteCandidates.collect { pc.addIceCandidate(it) } }
pc.connectionState.first { it is PeerConnectionState.Connected }Signaling is a seam, never an implementation. Descriptions and candidates cross as SDP text and
candidate: lines — the exact currency a browser RTCPeerConnection speaks — and how they reach the
peer is yours. WebRTC standardizes no signaling protocol, and keeping it out is also what makes the
offer/answer machine testable without a network.
A data channel is a buffer-flow Connection<DataChannelPayload>. A message is Text or Binary —
the same distinction the wire draws (RFC 8831 §6.6 PPIDs) and that a browser peer sees as a String or
an ArrayBuffer on event.data:
chat.send(buffer) // binary, zero-copy — the buffer is the message
chat.send("hello") // text, UTF-8 encoded once inside the stack
chat.receive().collect { message ->
when (message) {
is DataChannelPayload.Binary -> use(message.bytes) // not a copy
is DataChannelPayload.Text -> use(message.text)
}
message.release() // a received Binary transfers its buffer to you
}
pc.incomingDataChannels.collect { channel -> /* the peer opened one (ondatachannel) */ }Text carries characters rather than bytes, so a message that claims to be a string cannot hold
something that is not valid UTF-8 — the receiving browser would reject it, and by then it is already on
the wire.
Channels are ordered and reliable by default; DataChannelConfig selects DeliveryOrder.Unordered and
the partial-reliability modes — SctpReliability.MaxRetransmits(n) or .MaxLifetime(duration)
(PR-SCTP, RFC 3758).
peerConnectionSupport() is a sealed type, so the branch is checked at compile time rather than
discovered at runtime:
val pc = when (val support = peerConnectionSupport()) {
is PeerConnectionSupport.BrowserDelegated -> support.create(scope, iceServers)
PeerConnectionSupport.Native -> nativePeerConnection(scope, binder, iceServers)
}Both arms return the same RtcPeerConnection, so everything after this point is shared code.
| Platform | Status |
|---|---|
| JVM, Android | Full — real UDP, pure-Kotlin DTLS |
| Linux (x64, arm64) | Full |
| macOS, iOS (x64, arm64, simulator) | Full |
| Browser (js, wasmJs) |
Full, by delegating to the platform RTCPeerConnection
|
| Node (js) |
Not usable. There is no RTCPeerConnection to delegate to, and the native path's DTLS handshake fails with a typed DtlsFailureReason.BackendUnavailable — the raw-ECDH primitive it needs is async-only on that target |
| tvOS (arm64, x64, simulator) |
Full, since socket-udp 4.1.6 published its UDP actual for these targets |
| watchOS (simulator only) |
Full on the simulator, same as tvOS. There is no watchOS device target in the matrix — watchosArm64 (32-bit arm64_32) is omitted because buffer-crypto publishes no klib for it, so a real watch app cannot link this library yet |
| Windows | Via the JVM. There is no Kotlin/Native Windows target |
DTLS 1.2 and 1.3 are pure Kotlin in commonMain on every non-browser target, so there is no platform
where the handshake depends on a native library being present.
<uuid>.local and publish
your own instead of your LAN address. On by default in nativePeerConnection (mdns = false opts
out); a hand-built NativePeerConnection still wires it explicitly via withMulticastMdns(…).IceServer URLs — stun: and turn: are parsed, resolved and gathered on per
address family by systemIceGathering(). What we cannot honour is refused by reason rather than
dropped: turns: and ?transport=tcp (no TCP TURN client), a turn: server with no credential, a
name that does not resolve. Each arrives as a typed IceGatheringNotice, so an absent candidate is
never indistinguishable from a server that failed to answer.systemNetworkMonitor() is push-based where the OS offers a signal
(ConnectivityManager, AF_NETLINK, NWPathMonitor, the JDK-21 routing socket) and polls where it
does not. Pair it with IceRestartPolicy.OnNetworkChange to restart automatically when the selected
pair's interface goes away. The default stays Manual, because a restart is a renegotiation only your
signaling channel can carry.Deliberately not implemented: SCTP multihoming, RFC 8260 interleaving, and HEARTBEAT (ICE consent freshness owns path liveness); RFC 6525's four non-originated reconfiguration request types (decoded and explicitly refused); and establishing a new DTLS association on renegotiation (a re-answer implying the opposite role is refused with a typed reason).
| Wrapping libwebrtc | This | |
|---|---|---|
| Copies | Every frame crosses a JNI/ObjC boundary as a ByteArray/NSData
|
ReadBuffer/WriteBuffer end to end |
| Determinism | None — libwebrtc owns its threads, timers and RNG | Every core is caller-clocked; the whole stack runs under runTest virtual time |
| Reproducing a bug | Black box | A field capture becomes a committed fixture that replays in milliseconds, forever |
| Platforms | Android/iOS | The full KMP matrix, JVM and Linux servers included |
Every protocol state machine is a pure handle(event, now): List<Output> plus a
nextDeadline(now): Instant? — no dispatcher, no Clock.System, no Random.Default, no I/O inside a
core. Drivers own I/O; cores own truth. The practical consequence is that a full ICE + DTLS + SCTP
establishment completes at zero wall-clock on every target, and a 90-second field saga replays in
milliseconds.
com.ditchoom:webrtc-testsuite publishes the harness this project tests itself with: an in-memory
virtual network with NAT profiles, a TURN server, and an impairment pipe — all under runTest virtual
time, on every platform, with no Docker and no OS sockets.
dependencies {
testImplementation("com.ditchoom:webrtc-testsuite:0.20.0")
}runTest {
withWebRtcHarness(scope = backgroundScope, clock = virtualClock) {
natType(NatType.Symmetric) // both peers behind a symmetric NAT (RFC 4787)
relayOnly() // force the TURN-relay path
impaired(loss = 0.05) // 5% packet loss
assertEquals("ping", roundTrip("ping"))
assertNoBufferLeaks() // every buffer the scenario allocated came back
}
}assertNoBufferLeaks() closes the scenario, joins everything it launched, and fails unless every chunk
is back in the pool — the invariant this library holds itself to (directive #6), now assertable over your
own code. bufferCensus() returns the numbers instead of asserting on them.
./gradlew build # all modules, all host-available targets
./gradlew allTests # tests across every module + platformRequires JDK 21 (via toolchain). Apple targets build on macOS only.
webrtc is the only artifact most consumers need; it brings the rest transitively.
| Module | What |
|---|---|
webrtc |
PeerConnection, the JSEP state machine, data channels — the consumer API |
webrtc-sdp |
SDP parse/serialize — no I/O |
webrtc-stun |
STUN/TURN wire codec (RFC 8489/8656) + sans-io client machines |
webrtc-ice |
ICE agent (RFC 8445, 8838) — sans-io core, gathering seams, udpDatagramBinder()
|
webrtc-dtls |
DTLS 1.2/1.3 + the DTLS-SRTP exporter |
webrtc-sctp |
SCTP (RFC 8831) + DCEP (RFC 8832) |
webrtc-testsuite |
The published harness above |
Every published artifact ships a Dokka javadoc jar, and the KDoc is where the detail lives — most types here document the decision behind them, not just the signature. Alongside it: CHANGELOG · Design principles · Testing strategy
Apache 2.0 — see LICENSE.md.
WebRTC data channels for Kotlin Multiplatform — zero-copy, sans-io, and deterministic under test.
com.ditchoom:webrtc is a WebRTC stack written in common Kotlin on top of the DitchOoM
buffer and socket
libraries. ICE, DTLS, SCTP and the JSEP/SDP machinery are ours, in commonMain — this is not a
wrapper around libwebrtc, and there is no native blob to ship. The one exception is the browser, where
raw UDP does not exist and the implementation delegates to the platform's own RTCPeerConnection.
It establishes and carries data channels against Chrome, Firefox, WebKit, Pion and werift over real NAT kernels in CI, on IPv4, IPv6 and dual-stack.
dependencies {
implementation("com.ditchoom:webrtc:0.20.0")
}Media (RTP/SRTP) is not implemented. This is the data-channel half of WebRTC — the part that carries bytes, and the part with no codec or hardware dependencies.
One thing has to be decided before a session exists, and the library asks rather than guessing: which
sockets to bind. Everything else — interface enumeration, STUN/TURN gathering, the DTLS identity, mDNS
candidate privacy — is defaulted by nativePeerConnection.
The binder stays a parameter on purpose. A factory that bound its own UDP socket could never share one with a QUIC-P2P connection, which is the arrangement this stack is built for, so the constraint is structural rather than documented.
fun peerConnection(
scope: CoroutineScope,
seed: Long,
iceServers: List<IceServer> = emptyList(), // listOf(IceServer("stun:stun.example.org"))
) = nativePeerConnection(
scope = scope,
// The one seam between a virtual-time test and a real kernel — and a *parameter*, never
// something the factory binds for itself, so one demuxed UDP socket can carry more than
// this session.
binder = udpDatagramBinder(),
// `stun:` / `turn:` URLs. Parsed, resolved, and gathered on per address family; whatever is
// unusable (a `turns:` URL, a TURN server with no credential) is reported, never dropped.
iceServers = iceServers,
// Off here ONLY because both peers share this process: mDNS publishes host candidates as a
// `<uuid>.local` name the peer must resolve over real multicast. Leave it on in an app —
// that is the default, and what a browser does unconditionally.
mdns = false,
random = Random(seed),
)That block is not an illustration — it is compiled and run on every build, as
ReadmeQuickstartTest, which
stands two peers up over real loopback UDP and echoes a message between them.
Then run the ordinary offer/answer dance and ship the results over your signaling:
val pc = peerConnection(scope, seed = Random.nextLong(), iceServers = listOf(IceServer("stun:stun.example.org")))
val chat = pc.createDataChannel(DataChannelConfig(label = "chat"))
val offer = pc.createOffer()
pc.setLocalDescription(SdpType.Offer, offer)
signaling.send(offer) // your transport, your protocol
pc.setRemoteDescription(SdpType.Answer, signaling.awaitAnswer())
// Trickle both ways (RFC 8838)
scope.launch { pc.localIceCandidates.collect { signaling.send(it) } }
scope.launch { signaling.remoteCandidates.collect { pc.addIceCandidate(it) } }
pc.connectionState.first { it is PeerConnectionState.Connected }Signaling is a seam, never an implementation. Descriptions and candidates cross as SDP text and
candidate: lines — the exact currency a browser RTCPeerConnection speaks — and how they reach the
peer is yours. WebRTC standardizes no signaling protocol, and keeping it out is also what makes the
offer/answer machine testable without a network.
A data channel is a buffer-flow Connection<DataChannelPayload>. A message is Text or Binary —
the same distinction the wire draws (RFC 8831 §6.6 PPIDs) and that a browser peer sees as a String or
an ArrayBuffer on event.data:
chat.send(buffer) // binary, zero-copy — the buffer is the message
chat.send("hello") // text, UTF-8 encoded once inside the stack
chat.receive().collect { message ->
when (message) {
is DataChannelPayload.Binary -> use(message.bytes) // not a copy
is DataChannelPayload.Text -> use(message.text)
}
message.release() // a received Binary transfers its buffer to you
}
pc.incomingDataChannels.collect { channel -> /* the peer opened one (ondatachannel) */ }Text carries characters rather than bytes, so a message that claims to be a string cannot hold
something that is not valid UTF-8 — the receiving browser would reject it, and by then it is already on
the wire.
Channels are ordered and reliable by default; DataChannelConfig selects DeliveryOrder.Unordered and
the partial-reliability modes — SctpReliability.MaxRetransmits(n) or .MaxLifetime(duration)
(PR-SCTP, RFC 3758).
peerConnectionSupport() is a sealed type, so the branch is checked at compile time rather than
discovered at runtime:
val pc = when (val support = peerConnectionSupport()) {
is PeerConnectionSupport.BrowserDelegated -> support.create(scope, iceServers)
PeerConnectionSupport.Native -> nativePeerConnection(scope, binder, iceServers)
}Both arms return the same RtcPeerConnection, so everything after this point is shared code.
| Platform | Status |
|---|---|
| JVM, Android | Full — real UDP, pure-Kotlin DTLS |
| Linux (x64, arm64) | Full |
| macOS, iOS (x64, arm64, simulator) | Full |
| Browser (js, wasmJs) |
Full, by delegating to the platform RTCPeerConnection
|
| Node (js) |
Not usable. There is no RTCPeerConnection to delegate to, and the native path's DTLS handshake fails with a typed DtlsFailureReason.BackendUnavailable — the raw-ECDH primitive it needs is async-only on that target |
| tvOS (arm64, x64, simulator) |
Full, since socket-udp 4.1.6 published its UDP actual for these targets |
| watchOS (simulator only) |
Full on the simulator, same as tvOS. There is no watchOS device target in the matrix — watchosArm64 (32-bit arm64_32) is omitted because buffer-crypto publishes no klib for it, so a real watch app cannot link this library yet |
| Windows | Via the JVM. There is no Kotlin/Native Windows target |
DTLS 1.2 and 1.3 are pure Kotlin in commonMain on every non-browser target, so there is no platform
where the handshake depends on a native library being present.
<uuid>.local and publish
your own instead of your LAN address. On by default in nativePeerConnection (mdns = false opts
out); a hand-built NativePeerConnection still wires it explicitly via withMulticastMdns(…).IceServer URLs — stun: and turn: are parsed, resolved and gathered on per
address family by systemIceGathering(). What we cannot honour is refused by reason rather than
dropped: turns: and ?transport=tcp (no TCP TURN client), a turn: server with no credential, a
name that does not resolve. Each arrives as a typed IceGatheringNotice, so an absent candidate is
never indistinguishable from a server that failed to answer.systemNetworkMonitor() is push-based where the OS offers a signal
(ConnectivityManager, AF_NETLINK, NWPathMonitor, the JDK-21 routing socket) and polls where it
does not. Pair it with IceRestartPolicy.OnNetworkChange to restart automatically when the selected
pair's interface goes away. The default stays Manual, because a restart is a renegotiation only your
signaling channel can carry.Deliberately not implemented: SCTP multihoming, RFC 8260 interleaving, and HEARTBEAT (ICE consent freshness owns path liveness); RFC 6525's four non-originated reconfiguration request types (decoded and explicitly refused); and establishing a new DTLS association on renegotiation (a re-answer implying the opposite role is refused with a typed reason).
| Wrapping libwebrtc | This | |
|---|---|---|
| Copies | Every frame crosses a JNI/ObjC boundary as a ByteArray/NSData
|
ReadBuffer/WriteBuffer end to end |
| Determinism | None — libwebrtc owns its threads, timers and RNG | Every core is caller-clocked; the whole stack runs under runTest virtual time |
| Reproducing a bug | Black box | A field capture becomes a committed fixture that replays in milliseconds, forever |
| Platforms | Android/iOS | The full KMP matrix, JVM and Linux servers included |
Every protocol state machine is a pure handle(event, now): List<Output> plus a
nextDeadline(now): Instant? — no dispatcher, no Clock.System, no Random.Default, no I/O inside a
core. Drivers own I/O; cores own truth. The practical consequence is that a full ICE + DTLS + SCTP
establishment completes at zero wall-clock on every target, and a 90-second field saga replays in
milliseconds.
com.ditchoom:webrtc-testsuite publishes the harness this project tests itself with: an in-memory
virtual network with NAT profiles, a TURN server, and an impairment pipe — all under runTest virtual
time, on every platform, with no Docker and no OS sockets.
dependencies {
testImplementation("com.ditchoom:webrtc-testsuite:0.20.0")
}runTest {
withWebRtcHarness(scope = backgroundScope, clock = virtualClock) {
natType(NatType.Symmetric) // both peers behind a symmetric NAT (RFC 4787)
relayOnly() // force the TURN-relay path
impaired(loss = 0.05) // 5% packet loss
assertEquals("ping", roundTrip("ping"))
assertNoBufferLeaks() // every buffer the scenario allocated came back
}
}assertNoBufferLeaks() closes the scenario, joins everything it launched, and fails unless every chunk
is back in the pool — the invariant this library holds itself to (directive #6), now assertable over your
own code. bufferCensus() returns the numbers instead of asserting on them.
./gradlew build # all modules, all host-available targets
./gradlew allTests # tests across every module + platformRequires JDK 21 (via toolchain). Apple targets build on macOS only.
webrtc is the only artifact most consumers need; it brings the rest transitively.
| Module | What |
|---|---|
webrtc |
PeerConnection, the JSEP state machine, data channels — the consumer API |
webrtc-sdp |
SDP parse/serialize — no I/O |
webrtc-stun |
STUN/TURN wire codec (RFC 8489/8656) + sans-io client machines |
webrtc-ice |
ICE agent (RFC 8445, 8838) — sans-io core, gathering seams, udpDatagramBinder()
|
webrtc-dtls |
DTLS 1.2/1.3 + the DTLS-SRTP exporter |
webrtc-sctp |
SCTP (RFC 8831) + DCEP (RFC 8832) |
webrtc-testsuite |
The published harness above |
Every published artifact ships a Dokka javadoc jar, and the KDoc is where the detail lives — most types here document the decision behind them, not just the signature. Alongside it: CHANGELOG · Design principles · Testing strategy
Apache 2.0 — see LICENSE.md.