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/MessageChannelAPI -
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 anRpcDescriptortree describing the structure:dataType(enum likeSTRING,INT,CLASS,LIST, etc.),serialName,elements(child fields), and an optionalidfor recursive types. -
RpcDataType.BinaryData-- a binary payload (RpcBinaryData). -
RpcDataType.Service-- a sub-service reference. ThequalifiedNameidentifies the service, and you can callgetIntrospectionFor(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
@KsServiceinterfaces 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 --
@KsErrorand transport-specific error mapping -
context-propagation.md --
@KsContextacross 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
collectmultiple times (each gets its own server-side collection job). -
The service does NOT auto-close after collection -- you must call
close()explicitly. -
Each
startCollectionreturns aKsCollectionTokenthat 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 astartCollectionmethod that accepts a collector and returns a token. -
KsFlowCollector-- a callback sub-service that receivesonItem,onComplete, andonErrorsignals. Terminal signals (onComplete,onError) are annotated with@KsNotification. -
KsCollectionToken-- a sub-service with acancelCollectionmethod 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 --
@KsMethodsignatures 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:
-
Extends
RpcService -
Is annotated with
@KsService -
Has methods annotated with
@KsMethodwith 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:
@KsMethodfunctions may accept at most one parameter (plus the implicit receiver). To pass multiple values, wrap them in a@Serializabledata 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: Unitparameter 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): StatusInfoContent copied to clipboard
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
@KsErroron@KsMethodfunctions -
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@KsErrorbindings 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
@SerializableandThrowable? The class needs@Serializableso the runtime can encode it for wire transport, and it needs to extendThrowableso you canthrowandcatchit 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
codein@KsErroris the single source of truth for the wire code. There is no per-throw override. -
The server serializes the throwable using its
KSerializerand 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
messageand 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
@KsErrorcodes default to HTTP 500 with the original code in anX-Ksrpc-Error-Codeheader. 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
@KsMethodendpoints and the@KsErrorannotation 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(defaulttrue) -- whentrue, each message is prefixed withContent-Lengthheaders per the LSP base protocol. Set tofalsefor newline-delimited JSON-RPC (one JSON object per line). -
cancellationConvention(defaultJsonRpcCancellationConvention.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$/cancelRequestconvention.
// 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
@KsMethodpath maps to the JSON-RPCmethodfield -
Request/response correlation uses the JSON-RPC
idfield -
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/methodwithout a leading/(e.g.,textDocument/completion), while other ksrpc transports typically use/pathstyle (e.g.,/greet). Choose your@KsMethodnames 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 --
@KsErrorand error code mapping -
context-propagation.md --
@KsContextconventions -
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
JniSerializedas the wire format -- a binary serialization format, not JSON -
Bidirectional: both JVM and Native sides can host and call services
-
Full
Connectionwith 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
PacketChannelBaseas 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
@KsMethodcall is a POST to{basePath}/call/{methodName} -
Request and response bodies are JSON-encoded via
kotlinx.serialization -
Binary data (
RpcBinaryDataparameters/returns) is streamed via ktor'sByteReadChannel/ByteWriteChannel, flagged with abinary: trueheader -
Sub-service outputs are supported (the server can return
@KsServiceinterfaces), 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 --
@KsErrorannotation and error routing -
context-propagation.md --
@KsContextacross 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
@KsServiceparameters) -
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 --
@KsErrorannotation 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-- extendsSingleChannelConnectionwith 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: Nheaders 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@KsServicetype -
RpcBidiService-- any method accepting a@KsServiceinput, or returningFlow<T> -
IntrospectableRpcService-- now extendsRpcHostService(wasRpcService)
@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) -
Packetclass is@KsrpcInternal -
ServiceTierenum added to public API -
RpcObject.serviceTierproperty 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 abindingclass that implementsKsContextBinding. -
Two
@KsContextannotations on the same method (or inherited from the service level) must not declare the samewireKey.
Wire transport details
Context values are carried differently depending on the transport:
-
Packet protocol (sockets, WebSockets): encoded in an optional
cxfield 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, anAuthTokenbinding withwireKey = "x-auth-token"becomes anx-auth-tokenHTTP header. See the transports.md for HTTP configuration. -
JSON-RPC: context values are carried via the
RootSiblingsconvention -- 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
@KsMethodendpoints where@KsContextannotations 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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.