
gRPC client moving raw message bytes via a JNI-backed native runtime; accepts encoded requests and returns encoded responses, supports unary/streaming suspend/Flow calls, deadlines, metadata, and flexible transports.
Kotlin Multiplatform gRPC client backed by a native library built from grpc-rust (tonic), called over JNI.
kurpc moves raw message bytes: you pass the encoded request and receive the encoded response,
so any protobuf runtime works (protobuf-java, protobuf-javalite, Wire,
kotlinx-serialization-protobuf, ...). For protobuf-java and protobuf-javalite messages, the
protoc-gen-kurpc plugin generates typed clients (Generated clients).
suspend functions and
Flows. Cancelling the coroutine cancels the call.-bin), keep-alive, wait-for-ready.I forget why I named it "kurpc". "u" may be "you", you can customize it. Or means Unix domain socket.
| Target | Requirement | Native library |
|---|---|---|
| JVM | Java 8+ |
kurpc-natives-jvm jar, one per OS / arch |
| Android |
minSdk 21 |
Included in the AAR |
Desktop classifiers of kurpc-natives-jvm:
| Classifier | Platform |
|---|---|
linux-x86_64 |
Linux x86-64, glibc 2.17+ |
linux-aarch64 |
Linux AArch64, glibc 2.17+ |
macos-x86_64 |
macOS Intel |
macos-aarch64 |
macOS Apple silicon |
windows-x86_64 |
Windows x64 |
windows-aarch64 |
Windows on ARM |
The AAR contains arm64-v8a, armeabi-v7a, x86 and x86_64.
kurpc is published to Maven Central under the group io.github.xchacha20-poly1305. Make sure
mavenCentral() is in your repositories:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add to gradle/libs.versions.toml:
[versions]
kurpc = "0.2.0"
[libraries]
kurpc = { module = "io.github.xchacha20-poly1305:kurpc", version.ref = "kurpc" }
kurpc-natives-jvm = { module = "io.github.xchacha20-poly1305:kurpc-natives-jvm", version.ref = "kurpc" }Kotlin Multiplatform project (JVM and Android targets):
// build.gradle.kts
kotlin {
jvm()
android { /* ... */ }
sourceSets {
commonMain.dependencies {
implementation(libs.kurpc)
}
jvmMain.dependencies {
// One entry per platform the JVM application runs on.
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("linux-x86_64") })
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("macos-aarch64") })
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("windows-x86_64") })
}
}
}JVM-only project:
// build.gradle.kts
dependencies {
implementation(libs.kurpc)
implementation(variantOf(libs.kurpc.natives.jvm) { classifier("linux-x86_64") })
}Android-only project (the AAR already contains the native libraries):
// build.gradle.kts
dependencies {
implementation(libs.kurpc)
}// build.gradle.kts
dependencies {
implementation("io.github.xchacha20-poly1305:kurpc:0.2.0")
// JVM only:
implementation("io.github.xchacha20-poly1305:kurpc-natives-jvm:0.2.0:linux-x86_64")
}A natives jar holds the library as a classpath resource. On first use kurpc picks the resource
matching the running OS and architecture, extracts it under java.io.tmpdir and loads it, so
shipping several classifiers in one distribution is fine. The lookup order is:
Kurpc.loadLibrary("/path/to/libkurpc.so"), called before the first Channel is created.kurpc.library.path system property (-Dkurpc.library.path=/path/to/libkurpc.so).kurpc-natives-jvm.System.loadLibrary("kurpc"), which searches java.library.path.Use 1, 2 or 4 when you build the library yourself or cannot write to the temporary directory.
R8 / ProGuard keep rules ship with both the JVM jar and the AAR; no extra configuration is needed.
All examples use the package io.github.xchacha20_poly1305.kurpc.
import io.github.xchacha20_poly1305.kurpc.*
val channel = Channel(
ChannelConfig(
transport = Transport.Tcp("127.0.0.1", 50051),
authority = "localhost",
),
)A Channel reconnects after a dropped connection and is safe to share between coroutines. Close it when you are done; calls still running fail with ChannelClosedException.
channel.use {
// calls
}Method names are full gRPC paths: /<package>.<Service>/<Method>. Encode the request and decode
the response with your protobuf runtime:
val reply: ByteArray = channel.unary(
method = "/helloworld.Greeter/SayHello",
request = HelloRequest.newBuilder().setName("kurpc").build().toByteArray(),
)
println(HelloReply.parseFrom(reply).message)unaryCall returns the response headers and trailers along with the message:
val response: UnaryResponse = channel.unaryCall("/helloworld.Greeter/SayHello", request)
val requestId = response.headers.text("x-request-id")Server streaming and bidirectional streaming return a cold Flow: each collection starts a new
call, and cancelling the collection cancels the call.
// Server streaming
channel.serverStreaming("/example.Feed/Subscribe", request).collect { bytes ->
println(Event.parseFrom(bytes))
}
// Client streaming: sends every element, half-closes, returns the single response.
val summary: ByteArray = channel.clientStreaming(
"/example.Upload/Send",
flowOf(chunk1, chunk2, chunk3),
)
// Bidirectional streaming: requests are sent while responses are collected.
channel.bidiStreaming("/example.Chat/Talk", outgoing).collect { bytes ->
println(ChatMessage.parseFrom(bytes))
}protoc-gen-kurpc is a protoc plugin that generates a client per service for the classes of
protobuf-java or protobuf-javalite (proto2 and proto3; editions are not supported). With the
protobuf Gradle plugin, in a JVM or Android
module:
// build.gradle.kts
import com.google.protobuf.gradle.id
plugins {
id("com.google.protobuf") version "0.10.0"
}
dependencies {
implementation(libs.kurpc)
implementation("com.google.protobuf:protobuf-javalite:4.36.2")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.36.2"
}
plugins {
id("kurpc") {
// The `all` jar is self-contained; the plugin runs it with `java -jar`.
artifact = "io.github.xchacha20-poly1305:protoc-gen-kurpc:0.1.1:all@jar"
}
}
generateProtoTasks {
all().configureEach {
builtins {
named("java") { option("lite") }
}
plugins {
id("kurpc")
}
}
}
}For service Greeter in package helloworld, the plugin writes GreeterKurpc.kt into the
Java package of the proto file, with:
GreeterClient(invoker: CallInvoker): one function per method. Unary and client-streaming
methods are suspend functions; server-streaming and bidirectional methods return a Flow.
Every function takes an optional CallOptions.GreeterKurpc: SERVICE_NAME and a MethodDescriptor per method (GreeterKurpc.sayHello).val greeter = GreeterClient(channel)
val reply: HelloReply = greeter.sayHello(
HelloRequest.newBuilder().setName("kurpc").build(),
CallOptions(timeout = 5.seconds),
)Channel implements CallInvoker. A test can pass its own CallInvoker to the generated client
instead of opening a channel.
The descriptors also work without the generated client, through the typed unary,
serverStreaming, clientStreaming and bidiStreaming extensions of CallInvoker, for
example in a wrapper that retries every stream the same way:
fun <Request, Response> CallInvoker.retrying(
method: MethodDescriptor<Request, Response>,
request: Request,
): Flow<Response> = serverStreaming(method, request).retry { it is TransportException }A response that its message class cannot parse fails the call with a StatusException of
INTERNAL, whose cause is the parser's exception.
Outside Gradle, run the jar through a wrapper script, for example
protoc --plugin=protoc-gen-kurpc=./protoc-gen-kurpc.sh --kurpc_out=src/main/kotlin ..., where
the script runs java -jar protoc-gen-kurpc-0.1.1-all.jar "$@".
import kotlin.time.Duration.Companion.seconds
val options = CallOptions(
timeout = 5.seconds,
metadata = Metadata.of("authorization" to "Bearer $token") +
Metadata.ofBinary("trace-bin" to traceBytes),
waitForReady = false, // fail fast with UNAVAILABLE instead of waiting for a connection
)
channel.unary("/helloworld.Greeter/SayHello", request, options)Metadata set in ChannelConfig.metadata is sent with every call on the channel.
| Exception | Cause |
|---|---|
StatusException |
The call ended with a non-OK gRPC status |
TransportException |
The connection could not be established or broke |
ChannelClosedException |
The channel was closed before or during the call |
IllegalArgumentException |
Invalid input, such as a malformed method name or metadata |
try {
channel.unary("/helloworld.Greeter/SayHello", request)
} catch (e: StatusException) {
when (e.code) {
Status.Code.NOT_FOUND -> println("not found: ${e.description}")
Status.Code.DEADLINE_EXCEEDED -> println("timed out")
else -> throw e
}
}StatusException also carries the raw details bytes (grpc-status-details-bin) and the
trailers.
// Unix domain socket, also supported on Windows 10 1803+.
// A path starting with "\u0000" is a Linux / Android abstract socket.
Transport.Unix("/run/app/grpc.sock")
// Windows named pipe.
Transport.WindowsNamedPipe("""\\.\pipe\app""")
// A connected socket file descriptor (Unix only); kurpc takes ownership of it.
Transport.FileDescriptor { connectAndReturnFd() }
// Any byte stream. The connector runs again for every reconnect.
Transport.Custom {
val socket = Socket("example.com", 50051)
Connection.of(socket.getInputStream(), socket.getOutputStream())
}Connection.of has adapters for an InputStream / OutputStream pair and a blocking
ByteChannel (both platforms), and for an Android LocalSocket.
kurpc does not encrypt on its own. For TLS, use a Transport.Custom whose connector returns
Connection.tls(...), and set secure = true so the request uses the https scheme:
val channel = Channel(
ChannelConfig(
transport = Transport.Custom {
Connection.tls(Socket("api.example.com", 443), serverName = "api.example.com")
},
authority = "api.example.com",
secure = true,
),
)Connection.tls verifies the certificate against the platform's trust store by default and
offers ALPN h2. Pass your own SSLSocketFactory for a custom trust store or client
certificates. ALPN needs JDK 8u252+ on the JVM and, on Android below API 29, the platform's
Conscrypt provider.
import kotlin.time.Duration.Companion.seconds
ChannelConfig(
transport = Transport.Tcp("127.0.0.1", 50051),
connectTimeout = 5.seconds,
keepAlive = KeepAlive(interval = 30.seconds, timeout = 10.seconds, whileIdle = true),
userAgent = "my-app/1.0",
)
// Worker threads of the shared native runtime. Call before creating the first Channel.
Kurpc.configure(workerThreads = 2)Requirements: JDK 21, the Rust toolchain from rust-toolchain.toml, and the Android SDK with the
NDK version from gradle/libs.versions.toml.
./gradlew :kurpc:check # JVM tests on JDK 21 and JDK 8, Android Lint
./gradlew :protoc-gen-kurpc:check # protoc plugin; its tests call the test server through generated clients
cargo test --workspace # Rust tests
make publish_local # publishToMavenLocal for the host platform
make native_all # every natives jar and the AAR; needs cargo-zigbuildKotlin Multiplatform gRPC client backed by a native library built from grpc-rust (tonic), called over JNI.
kurpc moves raw message bytes: you pass the encoded request and receive the encoded response,
so any protobuf runtime works (protobuf-java, protobuf-javalite, Wire,
kotlinx-serialization-protobuf, ...). For protobuf-java and protobuf-javalite messages, the
protoc-gen-kurpc plugin generates typed clients (Generated clients).
suspend functions and
Flows. Cancelling the coroutine cancels the call.-bin), keep-alive, wait-for-ready.I forget why I named it "kurpc". "u" may be "you", you can customize it. Or means Unix domain socket.
| Target | Requirement | Native library |
|---|---|---|
| JVM | Java 8+ |
kurpc-natives-jvm jar, one per OS / arch |
| Android |
minSdk 21 |
Included in the AAR |
Desktop classifiers of kurpc-natives-jvm:
| Classifier | Platform |
|---|---|
linux-x86_64 |
Linux x86-64, glibc 2.17+ |
linux-aarch64 |
Linux AArch64, glibc 2.17+ |
macos-x86_64 |
macOS Intel |
macos-aarch64 |
macOS Apple silicon |
windows-x86_64 |
Windows x64 |
windows-aarch64 |
Windows on ARM |
The AAR contains arm64-v8a, armeabi-v7a, x86 and x86_64.
kurpc is published to Maven Central under the group io.github.xchacha20-poly1305. Make sure
mavenCentral() is in your repositories:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Add to gradle/libs.versions.toml:
[versions]
kurpc = "0.2.0"
[libraries]
kurpc = { module = "io.github.xchacha20-poly1305:kurpc", version.ref = "kurpc" }
kurpc-natives-jvm = { module = "io.github.xchacha20-poly1305:kurpc-natives-jvm", version.ref = "kurpc" }Kotlin Multiplatform project (JVM and Android targets):
// build.gradle.kts
kotlin {
jvm()
android { /* ... */ }
sourceSets {
commonMain.dependencies {
implementation(libs.kurpc)
}
jvmMain.dependencies {
// One entry per platform the JVM application runs on.
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("linux-x86_64") })
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("macos-aarch64") })
implementation(project.dependencies.variantOf(libs.kurpc.natives.jvm) { classifier("windows-x86_64") })
}
}
}JVM-only project:
// build.gradle.kts
dependencies {
implementation(libs.kurpc)
implementation(variantOf(libs.kurpc.natives.jvm) { classifier("linux-x86_64") })
}Android-only project (the AAR already contains the native libraries):
// build.gradle.kts
dependencies {
implementation(libs.kurpc)
}// build.gradle.kts
dependencies {
implementation("io.github.xchacha20-poly1305:kurpc:0.2.0")
// JVM only:
implementation("io.github.xchacha20-poly1305:kurpc-natives-jvm:0.2.0:linux-x86_64")
}A natives jar holds the library as a classpath resource. On first use kurpc picks the resource
matching the running OS and architecture, extracts it under java.io.tmpdir and loads it, so
shipping several classifiers in one distribution is fine. The lookup order is:
Kurpc.loadLibrary("/path/to/libkurpc.so"), called before the first Channel is created.kurpc.library.path system property (-Dkurpc.library.path=/path/to/libkurpc.so).kurpc-natives-jvm.System.loadLibrary("kurpc"), which searches java.library.path.Use 1, 2 or 4 when you build the library yourself or cannot write to the temporary directory.
R8 / ProGuard keep rules ship with both the JVM jar and the AAR; no extra configuration is needed.
All examples use the package io.github.xchacha20_poly1305.kurpc.
import io.github.xchacha20_poly1305.kurpc.*
val channel = Channel(
ChannelConfig(
transport = Transport.Tcp("127.0.0.1", 50051),
authority = "localhost",
),
)A Channel reconnects after a dropped connection and is safe to share between coroutines. Close it when you are done; calls still running fail with ChannelClosedException.
channel.use {
// calls
}Method names are full gRPC paths: /<package>.<Service>/<Method>. Encode the request and decode
the response with your protobuf runtime:
val reply: ByteArray = channel.unary(
method = "/helloworld.Greeter/SayHello",
request = HelloRequest.newBuilder().setName("kurpc").build().toByteArray(),
)
println(HelloReply.parseFrom(reply).message)unaryCall returns the response headers and trailers along with the message:
val response: UnaryResponse = channel.unaryCall("/helloworld.Greeter/SayHello", request)
val requestId = response.headers.text("x-request-id")Server streaming and bidirectional streaming return a cold Flow: each collection starts a new
call, and cancelling the collection cancels the call.
// Server streaming
channel.serverStreaming("/example.Feed/Subscribe", request).collect { bytes ->
println(Event.parseFrom(bytes))
}
// Client streaming: sends every element, half-closes, returns the single response.
val summary: ByteArray = channel.clientStreaming(
"/example.Upload/Send",
flowOf(chunk1, chunk2, chunk3),
)
// Bidirectional streaming: requests are sent while responses are collected.
channel.bidiStreaming("/example.Chat/Talk", outgoing).collect { bytes ->
println(ChatMessage.parseFrom(bytes))
}protoc-gen-kurpc is a protoc plugin that generates a client per service for the classes of
protobuf-java or protobuf-javalite (proto2 and proto3; editions are not supported). With the
protobuf Gradle plugin, in a JVM or Android
module:
// build.gradle.kts
import com.google.protobuf.gradle.id
plugins {
id("com.google.protobuf") version "0.10.0"
}
dependencies {
implementation(libs.kurpc)
implementation("com.google.protobuf:protobuf-javalite:4.36.2")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.36.2"
}
plugins {
id("kurpc") {
// The `all` jar is self-contained; the plugin runs it with `java -jar`.
artifact = "io.github.xchacha20-poly1305:protoc-gen-kurpc:0.1.1:all@jar"
}
}
generateProtoTasks {
all().configureEach {
builtins {
named("java") { option("lite") }
}
plugins {
id("kurpc")
}
}
}
}For service Greeter in package helloworld, the plugin writes GreeterKurpc.kt into the
Java package of the proto file, with:
GreeterClient(invoker: CallInvoker): one function per method. Unary and client-streaming
methods are suspend functions; server-streaming and bidirectional methods return a Flow.
Every function takes an optional CallOptions.GreeterKurpc: SERVICE_NAME and a MethodDescriptor per method (GreeterKurpc.sayHello).val greeter = GreeterClient(channel)
val reply: HelloReply = greeter.sayHello(
HelloRequest.newBuilder().setName("kurpc").build(),
CallOptions(timeout = 5.seconds),
)Channel implements CallInvoker. A test can pass its own CallInvoker to the generated client
instead of opening a channel.
The descriptors also work without the generated client, through the typed unary,
serverStreaming, clientStreaming and bidiStreaming extensions of CallInvoker, for
example in a wrapper that retries every stream the same way:
fun <Request, Response> CallInvoker.retrying(
method: MethodDescriptor<Request, Response>,
request: Request,
): Flow<Response> = serverStreaming(method, request).retry { it is TransportException }A response that its message class cannot parse fails the call with a StatusException of
INTERNAL, whose cause is the parser's exception.
Outside Gradle, run the jar through a wrapper script, for example
protoc --plugin=protoc-gen-kurpc=./protoc-gen-kurpc.sh --kurpc_out=src/main/kotlin ..., where
the script runs java -jar protoc-gen-kurpc-0.1.1-all.jar "$@".
import kotlin.time.Duration.Companion.seconds
val options = CallOptions(
timeout = 5.seconds,
metadata = Metadata.of("authorization" to "Bearer $token") +
Metadata.ofBinary("trace-bin" to traceBytes),
waitForReady = false, // fail fast with UNAVAILABLE instead of waiting for a connection
)
channel.unary("/helloworld.Greeter/SayHello", request, options)Metadata set in ChannelConfig.metadata is sent with every call on the channel.
| Exception | Cause |
|---|---|
StatusException |
The call ended with a non-OK gRPC status |
TransportException |
The connection could not be established or broke |
ChannelClosedException |
The channel was closed before or during the call |
IllegalArgumentException |
Invalid input, such as a malformed method name or metadata |
try {
channel.unary("/helloworld.Greeter/SayHello", request)
} catch (e: StatusException) {
when (e.code) {
Status.Code.NOT_FOUND -> println("not found: ${e.description}")
Status.Code.DEADLINE_EXCEEDED -> println("timed out")
else -> throw e
}
}StatusException also carries the raw details bytes (grpc-status-details-bin) and the
trailers.
// Unix domain socket, also supported on Windows 10 1803+.
// A path starting with "\u0000" is a Linux / Android abstract socket.
Transport.Unix("/run/app/grpc.sock")
// Windows named pipe.
Transport.WindowsNamedPipe("""\\.\pipe\app""")
// A connected socket file descriptor (Unix only); kurpc takes ownership of it.
Transport.FileDescriptor { connectAndReturnFd() }
// Any byte stream. The connector runs again for every reconnect.
Transport.Custom {
val socket = Socket("example.com", 50051)
Connection.of(socket.getInputStream(), socket.getOutputStream())
}Connection.of has adapters for an InputStream / OutputStream pair and a blocking
ByteChannel (both platforms), and for an Android LocalSocket.
kurpc does not encrypt on its own. For TLS, use a Transport.Custom whose connector returns
Connection.tls(...), and set secure = true so the request uses the https scheme:
val channel = Channel(
ChannelConfig(
transport = Transport.Custom {
Connection.tls(Socket("api.example.com", 443), serverName = "api.example.com")
},
authority = "api.example.com",
secure = true,
),
)Connection.tls verifies the certificate against the platform's trust store by default and
offers ALPN h2. Pass your own SSLSocketFactory for a custom trust store or client
certificates. ALPN needs JDK 8u252+ on the JVM and, on Android below API 29, the platform's
Conscrypt provider.
import kotlin.time.Duration.Companion.seconds
ChannelConfig(
transport = Transport.Tcp("127.0.0.1", 50051),
connectTimeout = 5.seconds,
keepAlive = KeepAlive(interval = 30.seconds, timeout = 10.seconds, whileIdle = true),
userAgent = "my-app/1.0",
)
// Worker threads of the shared native runtime. Call before creating the first Channel.
Kurpc.configure(workerThreads = 2)Requirements: JDK 21, the Rust toolchain from rust-toolchain.toml, and the Android SDK with the
NDK version from gradle/libs.versions.toml.
./gradlew :kurpc:check # JVM tests on JDK 21 and JDK 8, Android Lint
./gradlew :protoc-gen-kurpc:check # protoc plugin; its tests call the test server through generated clients
cargo test --workspace # Rust tests
make publish_local # publishToMavenLocal for the host platform
make native_all # every natives jar and the AAR; needs cargo-zigbuild