Skip to content

Kerobit/KPeer

Repository files navigation

KPeer

KPeer is a Kotlin Multiplatform WebRTC transport focused on peer-to-peer data channels.

It provides:

  • one WebRTC PeerConnection per 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.

Install

Current library coordinates:

implementation("com.kerobit:kpeer:1.0.0")

KMM

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 platformContext on Android
  • consume the generated Apple framework or CocoaPod on iOS
  • consume the JS browser artifact on JS

Android

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")
        }
    }
}

iOS (CocoaPods)

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.

JS

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.

Core concepts

  • KPeer owns the peer connection and signaling lifecycle
  • KChannel owns sending and receiving messages for one data channel
  • KPeerSignal is the payload exchanged through your signaling backend
  • KSubscription is a small cancellable handle for callback subscriptions

Creating a peer

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.

Signaling

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:

  1. peer1 emits a signal
  2. you deliver that signal to peer2
  3. peer2 calls peer2.signal(signal)
  4. peer2 emits a response signal
  5. you deliver that signal back to peer1
  6. peer1 calls peer1.signal(signal)

Supported signaling payloads are:

  • KPeerOffer
  • KPeerAnswer
  • KPeerIceCandidate

ICE batching (outgoing) and buffering (incoming)

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

Connection timeout

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.

Signaling with Flow

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.

Signaling with callbacks

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.

Creating channels

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.

Flow API

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())

Callback API

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()

Complete sample

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))
    }
}

Closing and disposing

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.

Notes

  • KChannel exposes separate streams for binary and text messages
  • the callback API is a thin wrapper over the Flow API
  • 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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors