KPeer is a Kotlin Multiplatform WebRTC transport focused on peer-to-peer data channels.
It provides:
- one WebRTC
PeerConnectionper peer - multiple
DataChannels on the same connection - signaling primitives (
Offer,Answer,IceCandidate) Flow-based APIs- callback-based APIs
- automatic renegotiation when new channels are added later
KPeer does not include a signaling server. You exchange KPeerSignal messages with your own transport.
Current library coordinates:
implementation("com.kerobit:kpeer:1.0.0")If you are consuming KPeer from another Kotlin Multiplatform shared module, depend on it from commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.kerobit:kpeer:1.0.0")
}
}
}Then use the platform-specific pieces only where needed:
- pass
platformContexton Android - consume the generated Apple framework or CocoaPod on iOS
- consume the JS browser artifact on JS
In a plain Android module:
dependencies {
implementation("com.kerobit:kpeer:1.0.0")
}In KMM Android source sets, you can also depend on it explicitly from androidMain if that better matches your project layout:
kotlin {
sourceSets {
androidMain.dependencies {
implementation("com.kerobit:kpeer:1.0.0")
}
}
}The library generates a CocoaPod named KerobitKPeer and an Apple framework named KerobitKPeer.framework.
In a Podfile:
pod 'KerobitKPeer', :path => '../path/to/kpeer/library'If you are integrating from a KMM producer module, the generated CocoaPods configuration is centered around:
cocoapods {
name = "KerobitKPeer"
framework {
baseName = "KerobitKPeer"
}
}That means Swift/Objective-C consumption should use the generated KerobitKPeer framework/module as the main entry point.
The library includes a browser-oriented Kotlin/JS target backed by native browser WebRTC APIs.
The generated JS module name is kpeer, and the browser bundle output file is kpeer.js.
If you consume it from another KMM/Kotlin project:
kotlin {
sourceSets {
jsMain.dependencies {
implementation("com.kerobit:kpeer:1.0.0")
}
}
}If you consume the built JS artifact directly, the relevant outputs are the JS library bundle generated by the browser target.
This target is intended for browser environments where RTCPeerConnection and RTCDataChannel are available.
KPeerowns the peer connection and signaling lifecycleKChannelowns sending and receiving messages for one data channelKPeerSignalis the payload exchanged through your signaling backendKSubscriptionis a small cancellable handle for callback subscriptions
val peer = KPeer(
context = KPeerContext(
platformContext = androidContext // Android only
),
config = KPeerConfig(
initiator = true
)
)If you already have your own application scope:
val peer = KPeer(
context = KPeerContext(
scope = scope,
platformContext = androidContext
),
config = KPeerConfig(
initiator = true
)
)initiator = true creates the first offer.
initiator = false waits for the first remote offer.
KPeer does not provide a signaling transport. You are expected to move KPeerSignal objects between peers using your own transport layer.
The simplest mental model is:
peer1emits a signal- you deliver that signal to
peer2 peer2callspeer2.signal(signal)peer2emits a response signal- you deliver that signal back to
peer1 peer1callspeer1.signal(signal)
Supported signaling payloads are:
KPeerOfferKPeerAnswerKPeerIceCandidate
KPeer can reduce the burst of KPeerIceCandidate messages by batching outgoing local ICE candidates.
It also can buffer incoming remote ICE candidates until the remote description is set (which makes delivery order less important).
Configure it via KPeerConfig.iceCandidateEmitPolicy:
val peer = KPeer(
context = KPeerContext(scope = scope, platformContext = androidContext),
config = KPeerConfig(
initiator = true,
iceCandidateEmitPolicy = KIceCandidateEmitPolicy(
flushInterval = 50L, // ms (0 disables batching)
maxBatchSize = null // per-batch limit (null = only bounded by flushInterval)
)
)
)You can configure a connection-level timeout via KPeerConfig.connectionTimeoutMs.
If the peer does not transition to CONNECTED within the given time, it transitions to FAILED and the underlying transport is closed.
This sample wires two peers together locally using the signals flow:
val peer1 = KPeer(
context = KPeerContext(scope = scope, platformContext = androidContext),
config = KPeerConfig(initiator = true)
)
val peer2 = KPeer(
context = KPeerContext(scope = scope, platformContext = androidContext),
config = KPeerConfig(initiator = false)
)
scope.launch {
peer1.signals.collect { signal ->
peer2.signal(signal)
}
}
scope.launch {
peer2.signals.collect { signal ->
peer1.signal(signal)
}
}In a real application, those emitted signals would go through websockets, HTTP, Bluetooth, or any other signaling transport instead of being forwarded locally.
The same idea using the callback API:
val peer1 = KPeer(
context = KPeerContext(scope = scope, platformContext = androidContext),
config = KPeerConfig(initiator = true)
)
val peer2 = KPeer(
context = KPeerContext(scope = scope, platformContext = androidContext),
config = KPeerConfig(initiator = false)
)
peer1.onSignal { signal ->
scope.launch {
peer2.signal(signal)
}
}
peer2.onSignal { signal ->
scope.launch {
peer1.signal(signal)
}
}That local wiring is only for demonstration. In production, each onSignal callback would send the payload to the remote device, and the receiving side would call peer.signal(...) when the payload arrives.
val chatChannel = peer.createChannel(
KChannelConfig(
label = "chat",
ordered = true,
reliable = true
)
)Multiple channels can live on the same peer connection:
val chat = peer.createChannel(KChannelConfig(label = "chat"))
val telemetry = peer.createChannel(
KChannelConfig(
label = "telemetry",
ordered = false,
reliable = false
)
)
val fileTransfer = peer.createChannel(
KChannelConfig(
label = "file-transfer"
)
)If a new channel is created after the initial connection is already established, KPeer triggers renegotiation automatically on the initiator side.
Discover channels:
scope.launch {
peer.channels.collect { channel ->
println("Channel available: ${channel.label}")
}
}Forward signaling:
scope.launch {
peer.signals.collect { signal ->
signalingBackend.send(signal)
}
}Observe peer connection state:
scope.launch {
peer.connectionState.collect { state ->
println("connection state: $state")
}
}Receive channel messages:
scope.launch {
chatChannel?.bytes?.collect { bytes ->
println("bytes: ${bytes.size}")
}
}
scope.launch {
chatChannel?.text?.collect { message ->
println("text: $message")
}
}Observe channel state:
scope.launch {
chatChannel?.state?.collect { state ->
println("channel state: $state")
}
}Send messages:
chatChannel?.send("hello")
chatChannel?.send("hello".encodeToByteArray())If you prefer a callback-oriented API, you can subscribe directly:
val signalSubscription = peer.onSignal { signal ->
signalingBackend.send(signal)
}
val stateSubscription = peer.onConnectionState { state ->
println("peer state: $state")
}
val channelSubscription = peer.onChannel { channel ->
println("channel available: ${channel.label}")
channel.onText { message ->
println("text message: $message")
}
channel.onBytes { bytes ->
println("bytes received: ${bytes.size}")
}
channel.onState { state ->
println("channel state: $state")
}
}Every callback registration returns a KSubscription:
signalSubscription.cancel()
stateSubscription.cancel()
channelSubscription.cancel()This sample shows two peers in the same process. In a real application they would live in different devices or runtimes, and you would use websockets, HTTP, or another transport to exchange signaling messages.
import com.kerobit.kpeer.KChannelConfig
import com.kerobit.kpeer.KPeer
import com.kerobit.kpeer.KPeerConfig
import com.kerobit.kpeer.KPeerContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
fun sample(androidContext: Any?) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val peer1 = KPeer(
context = KPeerContext(
scope = scope,
platformContext = androidContext
),
config = KPeerConfig(initiator = true)
)
val peer2 = KPeer(
context = KPeerContext(
scope = scope,
platformContext = androidContext
),
config = KPeerConfig(initiator = false)
)
peer1.onSignal { signal ->
scope.launch {
peer2.signal(signal)
}
}
peer2.onSignal { signal ->
scope.launch {
peer1.signal(signal)
}
}
peer2.onChannel { channel ->
channel.onText { message ->
println("peer2 got text: $message")
}
channel.onBytes { bytes ->
println("peer2 got bytes: ${bytes.size}")
}
}
scope.launch {
val chat = peer1.createChannel(KChannelConfig(label = "chat"))
chat?.onState { state ->
println("peer1 chat state: $state")
}
chat?.send("hello from peer1")
chat?.send(byteArrayOf(1, 2, 3, 4))
}
}Close the peer connection only:
peer.close()Dispose the peer and also dispose the internally created context scope:
peer.dispose()dispose() only cancels the scope if that scope was created internally by KPeerContext. If you passed your own scope, ownership stays with your application.
KChannelexposes separate streams for binary and text messages- the callback API is a thin wrapper over the
FlowAPI - signaling ordering and delivery are still the responsibility of your app
- there is still no full perfect-negotiation strategy for simultaneous overlapping offers
- JVM and Linux targets currently expose stubs; WebRTC transport is implemented on the supported native platforms