Module transport-service-worker

Service Worker Transport (Experimental)

The service worker transport hosts a ksrpc service inside a browser service worker, allowing web applications to offload RPC processing to a background thread. Communication uses the MessagePort API.

This transport is experimental and requires explicit opt-in with @ExperimentalKsrpc:

@OptIn(ExperimentalKsrpc::class)

Module and dependencies

implementation("com.monkopedia.ksrpc:ksrpc-service-worker:1.0.0-RC3")

Platform availability

Platform Supported
JS Yes
WASM Yes
JVM No
Native No

Worker entry point

In the service worker script, use onServiceWorkerConnection to listen for incoming connections:

import com.monkopedia.ksrpc.annotation.ExperimentalKsrpc
import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.channels.registerDefault
import com.monkopedia.ksrpc.webworker.onServiceWorkerConnection

@OptIn(ExperimentalKsrpc::class)
fun main() {
val env = ksrpcEnvironment { }
onServiceWorkerConnection(env) { connection ->
connection.registerDefault(MyServiceImpl())
}
}

The worker listens for "ksrpc-connect" messages and establishes a Connection for each incoming client via the message's MessagePort.

Client side

From the main thread, use createServiceWorkerWithConnection to register the worker and obtain a connection:

import com.monkopedia.ksrpc.annotation.ExperimentalKsrpc
import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.toStub
import com.monkopedia.ksrpc.webworker.createServiceWorkerWithConnection

@OptIn(ExperimentalKsrpc::class)
fun main() {
val env = ksrpcEnvironment { }
val connection = createServiceWorkerWithConnection("/worker.js", env)
val service = connection.defaultChannel().toStub<MyService>()
}

Transport semantics

  • Returns a full Connection<String> supporting bidirectional communication

  • Supports sub-services in both directions

  • Binary data is supported

  • Communication uses the browser MessagePort / MessageChannel API

  • The worker script path is relative to the application's origin

See also

  • bidirectional.md -- two-way RPC patterns

  • transports.md -- comparison of all transports

Module introspection

Introspection

The ksrpc-introspection module lets clients discover service metadata at runtime -- service names, endpoint lists, and input/output type schemas.

Setup

Add the introspection dependency:

implementation("com.monkopedia.ksrpc:ksrpc-introspection:1.0.0-RC3")

Opting in

Annotate your service with @KsIntrospectable and extend IntrospectableRpcService instead of RpcService:

import com.monkopedia.ksrpc.IntrospectableRpcService
import com.monkopedia.ksrpc.annotation.KsIntrospectable
import com.monkopedia.ksrpc.annotation.KsMethod
import com.monkopedia.ksrpc.annotation.KsService

@KsService
@KsIntrospectable
interface MyService : IntrospectableRpcService {
@KsMethod("/greet")
suspend fun greet(name: String): String

@KsMethod("/compute")
suspend fun compute(input: ComputeRequest): ComputeResult
}

The compiler plugin generates the introspection endpoint automatically. Every IntrospectableRpcService exposes a getIntrospection() method that clients can call.

Querying introspection

val service: MyService = channel.toStub<MyService>()
val introspection = service.getIntrospection()

// Service name (fully qualified)
val name = introspection.getServiceName()

// List of endpoint names
val endpoints = introspection.getEndpoints()
// e.g. ["greet", "compute"]

// Detailed info for a specific endpoint
val info = introspection.getEndpointInfo("greet")
// RpcEndpointInfo(endpoint = "greet", input = DataStructure(...), output = DataStructure(...))

IntrospectionService

The IntrospectionService returned by getIntrospection() is itself a @KsService with these methods:

Method Description
getServiceName() Returns the fully qualified service name
getEndpoints() Returns the list of endpoint names
getEndpointInfo(endpoint) Returns RpcEndpointInfo with input/output type metadata
getIntrospectionFor(service) Returns introspection for a referenced sub-service

RpcEndpointInfo and RpcDataType

Each endpoint's input and output are described by an RpcDataType:

  • RpcDataType.DataStructure -- a serializable type. Contains an RpcDescriptor tree describing the structure: dataType (enum like STRING, INT, CLASS, LIST, etc.), serialName, elements (child fields), and an optional id for recursive types.

  • RpcDataType.BinaryData -- a binary payload (RpcBinaryData).

  • RpcDataType.Service -- a sub-service reference. The qualifiedName identifies the service, and you can call getIntrospectionFor(qualifiedName) to inspect it.

Inspecting sub-services

When an endpoint returns or accepts a sub-service, its RpcDataType.Service carries the service name. Use getIntrospectionFor to recurse into it:

val info = introspection.getEndpointInfo("getEntity")
val outputType = info.output
if (outputType is RpcDataType.Service) {
val subIntrospection = introspection.getIntrospectionFor(outputType.qualifiedName)
val subEndpoints = subIntrospection.getEndpoints()
}

This also works for Flow<T> endpoints, which are backed by KsFlowService<T> sub-services under the hood.

RpcDescriptor structure

For DataStructure types, the RpcDescriptor tree mirrors the kotlinx.serialization descriptor:

val info = introspection.getEndpointInfo("compute")
val inputSchema = (info.input as RpcDataType.DataStructure).schema
// RpcDescriptor(
// dataType = CLASS,
// serialName = "com.example.ComputeRequest",
// elements = { "field1" -> RpcDescriptor(dataType = STRING, ...), ... }
// )

Recursive types are handled via the id field -- only the first occurrence has elements populated; subsequent occurrences reference the same id with an empty elements map.

Related guides

  • service-declaration.md -- defining @KsService interfaces that can be introspected

  • flow-streaming.md -- flow endpoints appear as KsFlowService<T> sub-services in introspection

  • getting-started.md -- project setup and dependencies

Module transports

Transports

Overview

ksrpc supports multiple transports, each suited to different platforms, protocol requirements, and communication patterns. All transports use JSON as the default wire format via kotlinx.serialization, except JNI which uses a binary serialization format.

Transport Module(s) Platforms Bidirectional Sub-services Binary
transport-http.md ksrpc-ktor-client / ksrpc-ktor-server JVM, Native, JS/WASM (client) No Output only Yes (streaming)
transport-websocket.md ksrpc-ktor-websocket-* JVM, Native, JS/WASM (client) Yes Yes Yes (streaming)
transport-sockets.md ksrpc-sockets JVM, POSIX Native Yes Yes Yes (buffered)
transport-jsonrpc.md ksrpc-jsonrpc JVM, POSIX Native Yes No No
transport-jni.md ksrpc-jni JVM + Kotlin/Native Yes Yes Binary (not JSON)
transport-service-worker.md ksrpc-service-worker JS, WASM (experimental) Yes Yes Yes

Choosing a transport

HTTP is the simplest option for request/response workloads. It integrates with ktor's routing and works on all Kotlin targets (server is JVM/Native only). Use it when you do not need the server to push data to the client.

WebSocket adds bidirectional communication on top of ktor. Both sides can host services and invoke each other. Choose this for interactive or event-driven APIs that need push notifications or callbacks.

Sockets / Stdin use a content-length-prefixed packet protocol over raw byte streams. This is ideal for subprocess communication (LSP-style) or direct TCP connections without an HTTP stack. Supports JVM and POSIX Native.

JSON-RPC 2.0 implements the standard JSON-RPC 2.0 protocol, making ksrpc services interoperable with non-Kotlin JSON-RPC clients and servers. Returns a SingleChannelConnection (no sub-services). Supports @KsNotification for fire-and-forget messages.

JNI bridges Kotlin/JVM and Kotlin/Native in the same process via JNI, using binary serialization with zero network overhead. Use it to embed a Kotlin/Native ksrpc service in a JVM host (or vice versa) via shared libraries.

Service Worker (experimental) hosts a ksrpc service inside a browser service worker for JS and WASM targets. Requires @OptIn(ExperimentalKsrpc::class).

See also

  • bidirectional.md -- patterns for two-way RPC

  • error-handling.md -- @KsError and transport-specific error mapping

  • context-propagation.md -- @KsContext across transports

  • flow-streaming.md -- Flow-based streaming over bidirectional channels

Module flow-streaming

Flow Streaming

The ksrpc-flow module lets you use kotlinx.coroutines.Flow<T> in @KsMethod signatures for streaming data over ksrpc connections.

Setup

Add the flow dependency:

implementation("com.monkopedia.ksrpc:ksrpc-flow:1.0.0-RC3")

Important: Flow streaming requires a bidirectional transport (WebSockets, sockets, or service workers) because the flow protocol uses sub-services internally. HTTP does not support Flow<T> and will fail at runtime. If you need streaming over HTTP, consider a polling pattern or switch to a WebSocket transport.

Using Flow in service signatures

Flow<T> is supported as a return type only -- it cannot be used as an input parameter. Declare it as the return type on a @KsMethod:

import kotlinx.coroutines.flow.Flow

@KsService
interface EventService : RpcBidiService {
@KsMethod("/events")
suspend fun streamEvents(filter: String): Flow<Event>
}

The compiler plugin handles the transformation automatically. On the wire, Flow<T> is backed by a KsFlowService sub-service.

Implementing a flow endpoint

Return a standard Flow from your implementation:

class EventServiceImpl : EventService {
override suspend fun streamEvents(filter: String): Flow<Event> = flow {
while (true) {
val event = waitForEvent(filter)
emit(event)
}
}
}

The runtime wraps your Flow in a KsFlowService using asKsFlow() and registers it as a sub-service on the connection.

Collecting on the client

The client receives a standard Flow<T> and collects it normally:

val service = connection.defaultChannel().toStub<EventService>()
service.streamEvents("error").collect { event ->
println("Got event: ${event.message}")
}

Lifecycle

Flow<T> is only supported as a return type, not as an input parameter. If you need to send a stream of items to the server, use a sub-service callback pattern instead (see the bidirectional.md).

The returned flow is single-use and auto-closing. After collection completes (normally, with an error, or via cancellation), the underlying sub-service is closed automatically.

Cancellation

Cancelling the collecting coroutine propagates cancellation to the server:

val job = launch {
service.streamEvents("all").collect { event ->
process(event)
}
}

// Later: cancel the collection
job.cancel() // server-side flow collection is also cancelled

Back-pressure

Flow items are delivered through the sub-service call path. Each emit on the server side is a suspend call to the client's collector -- the server blocks until the client processes the item. This provides natural back-pressure without additional configuration.

Error propagation

If the server-side flow throws an exception, the client receives it as an RpcFailure through the collector's onError signal, which is then re-thrown from collect.

Advanced: KsFlowService directly

If you need multi-collection or explicit lifecycle control, declare KsFlowService directly instead of Flow<T>:

@KsService
interface AdvancedEventService : RpcBidiService {
@KsMethod("/events")
suspend fun streamEvents(filter: String): KsFlowService<Event>
}

With KsFlowService<T>:

  • You can call collect multiple times (each gets its own server-side collection job).

  • The service does NOT auto-close after collection -- you must call close() explicitly.

  • Each startCollection returns a KsCollectionToken that can cancel that specific collection.

val flowService = service.streamEvents("all")
try {
// First collection
flowService.collect { event -> handleBatch1(event) }
// Second collection (same flow service)
flowService.collect { event -> handleBatch2(event) }
} finally {
flowService.close()
}

Under the hood

The flow protocol uses three sub-services:

  • KsFlowService -- the main flow service, with a startCollection method that accepts a collector and returns a token.

  • KsFlowCollector -- a callback sub-service that receives onItem, onComplete, and onError signals. Terminal signals (onComplete, onError) are annotated with @KsNotification.

  • KsCollectionToken -- a sub-service with a cancelCollection method for cancelling an active collection.

The compiler plugin generates the FlowSubserviceTransformer that bridges between Flow<T> and KsFlowService<T> transparently.

Related guides

  • bidirectional.md -- required for flow streaming; also covers sub-service callback patterns for server-bound streams

  • service-declaration.md -- @KsMethod signatures and type support

  • transports.md -- which transports support the bidirectional connections flow requires

Module service-declaration

Service Declaration

Basics

Every ksrpc service is a Kotlin interface that:

  1. Extends RpcService

  2. Is annotated with @KsService

  3. Has methods annotated with @KsMethod with a unique name within the service

@KsService
interface MyService : RpcService {
@KsMethod("/doWork")
suspend fun doWork(input: String): Int
}

The compiler plugin generates a companion RpcObject that provides stub creation and serialization adapters. Any method on the interface that is not annotated with @KsMethod will produce a compiler warning.

One-parameter limit: @KsMethod functions may accept at most one parameter (plus the implicit receiver). To pass multiple values, wrap them in a @Serializable data class.

The @KsMethod name is the wire-level identifier. It must be unique within a single service but does not need to be globally unique. Choose stable names -- renaming breaks wire compatibility.

Primitive types

Any primitive type supported by kotlinx.serialization can be used directly as an input or output: String, Int, Long, Double, Float, Boolean, Byte, Short, Char.

@KsService
interface MathService : RpcService {
@KsMethod("/add")
suspend fun add(a: Int): Int

@KsMethod("/name")
suspend fun name(): String
}

Serializable types

Any @Serializable class can be used as input or output:

@Serializable
data class UserRequest(val name: String, val age: Int)

@Serializable
data class UserResponse(val id: String, val displayName: String)

@KsService
interface UserService : RpcService {
@KsMethod("/createUser")
suspend fun createUser(request: UserRequest): UserResponse
}

Unit input and output

Methods with no parameters are supported directly -- you do not need a placeholder parameter:

@KsService
interface LifecycleService : RpcService {
@KsMethod("/status")
suspend fun getStatus(): StatusInfo

@KsMethod("/ping")
suspend fun ping(): String

@KsMethod("/shutdown")
suspend fun shutdown(reason: String)
}

The compiler plugin synthesizes the Unit handling internally for zero-argument methods.

Backward compatibility: the older u: Unit parameter style still works and is equivalent to a zero-argument method. You may see it in older code:

// Legacy style -- still supported but no longer required
suspend fun getStatus(u: Unit): StatusInfo

Binary data

Binary payloads are supported through platform-specific stream types. Add the adapter module for the I/O library you use, then declare the type directly in your method signatures. Binary transfer is streaming on HTTP and WebSocket transports; on socket transports the data is buffered in memory. Binary is not supported on JSON-RPC.

Type Module Dependency
ByteReadChannel (ktor) ksrpc-binary-ktor implementation("com.monkopedia.ksrpc:ksrpc-binary-ktor:1.0.0-RC3")
Source (kotlinx-io) ksrpc-binary-kotlinx-io implementation("com.monkopedia.ksrpc:ksrpc-binary-kotlinx-io:1.0.0-RC3")
BufferedSource (okio) ksrpc-binary-okio implementation("com.monkopedia.ksrpc:ksrpc-binary-okio:1.0.0-RC3")
// Using ktor's ByteReadChannel (requires ksrpc-binary-ktor)
@KsService
interface FileService : RpcService {
@KsMethod("/upload")
suspend fun upload(data: ByteReadChannel): String

@KsMethod("/download")
suspend fun download(key: String): ByteReadChannel
}

// Using kotlinx-io Source (requires ksrpc-binary-kotlinx-io)
@KsService
interface IoService : RpcService {
@KsMethod("/read")
suspend fun read(key: String): Source
}

// Using okio BufferedSource (requires ksrpc-binary-okio)
@KsService
interface OkioService : RpcService {
@KsMethod("/read")
suspend fun read(key: String): BufferedSource
}

Each adapter module registers a transformer that the compiler plugin uses to convert between the platform type and the internal wire format. You only need the adapter module on your classpath -- no extra configuration required.

Sub-services

A @KsMethod can accept or return another @KsService interface. This enables contextual callback patterns and service hierarchies:

@KsService
interface EntityService : RpcService {
@KsMethod("/name")
suspend fun getName(): String

@KsMethod("/content")
suspend fun getContent(): ByteReadChannel
}

@KsService
interface CatalogService : RpcBidiService {
@KsMethod("/get")
suspend fun getEntity(id: Int): EntityService

@KsMethod("/register")
suspend fun registerEntity(entity: EntityService): Int
}

Sub-services as outputs require a transport that supports ChannelHost (HTTP server, Connection). Sub-services as inputs require ChannelClient (a Connection). See the bidirectional.md for details on callback patterns.

Notifications

Use @KsNotification to mark a method as fire-and-forget on transports that support notification semantics (JSON-RPC):

@KsService
interface LogService : RpcService {
@KsMethod("/log")
@KsNotification
suspend fun log(message: String)
}

The method must return Unit. On transports without notification support (HTTP, sockets), the call behaves as a normal request.

Timeouts

Use @KsTimeout to specify a client-side timeout for a method call:

@KsService
interface SlowService : RpcService {
@KsMethod("/compute")
@KsTimeout(seconds = 30)
suspend fun compute(input: String): String
}

The generated stub wraps the call in withTimeout. On transports with cancellation support, the cancellation propagates to the server.

Implementing services

Implement a service by extending the interface:

class CatalogServiceImpl : CatalogService {
override suspend fun getEntity(id: Int): EntityService = EntityImpl(id)
override suspend fun registerEntity(entity: EntityService): Int {
val name = entity.getName()
return store.register(name)
}
}

Convert between implementations and stubs using the generated companion:

// Implementation -> serialized service for hosting
val serialized = myImpl.serialized(env)

// Serialized channel -> typed stub for calling
val stub = channel.toStub<MyService>()

See RpcObject in the API docs for the full generated companion API.

Related guides

  • error-handling.md -- typed errors with @KsError on @KsMethod functions

  • context-propagation.md -- propagating request-scoped metadata with @KsContext

  • bidirectional.md -- sub-service parameters, callbacks, and connect<H, C>

  • flow-streaming.md -- using Flow<T> as a return type for streaming

  • introspection.md -- runtime discovery of service metadata

  • transports.md -- which transports support binary data, sub-services, and notifications

Module error-handling

Error Handling

KsrpcException

All ksrpc runtime errors extend KsrpcException, which carries:

  • code -- an integer error code (used on the wire)

  • message -- a human-readable description

  • data -- an optional typed payload (populated when @KsError bindings are active)

  • cause -- optional root cause (server-side only; not propagated across the wire)

Built-in subclasses:

  • RpcException -- generic wire error, code = -32603 (JSON-RPC "internal error"). HTTP maps to 500.

  • RpcEndpointException -- method not found, code = -32601 (JSON-RPC "method not found"). HTTP maps to 404.

Untyped errors

By default, exceptions thrown in a service handler are caught by the runtime, reported to the configured ErrorListener, and sent to the client as a generic KsrpcException with the message string. The full stack trace stays server-side.

class MyServiceImpl : MyService {
override suspend fun riskyCall(input: String): String {
throw IllegalStateException("something went wrong")
}
}

// Client side
try {
service.riskyCall("test")
} catch (e: KsrpcException) {
// e.code == -32603 (internal error)
// e.message contains "something went wrong"
}

@KsError: typed error bindings

Use @KsError to bind @Serializable exception types to integer error codes on specific methods. This gives clients typed exceptions they can catch directly.

Step 1: Define the error type

The error class must be @Serializable and extend Throwable (typically RuntimeException). Only declare fields that are safe to serialize -- avoid cause and stackTrace.

Why both @Serializable and Throwable? The class needs @Serializable so the runtime can encode it for wire transport, and it needs to extend Throwable so you can throw and catch it naturally. Note that the stack trace is NOT preserved across the RPC boundary -- only the serialized fields travel over the wire. The client receives a freshly deserialized instance with a local stack trace from the deserialization site.

@Serializable
class AuthError(val reason: String) : RuntimeException() {
override val message: String get() = "Authentication failed: $reason"
}

@Serializable
class RateLimitError(val retryAfterMs: Long) : RuntimeException() {
override val message: String get() = "Rate limited, retry after ${retryAfterMs}ms"
}

Step 2: Bind errors to methods

Apply @KsError annotations to @KsMethod functions. Each binding maps a type to a unique integer code:

@KsService
interface AuthService : RpcService {
@KsMethod("/login")
@KsError(code = 100, type = AuthError::class)
@KsError(code = 101, type = RateLimitError::class)
suspend fun login(credentials: Credentials): AuthToken
}

@KsError is @Repeatable -- you can bind multiple error types to the same method. Each code must be unique within the method.

Step 3: Throw from the handler

Throw the bound type directly from your service implementation. No wrapper is needed:

class AuthServiceImpl : AuthService {
override suspend fun login(credentials: Credentials): AuthToken {
if (!isValid(credentials)) {
throw AuthError("invalid credentials")
}
if (isRateLimited(credentials.userId)) {
throw RateLimitError(retryAfterMs = 5000)
}
return generateToken(credentials)
}
}

Step 4: Catch on the client

The client receives the original typed exception, deserialized from the wire:

try {
service.login(credentials)
} catch (e: AuthError) {
println("Auth failed: ${e.reason}")
} catch (e: RateLimitError) {
delay(e.retryAfterMs)
retry()
} catch (e: KsrpcException) {
// Fallback for unbound errors
println("RPC error ${e.code}: ${e.message}")
}

Wire behavior

  • The code in @KsError is the single source of truth for the wire code. There is no per-throw override.

  • The server serializes the throwable using its KSerializer and sends the code + serialized payload.

  • The client deserializes back into the bound type and re-throws it.

  • Server stack traces are NOT propagated -- clients see a stack from local deserialization. Use message and serialized fields for diagnostics.

Unexpected error codes

When the server throws a typed error whose code the client does not have a @KsError mapping for (e.g., the server was updated with new error types but the client was not), the client does not crash. Instead, the runtime surfaces a generic KsrpcException carrying the raw wire-level code, the error message, and the raw wire-format payload in KsrpcException.data. This lets callers inspect or log the payload even without the @KsError binding:

try {
service.someMethod("input")
} catch (e: AuthError) {
// Known typed error
} catch (e: KsrpcException) {
// Unknown error code from a newer server
// e.code -- the wire-level integer code
// e.message -- the error message string
// e.data -- the raw serialized payload (if any)
println("Unrecognized error code ${e.code}: ${e.message}")
}

The built-in sentinel codes are always recognized: ENDPOINT_NOT_FOUND_CODE (-32601) produces RpcEndpointException, and INTERNAL_ERROR_CODE (-32603) produces RpcException.

Wire format by transport

How typed errors appear on the wire depends on the transport:

  • HTTP: custom @KsError codes default to HTTP 500 with the original code in an X-Ksrpc-Error-Code header. You can customize the mapping -- see the transports.md for details on HTTP error mapping.

  • JSON-RPC: errors are carried in the standard JSON-RPC error envelope (error.code, error.message, error.data). See the transports.md for JSON-RPC specifics.

  • Sockets / WebSockets: errors are encoded in the packet protocol's error frame.

ErrorListener

Configure a global error listener in KsrpcEnvironment to observe all errors:

val env = ksrpcEnvironment {
errorListener = ErrorListener { t ->
logger.error("RPC error", t)
}
}

Create a local environment with a different error handler using onError:

val localEnv = env.onError { t ->
metrics.recordError(t)
}

The ErrorListener is called for all errors -- both untyped and typed. It runs on the server side before the error is sent to the client.

Related guides

  • service-declaration.md -- defining @KsMethod endpoints and the @KsError annotation target

  • transports.md -- how errors map to HTTP status codes and JSON-RPC error envelopes

  • getting-started.md -- environment configuration including ErrorListener

Module transport-jsonrpc

JSON-RPC 2.0 Transport

The JSON-RPC transport communicates using the JSON-RPC 2.0 protocol over byte streams, enabling interop with other JSON-RPC clients and servers.

The JSON-RPC transport returns a SingleChannelConnection rather than a full Connection -- it does not support sub-services. Each @KsMethod name maps directly to the JSON-RPC method field.

Module and dependencies

implementation("com.monkopedia.ksrpc:ksrpc-jsonrpc:1.0.0-RC3")

Platform availability

Platform Supported
JVM Yes
POSIX Native Yes
Windows Native No
JS/WASM No

Setup (stdin/stdout, LSP-compatible)

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.jsonrpc.asJsonRpcConnection

val env = ksrpcEnvironment { }
val connection = (stdinChannel to stdoutChannel)
.asJsonRpcConnection(env)
connection.registerDefault(MyServiceImpl())

The input/output pair is Pair<ByteReadChannel, ByteWriteChannel>, the same as the socket transport.

Options

asJsonRpcConnection accepts the following parameters:

  • includeContentHeaders (default true) -- when true, each message is prefixed with Content-Length headers per the LSP base protocol. Set to false for newline-delimited JSON-RPC (one JSON object per line).

  • cancellationConvention (default JsonRpcCancellationConvention.None) -- configures how cancellation is communicated on the wire:

    • None -- cancellation is local only; no notification is sent to the remote side.

    • Notification(method) -- sends a JSON-RPC notification with the given method name and { "id": <original id> } params. Incoming notifications with that method cancel the matching handler.

    • JsonRpcCancellationConvention.Lsp -- convenience for the LSP $/cancelRequest convention.

// LSP-compatible with cancellation support
val connection = (input to output).asJsonRpcConnection(
env,
includeContentHeaders = true,
cancellationConvention = JsonRpcCancellationConvention.Lsp
)

Notifications

Methods annotated with @KsNotification are sent as JSON-RPC notifications (no id field, no response expected). This is useful for fire-and-forget messages like LSP's textDocument/didOpen.

@KsService
interface MyLspService : RpcService {
@KsMethod("textDocument/didOpen")
@KsNotification
suspend fun didOpen(params: DidOpenParams)

@KsMethod("textDocument/completion")
suspend fun completion(params: CompletionParams): CompletionList
}

Transport semantics

  • Returns a SingleChannelConnection<String> (no sub-services)

  • Bidirectional: both sides can host a service and call the other

  • The @KsMethod path maps to the JSON-RPC method field

  • Request/response correlation uses the JSON-RPC id field

  • Binary data is not supported (JSON-only wire format)

Path convention: JSON-RPC method names map verbatim to the wire. The conventional style for JSON-RPC is namespace/method without a leading / (e.g., textDocument/completion), while other ksrpc transports typically use /path style (e.g., /greet). Choose your @KsMethod names to match the protocol conventions of the transport you are targeting.

Error mapping

JSON-RPC errors use the standard error object with code, message, and optional data fields. ksrpc maps @KsError codes to JSON-RPC error codes. See the error-handling.md for details on defining custom error types.

Context propagation

@KsContext bindings in JSON-RPC default to the RootSiblings convention, where context values are added as sibling keys at the root of the params object. Other conventions are available. See the context-propagation.md for details.

See also

  • error-handling.md -- @KsError and error code mapping

  • context-propagation.md -- @KsContext conventions

  • transport-sockets.md -- the underlying byte stream protocol

  • transports.md -- comparison of all transports

Module transport-jni

JNI Transport

The JNI transport provides a shared RPC interface across the JVM/Native boundary in the same process. It uses binary serialization rather than JSON, eliminating network overhead entirely.

Module and dependencies

implementation("com.monkopedia.ksrpc:ksrpc-jni:1.0.0-RC3")

Platform availability

Side Platform Notes
JVM JVM JniConnection -- the JVM side of the bridge
Native Kotlin/Native NativeConnection -- the native side of the bridge

Both sides run in the same OS process, communicating through JNI calls rather than sockets or HTTP.

JVM side setup

On the JVM side, create a JniConnection with a NativeKsrpcEnvironmentFactory that initializes the native ksrpc environment:

import com.monkopedia.ksrpc.jni.JniConnection
import com.monkopedia.ksrpc.jni.NativeKsrpcEnvironmentFactory
import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.toStub

val env = ksrpcEnvironment { }
val connection = JniConnection(
scope,
env,
nativeEnvironmentFactory
)

// Call a service hosted on the native side
val service = connection.defaultChannel().toStub<MyService>()
val result = service.someMethod("hello")

// Or host a service for the native side to call
connection.registerDefault(MyJvmServiceImpl())

The NativeKsrpcEnvironmentFactory is a functional interface that creates the native-side ksrpc environment pointer. Its implementation depends on how your native shared library is loaded and initialized.

Native side

On the Kotlin/Native side, the NativeConnection is created automatically by the JNI bridge when JniConnection.createConnection is called. The native side uses the same registerDefault / defaultChannel pattern:

import com.monkopedia.ksrpc.jni.NativeConnection
import com.monkopedia.ksrpc.channels.registerDefault
import com.monkopedia.ksrpc.toStub

// Inside a JNI callback or initialization function
fun initializeService(connection: NativeConnection) {
// Host a native service for the JVM to call
connection.registerDefault(MyNativeServiceImpl())

// Or call a service hosted on the JVM side
val jvmService = connection.defaultChannel().toStub<JvmService>()
}

Transport semantics

  • Uses JniSerialized as the wire format -- a binary serialization format, not JSON

  • Bidirectional: both JVM and Native sides can host and call services

  • Full Connection with sub-service support in both directions

  • Zero network overhead -- data passes through JNI method calls in the same process

  • Packet-based protocol using the same PacketChannelBase as other bidirectional transports

  • Coroutine continuations are bridged across the JNI boundary, preserving suspend semantics

See also

  • bidirectional.md -- two-way RPC patterns

  • transports.md -- comparison of all transports

Module transport-http

HTTP Transport

The HTTP transport maps each @KsMethod call to an HTTP POST request. It integrates with ktor on both client and server, providing the simplest path to exposing ksrpc services over the network.

HTTP is request/response only -- it returns a ChannelClient, not a bidirectional Connection. For bidirectional communication, use the transport-websocket.md or transport-sockets.md transports instead.

Modules and dependencies

// Client
implementation("com.monkopedia.ksrpc:ksrpc-ktor-client:1.0.0-RC3")

// Server
implementation("com.monkopedia.ksrpc:ksrpc-ktor-server:1.0.0-RC3")

Platform availability

Component JVM Native JS/WASM
Client Yes Yes Yes
Server Yes Yes No

Server setup (ktor)

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.ktor.serveHttp
import io.ktor.server.routing.routing

val env = ksrpcEnvironment { }
embeddedServer(Netty, port = 8080) {
routing {
serveHttp("/api/myservice", MyServiceImpl(), env)
}
}.start(wait = true)

serveHttp also accepts a pre-serialized SerializedService<String> or a SerializedChannel<String>, giving you control over when serialization setup happens.

Client setup

import com.monkopedia.ksrpc.ktor.asHttpChannelClient
import com.monkopedia.ksrpc.toStub

val env = ksrpcEnvironment { }
val channelClient = HttpClient { }
.asHttpChannelClient("http://localhost:8080/api/myservice", env)
val service = channelClient.defaultChannel().toStub<MyService>()

Transport semantics

  • Each @KsMethod call is a POST to {basePath}/call/{methodName}

  • Request and response bodies are JSON-encoded via kotlinx.serialization

  • Binary data (RpcBinaryData parameters/returns) is streamed via ktor's ByteReadChannel/ByteWriteChannel, flagged with a binary: true header

  • Sub-service outputs are supported (the server can return @KsService interfaces), but sub-service inputs are not (the client cannot pass callback services to the server)

Error mapping

HTTP maps ksrpc error codes to HTTP status codes. The default mapping:

ksrpc code HTTP status
ENDPOINT_NOT_FOUND_CODE (-32601) 404
INTERNAL_ERROR_CODE (-32603) 500

Custom @KsError codes default to HTTP 500, with the original code carried in the X-Ksrpc-Error-Code header and the message in X-Ksrpc-Error-Message. You can provide a custom mapping on both ends:

val errorMap = mapOf(
100 to 422, // map your custom error code to an HTTP status
KsrpcException.ENDPOINT_NOT_FOUND_CODE to 404,
KsrpcException.INTERNAL_ERROR_CODE to 500
)

// Server
serveHttp("/api/myservice", service, env, errorCodeToHttpStatus = errorMap)

// Client
httpClient.asHttpChannelClient(url, env, errorCodeToHttpStatus = errorMap)

Pass the same map on both ends so the round-trip preserves user-defined error codes.

Context propagation

@KsContext bindings are propagated via HTTP headers. See the context-propagation.md for details on defining and registering context bindings.

See also

  • error-handling.md -- @KsError annotation and error routing

  • context-propagation.md -- @KsContext across transports

  • transports.md -- comparison of all transports

Module transport-websocket

WebSocket Transport

The WebSocket transport provides bidirectional communication over ktor WebSocket sessions. Both sides can host services and invoke each other, making it suitable for interactive and event-driven APIs.

Modules and dependencies

// Client
implementation("com.monkopedia.ksrpc:ksrpc-ktor-websocket-client:1.0.0-RC3")

// Server
implementation("com.monkopedia.ksrpc:ksrpc-ktor-websocket-server:1.0.0-RC3")

// Shared protocol (transitive dependency, usually not needed directly)
implementation("com.monkopedia.ksrpc:ksrpc-ktor-websocket-shared:1.0.0-RC3")

Platform availability

Component JVM Native JS/WASM
Client Yes Yes Yes
Server Yes Yes No

Server setup

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.ktor.websocket.serveWebsocket
import io.ktor.server.websocket.WebSockets

val env = ksrpcEnvironment { }
embeddedServer(Netty, port = 8080) {
install(WebSockets)
routing {
serveWebsocket("/ws/myservice", MyServiceImpl(), env)
}
}.start(wait = true)

serveWebsocket also accepts a pre-serialized SerializedService<String>.

Client setup

import com.monkopedia.ksrpc.ktor.websocket.asWebsocketConnection
import com.monkopedia.ksrpc.toStub
import io.ktor.client.plugins.websocket.WebSockets

val env = ksrpcEnvironment { }
val client = HttpClient { install(WebSockets) }
val connection = client.asWebsocketConnection("ws://localhost:8080/ws/myservice", env)
val service = connection.defaultChannel().toStub<MyService>()

Transport semantics

  • Uses a packet-based protocol over WebSocket frames

  • Returns a full Connection<String> supporting bidirectional communication

  • Supports sub-services in both directions (input and output @KsService parameters)

  • Binary data is streamed via WebSocket binary frames

  • The connection stays open until explicitly closed

Bidirectional usage

The returned Connection<String> supports registerDefault for hosting a service back to the server:

val connection = client.asWebsocketConnection(url, env)

// Host a service for the server to call back
connection.registerDefault(MyCallbackImpl())

// Call the server's service
val service = connection.defaultChannel().toStub<MyService>()

You can also use the connect helper to set up both directions in one call:

connection.connect<MyCallback, MyService> { remoteService ->
// remoteService is a stub for calling the server
MyCallbackImpl(remoteService) // returned value is hosted for server callbacks
}

See the bidirectional.md for more patterns.

See also

  • bidirectional.md -- two-way RPC patterns

  • flow-streaming.md -- Flow-based streaming over WebSocket

  • error-handling.md -- @KsError annotation and error routing

  • transports.md -- comparison of all transports

Module bidirectional

Bidirectional Communication

Connection vs SingleChannelConnection

ksrpc provides two levels of bidirectional channels:

  • SingleChannelConnection -- supports one hosted service and one client service (no sub-services). Used by JSON-RPC transport.

  • Connection -- extends SingleChannelConnection with full sub-service support in both directions. Used by WebSocket and socket transports.

Both provide registerDefault (host a service for incoming calls) and defaultChannel (get a channel for outgoing calls), and both can use them simultaneously.

Basic pattern

val env = ksrpcEnvironment { }
withStdInOut(env) { connection ->
// Host a service for the remote side to call
connection.registerDefault(MyHostServiceImpl())

// Get a stub for calling the remote side's service
val remoteService = connection.defaultChannel().toStub<RemoteService>()
val result = remoteService.someMethod("hello")
}

The connect helper

The connect<Host, Client> extension combines registerDefault and defaultChannel into a single call. The lambda receives the client stub and returns the host service implementation:

withStdInOut(env) { connection ->
connection.connect<MyHostService, MyClientService> { client ->
// client is a MyClientService stub for calling the remote side
MyHostServiceImpl(client) // returned value is registered as the hosted service
}
}

The first type parameter is the service you host (returned by the lambda); the second is the remote service you call (passed into the lambda).

This is equivalent to:

withStdInOut(env) { connection ->
val client = connection.defaultChannel().toStub<MyClientService>()
connection.registerDefault(MyHostServiceImpl(client))
}

Sub-service callbacks

On transports that support Connection (sockets, WebSockets, JNI), you can pass @KsService interfaces as method parameters and return values. This enables contextual callback patterns:

@KsService
interface ProgressCallback : RpcService {
@KsMethod("/onProgress")
suspend fun onProgress(percent: Int)

@KsMethod("/onComplete")
suspend fun onComplete()
}

@KsService
interface TaskRunner : RpcBidiService {
@KsMethod("/run")
suspend fun runTask(callback: ProgressCallback): String
}

The client passes a callback implementation. The server calls it during execution:

// Client side
val runner = connection.defaultChannel().toStub<TaskRunner>()
val result = runner.runTask(object : ProgressCallback {
override suspend fun onProgress(percent: Int) {
println("Progress: $percent%")
}
override suspend fun onComplete() {
println("Done!")
}
})
// Server side
class TaskRunnerImpl : TaskRunner {
override suspend fun runTask(callback: ProgressCallback): String {
callback.onProgress(0)
doWork()
callback.onProgress(50)
doMoreWork()
callback.onProgress(100)
callback.onComplete()
return "finished"
}
}

Sub-service inputs require a ChannelClient (i.e. a Connection). Sub-service outputs require a ChannelHost (HTTP server or Connection). HTTP supports sub-service outputs but not inputs because it is not bidirectional.

Nested sub-services

Sub-services can themselves return sub-services, enabling rich hierarchical APIs:

@KsService
interface TaskHandle : RpcService {
@KsMethod("/cancel")
suspend fun cancel()

@KsMethod("/status")
suspend fun status(): String
}

@KsService
interface Scheduler : RpcBidiService {
@KsMethod("/schedule")
suspend fun schedule(callback: ProgressCallback): TaskHandle
}

Transport capabilities

Transport Sub-service input Sub-service output connect<H, C>
HTTP No Yes No
WebSocket Yes Yes Yes
Sockets Yes Yes Yes
JSON-RPC No No Yes (single channel)
JNI Yes Yes Yes
Service Worker Yes Yes Yes

For Flow-based streaming over bidirectional channels, see the flow-streaming.md.

Module getting-started

Getting Started

Gradle setup

Add the ksrpc compiler plugin and runtime dependency to your build.gradle.kts. Replace 1.0.0-RC3 with the latest release version from Maven Central or GitHub Releases:

plugins {
kotlin("multiplatform") version "2.3.0"
kotlin("plugin.serialization") version "2.3.0"
id("com.monkopedia.ksrpc.plugin") version "1.0.0-RC3"
}

kotlin {
sourceSets {
commonMain {
dependencies {
implementation("com.monkopedia.ksrpc:ksrpc-core:1.0.0-RC3")
}
}
}
}

For transport-specific dependencies, add the appropriate modules (see the transports.md):

// HTTP client
implementation("com.monkopedia.ksrpc:ksrpc-ktor-client:1.0.0-RC3")
// HTTP server
implementation("com.monkopedia.ksrpc:ksrpc-ktor-server:1.0.0-RC3")
// WebSocket client
implementation("com.monkopedia.ksrpc:ksrpc-ktor-websocket-client:1.0.0-RC3")
// WebSocket server
implementation("com.monkopedia.ksrpc:ksrpc-ktor-websocket-server:1.0.0-RC3")
// Sockets / stdin-out
implementation("com.monkopedia.ksrpc:ksrpc-sockets:1.0.0-RC3")
// JSON-RPC 2.0
implementation("com.monkopedia.ksrpc:ksrpc-jsonrpc:1.0.0-RC3")
// Flow streaming
implementation("com.monkopedia.ksrpc:ksrpc-flow:1.0.0-RC3")
// Introspection
implementation("com.monkopedia.ksrpc:ksrpc-introspection:1.0.0-RC3")

Define a service

Declare your service as an interface extending RpcService, annotated with @KsService. Tag each method with @KsMethod and a unique name:

import com.monkopedia.ksrpc.RpcService
import com.monkopedia.ksrpc.annotation.KsMethod
import com.monkopedia.ksrpc.annotation.KsService

@KsService
interface GreetingService : RpcService {
@KsMethod("/greet")
suspend fun greet(name: String): String
}

The compiler plugin generates a companion RpcObject and stub implementations automatically.

Implement the service

class GreetingServiceImpl : GreetingService {
override suspend fun greet(name: String): String = "Hello, $name!"
}

Host over HTTP

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.ktor.serveHttp
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.routing.routing

fun main() {
val env = ksrpcEnvironment { }
embeddedServer(Netty, port = 8080) {
routing {
serveHttp("/api/greeting", GreetingServiceImpl(), env)
}
}.start(wait = true)
}

Call from a client

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.ktor.asHttpChannelClient
import com.monkopedia.ksrpc.toStub
import io.ktor.client.HttpClient

suspend fun main() {
val env = ksrpcEnvironment { }
val client = HttpClient { }
.asHttpChannelClient("http://localhost:8080/api/greeting", env)
val service = client.defaultChannel().toStub<GreetingService>()

println(service.greet("world")) // prints "Hello, world!"
}

Environment configuration

All channels share a KsrpcEnvironment that holds the serialization format, default coroutine scope, and error handling:

val env = ksrpcEnvironment(Json { encodeDefaults = true }) {
errorListener = ErrorListener { t ->
t.printStackTrace()
}
}

The first argument to ksrpcEnvironment is a StringFormat (defaulting to Json). Custom serialization settings are passed there, not inside the builder block. The builder block configures other environment properties like errorListener and logger.

See KsrpcEnvironment in the API docs for the full set of configurable fields.

Next steps

  • service-declaration.md -- full reference on types, annotations, and patterns

  • transports.md -- all supported transports with setup code

  • bidirectional.md -- two-way connections and callbacks

  • error-handling.md -- typed errors with @KsError

  • context-propagation.md -- propagating auth tokens, trace IDs, and other metadata with @KsContext

  • flow-streaming.md -- streaming data with Flow<T>

  • introspection.md -- runtime service metadata discovery

Module transport-sockets

Socket and Stdin/Stdout Transport

The socket transport uses a content-length-prefixed packet protocol over raw byte streams. It supports bidirectional communication with full sub-service support, and is well suited for direct TCP connections and subprocess stdin/stdout communication.

Module and dependencies

implementation("com.monkopedia.ksrpc:ksrpc-sockets:1.0.0-RC3")

Platform availability

Platform Supported Notes
JVM Yes Uses InputStream/OutputStream or ktor byte channels
POSIX Native Yes Uses ktor ByteReadChannel/ByteWriteChannel
Windows Native No The implementation uses termios-based I/O
JS/WASM No

Socket server

import com.monkopedia.ksrpc.ksrpcEnvironment
import com.monkopedia.ksrpc.sockets.asConnection
import com.monkopedia.ksrpc.channels.registerDefault

val env = ksrpcEnvironment { }
val serverSocket = ServerSocket(1234)

while (true) {
val socket = serverSocket.accept()
launch {
val connection = (socket.getInputStream() to socket.getOutputStream())
.asConnection(env)
connection.registerDefault(MyServiceImpl())
}
}

Socket client

import com.monkopedia.ksrpc.sockets.asConnection
import com.monkopedia.ksrpc.toStub

val env = ksrpcEnvironment { }
val socket = Socket("localhost", 1234)
val connection = (socket.getInputStream() to socket.getOutputStream())
.asConnection(env)
val service = connection.defaultChannel().toStub<MyService>()

On Kotlin/Native, use ByteReadChannel / ByteWriteChannel pairs instead of JVM streams.

Stdin/stdout

A convenience for the socket protocol over standard I/O streams. Useful for subprocess stdin/stdout communication where the parent process launches a child and communicates via its stdin/stdout.

import com.monkopedia.ksrpc.sockets.withStdInOut

val env = ksrpcEnvironment { }
withStdInOut(env) { connection ->
connection.registerDefault(MyServiceImpl())
}

Launching a subprocess

On JVM, ProcessBuilder.asConnection starts a subprocess and connects to it via its stdin/stdout:

import com.monkopedia.ksrpc.sockets.asConnection

val env = ksrpcEnvironment { }
val connection = ProcessBuilder("my-service-binary")
.asConnection(env)
val service = connection.defaultChannel().toStub<MyService>()

The connection's onClose handler automatically destroys the subprocess.

Transport semantics

  • Uses Content-Length framing: each packet is prefixed with Content-Length: N headers followed by the JSON payload

  • Returns a full Connection<String> supporting bidirectional communication

  • Supports sub-services in both directions

  • Binary data is buffered (not streamed like HTTP/WebSocket)

  • Content-Length header framing compatible with LSP and similar protocols

See also

  • bidirectional.md -- two-way RPC patterns

  • transport-jsonrpc.md -- if you need LSP-compatible JSON-RPC semantics on top

  • transports.md -- comparison of all transports

Module migration-1-0

Migration Guide: 0.11.x to 1.0

Requirements

ksrpc 1.0 requires a recent Kotlin version. The compiler plugin uses FIR APIs that change between Kotlin versions, so consumers must run at least the same Kotlin version that ksrpc was compiled against. The Gradle plugin checks this on apply and fails fast with a clear message naming the required version.

Call-site code: no changes needed

The transport entry points are unchanged. Existing calls to ksrpcEnvironment { }, asConnection(env), serveHttp(...), toStub<T>(), registerDefault(...), etc. work exactly as before — the breaking changes are in the service interface declarations only.

Service tier hierarchy (breaking)

Services must now declare their capability tier explicitly:

Your service... Must extend
Only has simple input/output methods RpcService (unchanged)
Returns sub-services RpcHostService
Accepts sub-service inputs or uses Flow<T> RpcBidiService

Before (0.11.x):

@KsService
interface MyService : RpcService {
@KsMethod("/getData")
suspend fun getData(id: String): Data

@KsMethod("/getChild")
suspend fun getChild(id: String): ChildService
}

After (1.0):

@KsService
interface MyService : RpcHostService { // Changed: returns a sub-service
@KsMethod("/getData")
suspend fun getData(id: String): Data

@KsMethod("/getChild")
suspend fun getChild(id: String): ChildService
}

The compiler plugin will emit a clear error telling you exactly which tier is needed and why:

"MyService extends RpcService but method 'getChild' returns ChildService (a sub-service). Change 'RpcService' to 'RpcHostService'."

Quick reference

  • RpcService -- simple methods only

  • RpcHostService -- any method returning a @KsService type

  • RpcBidiService -- any method accepting a @KsService input, or returning Flow<T>

  • IntrospectableRpcService -- now extends RpcHostService (was RpcService)

@KsService interface inheritance (new)

You can now have @KsService interfaces that extend other @KsService interfaces:

@KsService
interface CoreApi : RpcService {
@KsMethod("/version")
suspend fun version(): String
}

@KsService
interface ExtendedApi : CoreApi, RpcBidiService {
@KsMethod("/stream")
suspend fun stream(filter: String): Flow<Update>
}

rpcObject<ExtendedApi>() includes all methods from CoreApi plus its own.

Runtime tier checks (new)

Registering a service on a transport that doesn't support its tier now throws immediately at registration time instead of failing later at call time:

IllegalArgumentException: MyService requires HOST transport capability,
but JSON-RPC (SingleChannelConnection) only supports up to SIMPLE.

Binary data (new modules)

Binary payload support moved from ByteReadChannel hardcoded in core to adapter modules:

Type Module
ByteReadChannel (ktor) ksrpc-binary-ktor
Source (kotlinx-io) ksrpc-binary-kotlinx-io
BufferedSource (okio) ksrpc-binary-okio

If you were using ByteReadChannel in method signatures, add ksrpc-binary-ktor to your dependencies.

@KsContext (new)

Per-call context propagation via @KsContext annotations. See the context-propagation.md.

@KsError (new)

Typed error mappings via @KsError. See the error-handling.md.

API cleanup

  • Several internal types now properly gated behind @KsrpcInternal (removed from public API surface)

  • Packet class is @KsrpcInternal

  • ServiceTier enum added to public API

  • RpcObject.serviceTier property added (compiler-generated)

BCV consumers: filter generated synthetic classes

Every @KsService interface receives a set of plugin-generated synthetic companions (Stub, Stub.Companion, Stub.Anonymous<MethodName> ServiceExecutors, Obj, the service's RpcObject / RpcObjectFactory companion, and the synthesized companion on plain-Kotlin subtypes of @KsService). The plugin now annotates all of them with the new @KsrpcGenerated marker so consumers using binary-compatibility-validator can keep them out of their API dumps:

apiValidation {
nonPublicMarkers += "com.monkopedia.ksrpc.annotation.KsrpcGenerated"
}

You do not need to reference @KsrpcInternal in your BCV configuration — that marker is for ksrpc's own internal symbols, which consumers do not declare directly. With KsrpcGenerated filtered, generated declarations no longer appear in your API dumps and plugin-internal changes (for example the ServiceExecutor package change in 1.0.0-RC2) no longer trigger spurious apiCheck failures on upgrade.

Module context-propagation

Context Propagation

ksrpc can propagate coroutine context elements across the wire on a per-call basis. This is useful for authentication tokens, trace IDs, and similar request-scoped metadata.

Defining a context binding

A context binding is a CoroutineContext.Element paired with a KsContextBinding that describes how to encode/decode it for the wire. The recommended pattern uses a named companion object:

import com.monkopedia.ksrpc.KsContextBinding
import kotlin.coroutines.CoroutineContext

class AuthToken(val token: String) : CoroutineContext.Element {
override val key get() = Key

companion object Key : KsContextBinding<AuthToken> {
override val wireKey = "x-auth-token"
override fun toWire(value: AuthToken) = value.token
override fun fromWire(encoded: String) = AuthToken(encoded)
}
}

The binding must implement:

  • wireKey -- a stable string identifier used by transports (e.g. as an HTTP header name, a JSON-RPC metadata key, or a packet protocol field). Must be unique across all bindings on a given method.

  • toWire(value) -- encode the element to a string for transmission.

  • fromWire(encoded) -- decode a string back into the element.

Creating a context annotation

Wrap your binding in an annotation meta-annotated with @KsContext:

import com.monkopedia.ksrpc.annotation.KsContext

@KsContext(binding = AuthToken.Key::class)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
annotation class Auth

Applying to services and methods

Apply the annotation at the service level (all methods) or on individual methods:

@Auth  // applies to all methods in this service
@KsService
interface SecureService : RpcService {
@KsMethod("/data")
suspend fun getData(key: String): String

@KsMethod("/admin")
@WithTrace // additional context binding on this method only
suspend fun adminAction(command: String): String
}

Multiple context annotations can be applied to the same method. The compiler rejects duplicate wireKey values at compile time.

Setting context on the caller side

Use standard withContext to install the element before making a call:

import kotlinx.coroutines.withContext

withContext(AuthToken("my-secret-token")) {
val result = service.getData("key")
}

The generated stub reads the context element from the coroutine context and encodes it onto the wire using the binding's toWire.

Reading context on the handler side

The handler reads the propagated value from coroutineContext using the binding's key:

class SecureServiceImpl : SecureService {
override suspend fun getData(key: String): String {
val auth = coroutineContext[AuthToken.Key]
?: throw IllegalStateException("No auth token")
validateToken(auth.token)
return fetchData(key)
}

override suspend fun adminAction(command: String): String {
val auth = coroutineContext[AuthToken.Key]
?: throw IllegalStateException("No auth token")
val trace = coroutineContext[TraceId.Key]
logger.info("Admin action by ${auth.token}, trace=${trace?.value}")
return executeCommand(command)
}
}

Note: with a named companion pattern, use coroutineContext[AuthToken.Key] (the companion object), not coroutineContext[AuthToken] (which resolves to the class, not the key).

Absent context

If the caller does not install a context element, the handler sees null from the coroutineContext[Key] lookup. Your handler should handle this case -- either with a default value or by throwing an error.

Compiler validation

The compiler plugin performs these checks:

  • A @KsContext-annotated annotation must reference a binding class that implements KsContextBinding.

  • Two @KsContext annotations on the same method (or inherited from the service level) must not declare the same wireKey.

Wire transport details

Context values are carried differently depending on the transport:

  • Packet protocol (sockets, WebSockets): encoded in an optional cx field on the packet. See the transports.md for socket and WebSocket setup.

  • HTTP (ktor): context values are propagated as HTTP request headers keyed by the binding's wireKey. For example, an AuthToken binding with wireKey = "x-auth-token" becomes an x-auth-token HTTP header. See the transports.md for HTTP configuration.

  • JSON-RPC: context values are carried via the RootSiblings convention -- metadata is placed alongside the standard JSON-RPC fields in the request object. See the transports.md for JSON-RPC options.

From your perspective, you set context with withContext and read it with coroutineContext[Key] -- the transport handles the encoding automatically.

Related guides

  • service-declaration.md -- defining @KsMethod endpoints where @KsContext annotations are applied

  • error-handling.md -- typed errors with @KsError, another method-level annotation

  • transports.md -- transport-specific setup and wire format details

  • bidirectional.md -- context propagation works across bidirectional connections too

All modules:

Link copied to clipboard

Annotation-only module that defines the ksrpc service contract without pulling in runtime dependencies. Contains @KsService, @KsMethod, @KsError, @KsContext, @KsNotification, and the KsContextBinding interface used to declare per-call context propagation.

Link copied to clipboard

Binary data adapter that bridges ktor's ByteReadChannel onto the transport-agnostic RpcBinaryData interface. Add this module to your classpath when you need to pass ktor byte channels through ksrpc without ksrpc-core depending on ktor-io directly.

Link copied to clipboard

Core runtime library for ksrpc. Provides the abstract channel and connection model, the RpcMethod descriptor, RpcObject companion generation target, serialization plumbing, environment configuration (KsrpcEnvironment), and the transport-agnostic RpcBinaryData interface for binary payloads.

Link copied to clipboard

Runtime endpoint metadata and schema introspection. Services that opt in with @KsIntrospectable expose an IntrospectionService sub-service that reports endpoint names, input/output schemas (RpcEndpointInfo, RpcDescriptor), and nested sub-service structure at runtime.

Link copied to clipboard

JNI bridge for Kotlin/Native to JVM interop. Provides a compact binary serialization format (JniSer/JniSerialized) and a NativeConnection that passes ksrpc calls across the JNI boundary without JSON round-tripping. Use this module when embedding a Kotlin/Native ksrpc service inside a JVM host (or vice versa) via shared libraries.

Link copied to clipboard

Implementation of ksrpc channels that communicate using the JSON-RPC 2.0 protocol. Supports notification semantics for @KsNotification-annotated methods by omitting the id field on the wire.

Link copied to clipboard

Implementation of ksrpc channels that uses a ktor HttpClient to communicate over HTTP. Sends each RPC call as an HTTP request; binary payloads are streamed via ktor's byte channel integration.

Link copied to clipboard

Implementation of ksrpc channels that serves RPC endpoints via a ktor HTTP server. Install the ksrpc routing plugin into your ktor Application to expose services over HTTP.

Link copied to clipboard

Implementation of ksrpc channels that uses ktor to communicate over websockets as an HTTP client. Provides a connection factory that opens a ktor websocket session and wraps it in a WebsocketPacketChannel.

Link copied to clipboard

Implementation of ksrpc channels that uses ktor to serve RPC endpoints over websockets. Install the websocket routing plugin into your ktor Application to accept websocket connections and dispatch them to ksrpc services.

Link copied to clipboard

Utility module that wraps a ksrpc service into a standalone server application with command-line configuration. Extend BaseServiceApp (or ServiceApp on JVM/Native) to get argument parsing, transport selection, and hosting out of the box.

Link copied to clipboard

Experimental JS/WasmJS transport that communicates over browser service workers. Use createServiceWorkerWithConnection to obtain a Connection<String> backed by a registered service worker script. Requires @OptIn(ExperimentalKsrpc::class).

Link copied to clipboard

Implementation of ksrpc channels that communicate over POSIX sockets or standard input/output streams. Builds on PacketChannelBase from ksrpc-packets and provides platform-specific read/write channel adapters for JVM, Native, and JS targets.