
Typed RPC service contracts, hosted implementations, and interchangeable multi-protocol transport support with generated API gateway projections, interceptors, lifecycle management, proxy factories, and OpenAPI/SDK rendering.
Carbide is a Kotlin Multiplatform framework for defining typed service contracts, hosting their implementations, and calling them over interchangeable RPC protocols. Start with:
The sections below are reference documentation for individual capabilities.
./kotlin buildAn RPC service is just an interface extending IService and its implementation:
interface AwesomeService : IService {
suspend fun awesome(request: AwesomeRequest): AwesomeResponse
}
class AwesomeServiceImpl : AwesomeService {
override suspend fun awesome(request: AwesomeRequest): AwesomeResponse = TODO()
}
host.registerService<AwesomeService> {
AwesomeServiceImpl()
}
val client = proxyFactory.create<AwesomeService>()IService is the common service programming surface. It supplies a stable logger per implementation
class and brings Carbide context, logging, and standard-library utilities into contract modules. An
implementation that owns resources may also implement IServiceLifecycle; the host calls its
suspending start, health, and stop methods locally. Lifecycle methods are never generated as
remotely callable service operations.
Applications that host a subsystem can use the published ifx.subsystem module
as their single Carbide runtime dependency:
dependencies:
- io.carbide-ifx:ifx.subsystem:0.1.0It exports the host, RSocket and JSON-RPC protocols and proxy factories,
interceptor contracts, context and logging support, OpenTelemetry, the actuator,
and host tooling such as ServiceExplorer. Host.development() provides an
unauthenticated dual-protocol host with actuator inspection enabled. Production applications
should construct Host directly and install only the listeners and utilities they intend to expose.
Generated service bindings still require the subsystem/application KSP and compiler-plugin setup described below. Those are build-time tools rather than runtime dependencies.
The bundle supports JVM and macOS ARM64 and publishes platform-correct multiplatform metadata.
Runnable JVM subsystem modules can enable the local ifx.build.jib Amper plugin:
product:
type: jvm/app
settings:
jvm:
mainClass: com.example.CustomerSubsystemKt
plugins:
ifx.build.jib:
enabled: true
image: example/customer-subsystem:dev
ports: [ 8080, 8081 ]The plugin adds three module tasks:
./kotlin do jibTar -m customer.subsystem # cacheable image tar, no Docker daemon
./kotlin do jibDocker -m customer.subsystem # load the image into the local Docker daemon
./kotlin do jibPush -m customer.subsystem # push directly to the configured registryThe default base is the non-root Java 21 distroless image. baseImage,
jvmArgs, tags, ports, environment, and labels can be overridden per
subsystem. Build outputs such as an npm web application remain ordinary files
and can be copied into the image as their own layer:
plugins:
ifx.build.jib:
enabled: true
image: example/customer-subsystem:dev
extraDirectories:
- source: //typescript/customer-ui/dist
destination: /app/webapps/customerThe source directory must already have been produced by its owning build. Jib
tracks its contents as task inputs and copies them verbatim; it does not embed
them in Kotlin sources or JAR resources. Registry push and private-base pulls
use standard Docker credential discovery; set targetCredentialHelper or
baseCredentialHelper when a named helper is required. Do not put registry
passwords in module configuration. Pin baseImage by digest when builds must
remain reproducible across base-image updates.
Kotlin Toolchain currently supports only local custom plugin modules. Downstream
repositories must therefore vendor this small ifx.build.jib module until external
plugin publication is supported.
Host owns service registration and the lifecycle of its Ktor servers. Each
configured listener exposes exactly one protocol on its own port. A listener uses
the registered service endpoints by default, or an EndpointSource can replace
them with an immutable projection. Protocol implementations only install their
routes and wire handling into the listener provided by the host.
import ifx.subsystem.development
val host = Host.development(
name = "Example System",
rsocketPort = 7000,
jsonRpcPort = 7001,
)
host.registerService<AwesomeService> { AwesomeServiceImpl() }
host.start()
val rsocketClient = RSocketProxyFactory.forHost(host).create<AwesomeService>()
val jsonRpcClient = JsonRpcProxyFactory.forHost(host).create<AwesomeService>()Using separate Ktor applications prevents route and plugin collisions between
protocols and gives each listener an independent port and network interface,
with a boundary for protocol-specific TLS and authentication. A listener
configured with port = 0 receives an available port at startup; use
host.port(RSOCKET_PROTOCOL_ID) or
host.port(JSON_RPC_PROTOCOL_ID) to read the resolved value.
RSocket supports fire-and-forget, request/response, and request-stream
interactions. Regular JSON-RPC over HTTP supports fire-and-forget notifications
and request/response. Calling a service operation that returns Flow through the
JSON-RPC client fails explicitly because JSON-RPC has no standard streaming
interaction.
A gateway is a static, transport-neutral projection of existing service operations. Generated service descriptors expose owner-typed operation values, so the common case contains no routes, DTO mapping, annotations, or duplicate interfaces:
val ProductWebApi = gateway("product-web") {
expose(IProductAccessDescriptor) {
only(filter, generateRandowProduct)
}
expose(ISalesManagerDescriptor)
}expose(descriptor) includes all ordinary operations; inherited service
lifecycle operations are excluded. only(...) narrows the set. Service names,
operation names, and the surface address follow conventions and can be versioned
or explicitly renamed in the projection.
For an embedded gateway, give the same endpoint source to each public listener. Internal listeners can continue to use the complete registered endpoint set:
val publicEndpoints = ProductWebApi.endpointSource()
val host = Host {
listen(RSocketServerProtocol(), id = "internal-rsocket")
listen(
RSocketServerProtocol(rSocketAuthenticator),
id = "public-rsocket",
endpointSource = publicEndpoints,
)
listen(
GatewayHttpServerProtocol(httpAuthenticator),
endpointSource = publicEndpoints,
)
}For a separate gateway process, keep the projection unchanged and supply typed remote targets:
val publicEndpoints = ProductWebApi.endpointSource {
remote(IProductAccessDescriptor, productRSocketClient)
remote(ISalesManagerDescriptor, salesRSocketClient)
}The public RSocket surface is one service address (product-web above), with
routes such as productAccess/filter. Setup authentication establishes trusted
context for the connection and overwrites any client-supplied context. The
conventional HTTP adapter publishes
POST /api/{surface}/{manager}/{operation}; request streams use newline-delimited JSON
events named next, complete, and error. It is deliberately separate from
JSON-RPC.
OpenAPI 3.1 is served at /api/{surface}/openapi.json and can also be emitted as
a build artifact without starting a host. Enable the artifact plugin on the
module that declares the projections (the module must already run
ifx.subsystem.ksp):
plugins:
ifx.build.gateway:
enabled: trueThen run:
./kotlin do gatewayArtifacts -m product.gatewayKSP finds every non-private top-level val whose inferred type is
GatewayProjection; no annotation or projection-name string is needed. The
task writes one deterministic directory per public address:
gateway/
├── product-web/
│ ├── sdk.ts
│ └── openapi.json
└── product-web/v2/
├── sdk.ts
└── openapi.json
Only indexes generated into the declaring module's own JAR are loaded, so a gateway does not accidentally publish projections from its dependencies. The resulting directory is the build/publishing boundary: npm and API-catalog jobs consume it without loading a host or duplicating the DSL in build configuration.
The renderers remain directly available when deployment metadata must be supplied programmatically:
val openApiJson = ProductWebApi.renderOpenApi(
deployment = GatewayHttpDeployment(
title = "Product Web API",
apiVersion = "1.0.0",
serverUrls = listOf("https://api.example.com"),
),
)renderTypeScriptSdk() generates a protocol-neutral SDK with manager
namespaces and only the projected operations while preserving the generated DTO
shapes. Use it with either @carbide-ifx/rpc-sdk-rsocket or the separate
@carbide-ifx/rpc-sdk-http binding. The latter accepts ordinary Fetch request headers
for browser authentication and decodes NDJSON incrementally.
The ifx.subsystem bundle provides an opinionated development host with RSocket,
JSON-RPC, IActuator, and the browser Service Explorer. Passing 0 for either
port selects an available port. Host.development() only assembles the host;
suspending lifecycle work begins when start() is called:
import ifx.subsystem.development
val host = Host.development(rsocketPort = 8080, jsonRpcPort = 8081)
val testHost = Host.development()Every Host installs context propagation and unhandled-exception reporting as
mandatory interceptors. Passing interceptors only adds caller-defined layers;
it cannot replace the mandatory interceptors. Additional interceptors are
installed before the actuator and subsequent business services:
val host = Host.development(
interceptors = listOf(telemetry),
)For a custom protocol or tooling composition, construct Host directly.
This explicit composition is the production path: Host.development() exposes unauthenticated
RSocket and JSON-RPC listeners, actuator logs, and Service Explorer.
A proxy factory owns the client transport, so it is a long-lived object: hold one
per subsystem rather than creating one per call. Each factory keeps a single
binding — and therefore a single connection — per destination and service address, so
create<T>() is cheap and repeatable. This is what makes the common manager
shape safe:
class SalesManager(val proxyFactory: IProxyFactory) : ISalesManager {
val productAccess get() = proxyFactory.create<IProductAccess>()
}When a dependency lives on another host, bind a lightweight view of the factory to that destination. The view shares the factory's transport, interceptors, connection cache, and lifecycle:
val productAccess = proxyFactory
.at(ServiceEndpoint("product-service.internal", 8081))
.create<IProductAccess>()Add interceptors before creating the first proxy. The first create call freezes interceptor
configuration across the factory and all destination-bound views, so every proxy observes the same
pipeline. Caller-owned interceptor lists are copied.
Bindings are cached by destination and service address, so repeated at(endpoint).create<T>()
calls do not create additional transport clients or connections. They remain cached until the
factory closes, so the set of destinations should be stable and bounded. Keep endpoint construction
in the composition root when deployment configuration is static.
Close the factory during shutdown to release its connections:
try {
// serve requests
} finally {
proxyFactory.close()
host.stop()
}Proxies remain valid objects after close() but cannot make calls. A connection
that drops is replaced on the next call, so a factory survives a restart of the
service it points at. A failed call is never replayed.
Only acquiring a connection is bounded by a client-side timeout. Calls themselves
are not: an application deadline belongs to the caller, so wrap calls in
withTimeout when one is required. Errors raised by a remote service travel as
per-stream error frames and leave the shared connection intact.
Because calls carry no timeout of their own, the RSocket keep-alive is what detects a peer that stops responding, and it therefore sets the worst-case delay before a lost connection is noticed. The protocol default of a 20 s interval and 90 s lifetime means a call can wait roughly 110 s. Tighten it per factory when that is too slow:
val proxyFactory = RSocketProxyFactory.forHost(
host,
keepAlive = KeepAlive(interval = 2.seconds, maxLifetime = 6.seconds),
)Every failure — a dropped transport, a remote service exception, an exhausted
connect budget — reaches the caller as ProtocolException with the underlying
cause attached, matching the JSON-RPC client. Cancelling the caller is passed
through as cancellation, so withTimeout and structured concurrency behave
normally.
An interceptor is one onion layer around a complete RPC invocation. The invocation
is represented as a cold Flow<Message>: fire-and-forget emits nothing,
request/response emits once, and request streams emit normally. Keeping one model
for all three interaction types means cleanup, failures, cancellation, and future
telemetry spans can surround the full lifetime of a stream.
class TimingInterceptor : IInterceptor {
override fun intercept(
call: InterceptorCall,
next: InterceptorChain,
): Flow<Message> = flow {
val started = TimeSource.Monotonic.markNow()
try {
emitAll(next(call))
} finally {
println("${call.operation}: ${started.elapsedNow()}")
}
}
}Client interceptors run in registration order around the transport. Server
interceptors run in reverse order, so using [telemetry, encryption] on both sides
produces a symmetric onion:
client telemetry -> client encryption -> transport -> server encryption -> server telemetry -> service
Context is an immutable ambient container with no predefined application
fields. Every value placed in it is serialized immediately and propagated by
the host's mandatory ContextInterceptor:
@Serializable
@SerialName("ifx.caller")
data class Caller(val subject: String)
@Serializable
@SerialName("ifx.request")
data class RequestMetadata(val requestId: String)
val interceptors = listOf(
Encryption,
)
host.addInterceptors(interceptors)
proxyFactory.addInterceptors(host.interceptors)
withContext(Context().set(Caller("user-42"))) {
client.awesome(request)
}On the server, propagated values are installed in the coroutine context
for the complete invocation, including stream collection, and can be read with
Context.current().getOrNull<Caller>(). Context values must be serializable;
use a stable @SerialName as their cross-system identity. Unknown values remain
as opaque JSON and can pass through systems that do not understand them. The
host places context before caller interceptors in client order, so reversed
server ordering decrypts or decodes headers before context extraction. Generic
JSON headers can be inspected or changed with Message.headers() and
Message.withHeader(...).
Service application logs can carry their generated service identity through Kermit's string tag while retaining a readable console tag:
class AwesomeServiceImpl : AwesomeService {
private val repositoryLog = log.withTag("Repository")
override suspend fun awesome(request: AwesomeRequest): AwesomeResponse {
repositoryLog.info { "Loading $request" }
// ...
}
}While the host executes a service, it supplies the registered interface and implementation identity
to the inherited logger. The standard writer renders this as AwesomeServiceImpl.Repository. The log-tail
writer retains the structured contract address, implementation class, tag path,
severity, message, and throwable. Plain framework log tags continue to reach the
standard writer but are not retained. LogTail.logs(address) returns the latest
500 entries for that service address in sequence order. LogTail.latest(address)
exposes the retained tail and future entries as a non-blocking
Flow<LogTailEntry>. The writer, retention store, and entry model belong to the
ifx.actuator module; ifx.logging only provides structured tags and the generic
writer installation point. The retained tail is deliberately process-wide, so multiple
actuators in one process expose the same entries. Additional writers receive a removable
registration from installLogWriter() and are isolated so their failures cannot fail service code.
Every hosted service also reports non-cancellation exceptions that escape its server invocation. The error log carries the service interface, implementation class, and operation as its structured path before the original exception is re-thrown to the active transport. Exception reporting never replaces the original RPC failure or changes the transport's error response.
Register the separate actuator service to expose that flow through the normal service transport. Callers use the generated actuator client or a Carbide proxy; there is no separate HTTP streaming endpoint:
host.registerActuator()
val actuator = proxyFactory.create<IActuator>()
val catalog = actuator.catalog()
actuator.logTail<AwesomeService>().collect { entry ->
println(entry.message)
}ifx.telemetry.otel provides tracing without depending on a platform-specific
OpenTelemetry SDK. It propagates W3C traceparent/tracestate headers and exports
OTLP/HTTP JSON through Ktor on JVM and macOS.
val exporter = OtlpHttpSpanExporter(
endpoint = "http://localhost:4318/v1/traces",
)
val spanProcessor = BatchSpanProcessor(
exporter = exporter,
onDroppedSpans = { dropped ->
Log("OpenTelemetry").warn {
"Dropped ${dropped.count} spans: ${dropped.reason}"
}
},
)
val rpcMetrics = RpcMetrics(
exporter = OtlpHttpMetricExporter(
endpoint = "http://localhost:4318/v1/metrics",
),
onExportFailure = { error ->
Log("OpenTelemetry").warn(error) { "Failed to export RPC metrics" }
},
)
val telemetry = TelemetryRuntime(
spanProcessor = spanProcessor,
resource = TelemetryResource(
serviceName = "sales-manager",
serviceNamespace = "commerce",
serviceVersion = "2.1.0",
serviceInstanceId = instanceId,
deploymentEnvironmentName = "production",
attributes = mapOf("cloud.region" to "eu-west-1"),
),
rpcMetrics = rpcMetrics,
)
val interceptors = listOf(
telemetry.rpcInterceptor(logRpcCalls = true),
Encryption,
)
host.addInterceptors(interceptors)
proxyFactory.addInterceptors(interceptors)The same runtime exposes the tracer used by RPC instrumentation. Manual spans inherit the active RPC span and automatically update log correlation:
telemetry.tracer.span("load-products") {
setAttribute("product.count", products.size)
repository.loadProducts()
}
repository.products()
.inSpan(telemetry.tracer, "stream-products")
.collect { product -> consume(product) }Use links for asynchronous or many-to-many causality where parent/child nesting would be misleading:
telemetry.tracer.span(
name = "process orders",
kind = SpanKind.CONSUMER,
links = listOf(SpanLink(messageCreationContext)),
) {
process(messages)
}Links supplied at creation are visible to the sampler. A recording span can also call addLink(...)
when a relationship is discovered later. Each tracer retains at most 128 links per span by default;
configure TelemetryRuntime(maxLinksPerSpan = ...) to change the bound. Additional links are reported
through the OTLP droppedLinksCount field.
Install the optional OpenTelemetryClientPlugin from ifx.telemetry.otel on application HTTP
clients to create client spans and inject W3C trace context automatically:
val httpClient = HttpClient {
install(OpenTelemetryClientPlugin) {
tracer = telemetry.tracer
}
}Do not install the plugin on the OTLP exporter's own client. shouldInstrument can exclude collector,
health-check, or other requests that should not be traced. HTTP spans end after response headers are
received; later response-body consumption is not included.
The default sampler is ParentBasedSampler(AlwaysOnSampler): root traces are sampled and child
spans preserve the upstream sampled flag. To sample ten percent of new traces while preserving
upstream decisions, configure
sampler = ParentBasedSampler(ProbabilitySampler(probability = 0.1)). AlwaysOffSampler is also
available for disabling root trace export without disabling trace-context propagation.
Set logRpcCalls = true to emit correlated RPC request and response logs from the telemetry
interceptor. Their structured tags carry trace_id, span_id, and trace_flags; do not add a
separate RPC logging interceptor. RPC diagnostics are written to the console but are not retained in
actuator log tails, which contain service application logs only.
The Log severity methods are suspending, so application logs emitted inside an instrumented RPC
automatically receive the same correlation: log.info { "Loading products" }. Third-party logging
interfaces that cannot suspend use the isolated log.synchronous escape hatch.
Place telemetry before interceptors that encode or encrypt message headers. The
server reverses the list, so the same ordering decrypts traceparent before the
telemetry layer extracts it.
BatchSpanProcessor places completed spans on a bounded queue, exports full or scheduled batches,
and keeps collector latency out of the RPC path. Queue overflow, shutdown rejection, export timeout,
and export failure are reported through onDroppedSpans; the processor never retries inside the
application. Call flush() to export queued spans without stopping, and call suspending shutdown()
from the application lifecycle to drain the queue and close the HTTP exporter. TelemetryRuntime
combines span and RPC metric flush/shutdown when both are configured. Spans completed after shutdown
are rejected and reported.
Passing a SpanExporter directly to OpenTelemetryRpcInterceptor remains available for intentionally
synchronous export. The serviceName constructor remains as shorthand for a resource containing only
service.name.
RpcMetrics records rpc.client.call.duration and rpc.server.call.duration as cumulative
histograms in seconds. Recording only updates an in-process aggregate; OtlpHttpMetricExporter sends
it periodically, on flush(), and during suspending shutdown(). Call telemetry.shutdown() from
the application lifecycle after stopping RPC traffic.
Service modules do not generate descriptors or proxies. Modules that declare interfaces
inheriting IService apply the index processor, which generates only a small contract
index:
settings:
kotlin:
ksp:
processors:
- io.carbide-ifx:ifx.contract.ksp:0.1.0A subsystem's KSP run reads the reachable contract indexes and generates one Kotlin descriptor and proxy beside each contract name. No descriptor or proxy is generated in the service module, and no aggregate runtime registry is generated.
Only subsystem/application modules apply the RPC generator and compiler plugin:
settings:
kotlin:
ksp:
processors:
- io.carbide-ifx:ifx.subsystem.ksp:0.1.0
# Optional: generate TypeScript contracts and wire types.
- io.carbide-ifx:ifx.rpc.typescript.ksp:0.1.0
compilerPlugins:
- id: ifx.rpc.compiler
dependency: io.carbide-ifx:ifx.rpc.compiler:0.1.0The subsystem dependency graph is the contract manifest. Contract modules depend only on
ifx.service at runtime; that dependency exports the common context, logging, and standard-library
facilities, while contract modules do not generate RPC bindings or depend on protocol code.
Adding or removing a service-module dependency changes the generated descriptors without a
second service list or annotation. Apply the index processor only to modules that declare
service contracts; subsystem and unrelated infrastructure modules do not need it.
Host.development is the standard application factory from ifx.subsystem. The compiler
plugin supplies its generated IActuator descriptor and rewrites typed registerService<T> and
IProxyFactory.create<T> calls to pass the matching generated ServiceDescriptor<T>
directly. Proxy factories created with forHost obtain only the host address and
interceptors; descriptor selection remains compile-time:
import ifx.subsystem.development
val host = Host.development(name = "Test System")
host.registerService<IProductAccess> { ProductAccessEmulator() }Reusable helpers can accept a defaulted ServiceDescriptor<T> parameter. The compiler
plugin fills that argument in the consuming subsystem, which is how Host.development() and
registerActuator() remain usable while actuator itself continues to generate only a
contract index.
Code compiled without the plugin can use the low-level APIs by passing a generated descriptor explicitly:
host.registerService(IProductAccessDescriptor) { ProductAccessEmulator() }
val client = proxyFactory.create(IProductAccessDescriptor)In a multiplatform application, KSP emits individual descriptors into each platform source set and the compiler plugin links them directly on JVM and Native. Keep host assembly in the corresponding platform source sets. On Native, the processor first generates an empty package anchor, then discovers dependency KLIB indexes in the following KSP round. This keeps dependency aggregation automatic without an explicit contract list.
Generation does not expose a contract. Only registerService publishes an endpoint.
Descriptor linking uses no runtime lookup, classpath scanning, reflection, or
associated-object mutation of dependency contracts.
The optional ifx.rpc.typescript.ksp processor generates a TypeScript service
interface, operation request/response aliases, and all reachable serializable
types. User-defined request and response types must use @Serializable and
custom or contextual serializers are rejected because their wire shape cannot
be inferred from KSP symbols. It shares the canonical ifx.rpc.schema.ksp model
with descriptor generation, so Kotlin descriptors, TypeScript SDKs, gateway SDKs,
and OpenAPI documents do not maintain competing interpretations of the contract.
The ifx.host.webapp module provides a general WebApp host extension for
mounting a built web application directory on any listener. The web build remains
an ordinary npm, Vite, esbuild, or other frontend build; this extension only serves
its output and does not know about RPC services or tooling.
val host = Host(name = "Example") {
listen(RSocketServerProtocol(), port = 8080) {
install(WebApp(directory = "webapp/dist"))
}
}The ifx.service.explorer module bundles the Service Explorer's npm build. The
explorer targets an RSocket listener because its browser client invokes services
and streams logs through RSocket. Host.development() always installs it; callers do
not configure or package a frontend directory. Custom hosts can install
ServiceExplorer directly. The landing page obtains the host catalog and
per-service health from the registered IActuator utility service; no separate
HTTP catalog endpoint is exposed. Selecting a component opens its operations,
generates request controls from the serialized wire types, and displays
request/response, fire-and-forget, and streaming results. The standard host also
publishes Kubernetes-compatible JSON probes at /ifx/health/ready,
/ifx/health/live, and /ifx/health on its RSocket HTTP listener.
Set drainDelay on Host.development() to the deployment's endpoint-propagation window; it defaults
to zero so local shutdown is immediate. requestDrainTimeout bounds how long shutdown waits for
accepted calls and streams before stopping services and listeners.
For example, a host resolved to port 8080 exposes the UI at
http://localhost:8080/. The webapp calls IActuator.catalog() through the
ordinary generated service SDK.
val host = Host.development(
name = "Test System",
rsocketPort = 8080,
)The frontend build is published inside ifx.service.explorer for JVM and Native.
JVM uses ordinary JAR resources; Native uses a generated compressed asset
projection because Native library resources require application-level packaging.
Running the frontend build updates these projections and its local dist/
directory.
Generated TypeScript contracts export a {Service}Description value in
addition to their typed SDK. It contains the same operations and runtime
wire-type schema used by the hosted explorer, so other development tools can
reuse the metadata without attempting to reflect on erased TypeScript types.
Each generated contract also contains a protocol-neutral concrete
{Service}Sdk. Choose a separate protocol package when connecting it. The
protocol SDK entrypoint appends the generated service address to its base URL,
while the generated service SDK sends the exact Kotlin operation signatures
through the selected binding:
import { RSocketSdk } from "@carbide-ifx/rpc-sdk-rsocket";
import { JsonRpcSdk } from "@carbide-ifx/rpc-sdk-jsonrpc";
import { ISalesManagerSdk } from "./generated/ISalesManager";
const streamingSdk = await RSocketSdk.connect(
ISalesManagerSdk,
"ws://localhost:7000",
);
const jsonRpcSdk = await JsonRpcSdk.connect(
ISalesManagerSdk,
"http://localhost:7001",
);
try {
for await (const product of streamingSdk.listProducts()) {
console.log(product)
}
} finally {
streamingSdk.close()
jsonRpcSdk.close()
}@carbide-ifx/rpc-sdk contains only the shared binding, generated SDK, service
description, header, and interceptor contracts. @carbide-ifx/rpc-sdk-rsocket owns
RSocket/WebSocket dependencies and supports all interaction types.
@carbide-ifx/rpc-sdk-jsonrpc uses Fetch and supports notifications and
request/response; request streams fail explicitly because JSON-RPC over HTTP
has no standard streaming interaction. The RSocket dependencies remain pinned
to 1.0.0-alpha.3; this upstream API is still an alpha.
ifx.Kotlin
ifx.cloud
ifx.office
Service discovery Test client with UI /Transparent/ proxy
In the name of efficiency, effectiveness and productivity:
Infrastructure. For code
Hand off point Framework for running and testing services Communication layer - isolate business (service) code Formalized guidelines - contstraints Hosting Flow Rules Security
![[Pasted image 20221025080516.png]]
![[Pasted image 20221025080654.png]]
JVM .NET (some way to call out to python)
Request/response Fire-and forget Streaming
ProxyFactory Invocation Call Serve
Endpoints
RSocket JSON-RPC gRPC?
Platforms Dapr/Kubernets Local single executable Net Jvm
Message bus
Workflow
Encrypted calls
Authentication
Identity propagation
Authorization
Security audits
Transactions propagation
Transactions voting
Calls timeout
Reliability
Tracing and logging
Profiling and instrumentation
Instance management
Durability
Error masking
Fault isolation
Channel faulting
Buffering and throttling
Data versioning tolerance
Synchronization and synchronization context
MBV
Remotability
Interoperability
Queuing
Service bus
Discovery
Serialization ![[Pasted image 20221031111311.png]]
![[Pasted image 20221031114433.png]]
Carbide is a Kotlin Multiplatform framework for defining typed service contracts, hosting their implementations, and calling them over interchangeable RPC protocols. Start with:
The sections below are reference documentation for individual capabilities.
./kotlin buildAn RPC service is just an interface extending IService and its implementation:
interface AwesomeService : IService {
suspend fun awesome(request: AwesomeRequest): AwesomeResponse
}
class AwesomeServiceImpl : AwesomeService {
override suspend fun awesome(request: AwesomeRequest): AwesomeResponse = TODO()
}
host.registerService<AwesomeService> {
AwesomeServiceImpl()
}
val client = proxyFactory.create<AwesomeService>()IService is the common service programming surface. It supplies a stable logger per implementation
class and brings Carbide context, logging, and standard-library utilities into contract modules. An
implementation that owns resources may also implement IServiceLifecycle; the host calls its
suspending start, health, and stop methods locally. Lifecycle methods are never generated as
remotely callable service operations.
Applications that host a subsystem can use the published ifx.subsystem module
as their single Carbide runtime dependency:
dependencies:
- io.carbide-ifx:ifx.subsystem:0.1.0It exports the host, RSocket and JSON-RPC protocols and proxy factories,
interceptor contracts, context and logging support, OpenTelemetry, the actuator,
and host tooling such as ServiceExplorer. Host.development() provides an
unauthenticated dual-protocol host with actuator inspection enabled. Production applications
should construct Host directly and install only the listeners and utilities they intend to expose.
Generated service bindings still require the subsystem/application KSP and compiler-plugin setup described below. Those are build-time tools rather than runtime dependencies.
The bundle supports JVM and macOS ARM64 and publishes platform-correct multiplatform metadata.
Runnable JVM subsystem modules can enable the local ifx.build.jib Amper plugin:
product:
type: jvm/app
settings:
jvm:
mainClass: com.example.CustomerSubsystemKt
plugins:
ifx.build.jib:
enabled: true
image: example/customer-subsystem:dev
ports: [ 8080, 8081 ]The plugin adds three module tasks:
./kotlin do jibTar -m customer.subsystem # cacheable image tar, no Docker daemon
./kotlin do jibDocker -m customer.subsystem # load the image into the local Docker daemon
./kotlin do jibPush -m customer.subsystem # push directly to the configured registryThe default base is the non-root Java 21 distroless image. baseImage,
jvmArgs, tags, ports, environment, and labels can be overridden per
subsystem. Build outputs such as an npm web application remain ordinary files
and can be copied into the image as their own layer:
plugins:
ifx.build.jib:
enabled: true
image: example/customer-subsystem:dev
extraDirectories:
- source: //typescript/customer-ui/dist
destination: /app/webapps/customerThe source directory must already have been produced by its owning build. Jib
tracks its contents as task inputs and copies them verbatim; it does not embed
them in Kotlin sources or JAR resources. Registry push and private-base pulls
use standard Docker credential discovery; set targetCredentialHelper or
baseCredentialHelper when a named helper is required. Do not put registry
passwords in module configuration. Pin baseImage by digest when builds must
remain reproducible across base-image updates.
Kotlin Toolchain currently supports only local custom plugin modules. Downstream
repositories must therefore vendor this small ifx.build.jib module until external
plugin publication is supported.
Host owns service registration and the lifecycle of its Ktor servers. Each
configured listener exposes exactly one protocol on its own port. A listener uses
the registered service endpoints by default, or an EndpointSource can replace
them with an immutable projection. Protocol implementations only install their
routes and wire handling into the listener provided by the host.
import ifx.subsystem.development
val host = Host.development(
name = "Example System",
rsocketPort = 7000,
jsonRpcPort = 7001,
)
host.registerService<AwesomeService> { AwesomeServiceImpl() }
host.start()
val rsocketClient = RSocketProxyFactory.forHost(host).create<AwesomeService>()
val jsonRpcClient = JsonRpcProxyFactory.forHost(host).create<AwesomeService>()Using separate Ktor applications prevents route and plugin collisions between
protocols and gives each listener an independent port and network interface,
with a boundary for protocol-specific TLS and authentication. A listener
configured with port = 0 receives an available port at startup; use
host.port(RSOCKET_PROTOCOL_ID) or
host.port(JSON_RPC_PROTOCOL_ID) to read the resolved value.
RSocket supports fire-and-forget, request/response, and request-stream
interactions. Regular JSON-RPC over HTTP supports fire-and-forget notifications
and request/response. Calling a service operation that returns Flow through the
JSON-RPC client fails explicitly because JSON-RPC has no standard streaming
interaction.
A gateway is a static, transport-neutral projection of existing service operations. Generated service descriptors expose owner-typed operation values, so the common case contains no routes, DTO mapping, annotations, or duplicate interfaces:
val ProductWebApi = gateway("product-web") {
expose(IProductAccessDescriptor) {
only(filter, generateRandowProduct)
}
expose(ISalesManagerDescriptor)
}expose(descriptor) includes all ordinary operations; inherited service
lifecycle operations are excluded. only(...) narrows the set. Service names,
operation names, and the surface address follow conventions and can be versioned
or explicitly renamed in the projection.
For an embedded gateway, give the same endpoint source to each public listener. Internal listeners can continue to use the complete registered endpoint set:
val publicEndpoints = ProductWebApi.endpointSource()
val host = Host {
listen(RSocketServerProtocol(), id = "internal-rsocket")
listen(
RSocketServerProtocol(rSocketAuthenticator),
id = "public-rsocket",
endpointSource = publicEndpoints,
)
listen(
GatewayHttpServerProtocol(httpAuthenticator),
endpointSource = publicEndpoints,
)
}For a separate gateway process, keep the projection unchanged and supply typed remote targets:
val publicEndpoints = ProductWebApi.endpointSource {
remote(IProductAccessDescriptor, productRSocketClient)
remote(ISalesManagerDescriptor, salesRSocketClient)
}The public RSocket surface is one service address (product-web above), with
routes such as productAccess/filter. Setup authentication establishes trusted
context for the connection and overwrites any client-supplied context. The
conventional HTTP adapter publishes
POST /api/{surface}/{manager}/{operation}; request streams use newline-delimited JSON
events named next, complete, and error. It is deliberately separate from
JSON-RPC.
OpenAPI 3.1 is served at /api/{surface}/openapi.json and can also be emitted as
a build artifact without starting a host. Enable the artifact plugin on the
module that declares the projections (the module must already run
ifx.subsystem.ksp):
plugins:
ifx.build.gateway:
enabled: trueThen run:
./kotlin do gatewayArtifacts -m product.gatewayKSP finds every non-private top-level val whose inferred type is
GatewayProjection; no annotation or projection-name string is needed. The
task writes one deterministic directory per public address:
gateway/
├── product-web/
│ ├── sdk.ts
│ └── openapi.json
└── product-web/v2/
├── sdk.ts
└── openapi.json
Only indexes generated into the declaring module's own JAR are loaded, so a gateway does not accidentally publish projections from its dependencies. The resulting directory is the build/publishing boundary: npm and API-catalog jobs consume it without loading a host or duplicating the DSL in build configuration.
The renderers remain directly available when deployment metadata must be supplied programmatically:
val openApiJson = ProductWebApi.renderOpenApi(
deployment = GatewayHttpDeployment(
title = "Product Web API",
apiVersion = "1.0.0",
serverUrls = listOf("https://api.example.com"),
),
)renderTypeScriptSdk() generates a protocol-neutral SDK with manager
namespaces and only the projected operations while preserving the generated DTO
shapes. Use it with either @carbide-ifx/rpc-sdk-rsocket or the separate
@carbide-ifx/rpc-sdk-http binding. The latter accepts ordinary Fetch request headers
for browser authentication and decodes NDJSON incrementally.
The ifx.subsystem bundle provides an opinionated development host with RSocket,
JSON-RPC, IActuator, and the browser Service Explorer. Passing 0 for either
port selects an available port. Host.development() only assembles the host;
suspending lifecycle work begins when start() is called:
import ifx.subsystem.development
val host = Host.development(rsocketPort = 8080, jsonRpcPort = 8081)
val testHost = Host.development()Every Host installs context propagation and unhandled-exception reporting as
mandatory interceptors. Passing interceptors only adds caller-defined layers;
it cannot replace the mandatory interceptors. Additional interceptors are
installed before the actuator and subsequent business services:
val host = Host.development(
interceptors = listOf(telemetry),
)For a custom protocol or tooling composition, construct Host directly.
This explicit composition is the production path: Host.development() exposes unauthenticated
RSocket and JSON-RPC listeners, actuator logs, and Service Explorer.
A proxy factory owns the client transport, so it is a long-lived object: hold one
per subsystem rather than creating one per call. Each factory keeps a single
binding — and therefore a single connection — per destination and service address, so
create<T>() is cheap and repeatable. This is what makes the common manager
shape safe:
class SalesManager(val proxyFactory: IProxyFactory) : ISalesManager {
val productAccess get() = proxyFactory.create<IProductAccess>()
}When a dependency lives on another host, bind a lightweight view of the factory to that destination. The view shares the factory's transport, interceptors, connection cache, and lifecycle:
val productAccess = proxyFactory
.at(ServiceEndpoint("product-service.internal", 8081))
.create<IProductAccess>()Add interceptors before creating the first proxy. The first create call freezes interceptor
configuration across the factory and all destination-bound views, so every proxy observes the same
pipeline. Caller-owned interceptor lists are copied.
Bindings are cached by destination and service address, so repeated at(endpoint).create<T>()
calls do not create additional transport clients or connections. They remain cached until the
factory closes, so the set of destinations should be stable and bounded. Keep endpoint construction
in the composition root when deployment configuration is static.
Close the factory during shutdown to release its connections:
try {
// serve requests
} finally {
proxyFactory.close()
host.stop()
}Proxies remain valid objects after close() but cannot make calls. A connection
that drops is replaced on the next call, so a factory survives a restart of the
service it points at. A failed call is never replayed.
Only acquiring a connection is bounded by a client-side timeout. Calls themselves
are not: an application deadline belongs to the caller, so wrap calls in
withTimeout when one is required. Errors raised by a remote service travel as
per-stream error frames and leave the shared connection intact.
Because calls carry no timeout of their own, the RSocket keep-alive is what detects a peer that stops responding, and it therefore sets the worst-case delay before a lost connection is noticed. The protocol default of a 20 s interval and 90 s lifetime means a call can wait roughly 110 s. Tighten it per factory when that is too slow:
val proxyFactory = RSocketProxyFactory.forHost(
host,
keepAlive = KeepAlive(interval = 2.seconds, maxLifetime = 6.seconds),
)Every failure — a dropped transport, a remote service exception, an exhausted
connect budget — reaches the caller as ProtocolException with the underlying
cause attached, matching the JSON-RPC client. Cancelling the caller is passed
through as cancellation, so withTimeout and structured concurrency behave
normally.
An interceptor is one onion layer around a complete RPC invocation. The invocation
is represented as a cold Flow<Message>: fire-and-forget emits nothing,
request/response emits once, and request streams emit normally. Keeping one model
for all three interaction types means cleanup, failures, cancellation, and future
telemetry spans can surround the full lifetime of a stream.
class TimingInterceptor : IInterceptor {
override fun intercept(
call: InterceptorCall,
next: InterceptorChain,
): Flow<Message> = flow {
val started = TimeSource.Monotonic.markNow()
try {
emitAll(next(call))
} finally {
println("${call.operation}: ${started.elapsedNow()}")
}
}
}Client interceptors run in registration order around the transport. Server
interceptors run in reverse order, so using [telemetry, encryption] on both sides
produces a symmetric onion:
client telemetry -> client encryption -> transport -> server encryption -> server telemetry -> service
Context is an immutable ambient container with no predefined application
fields. Every value placed in it is serialized immediately and propagated by
the host's mandatory ContextInterceptor:
@Serializable
@SerialName("ifx.caller")
data class Caller(val subject: String)
@Serializable
@SerialName("ifx.request")
data class RequestMetadata(val requestId: String)
val interceptors = listOf(
Encryption,
)
host.addInterceptors(interceptors)
proxyFactory.addInterceptors(host.interceptors)
withContext(Context().set(Caller("user-42"))) {
client.awesome(request)
}On the server, propagated values are installed in the coroutine context
for the complete invocation, including stream collection, and can be read with
Context.current().getOrNull<Caller>(). Context values must be serializable;
use a stable @SerialName as their cross-system identity. Unknown values remain
as opaque JSON and can pass through systems that do not understand them. The
host places context before caller interceptors in client order, so reversed
server ordering decrypts or decodes headers before context extraction. Generic
JSON headers can be inspected or changed with Message.headers() and
Message.withHeader(...).
Service application logs can carry their generated service identity through Kermit's string tag while retaining a readable console tag:
class AwesomeServiceImpl : AwesomeService {
private val repositoryLog = log.withTag("Repository")
override suspend fun awesome(request: AwesomeRequest): AwesomeResponse {
repositoryLog.info { "Loading $request" }
// ...
}
}While the host executes a service, it supplies the registered interface and implementation identity
to the inherited logger. The standard writer renders this as AwesomeServiceImpl.Repository. The log-tail
writer retains the structured contract address, implementation class, tag path,
severity, message, and throwable. Plain framework log tags continue to reach the
standard writer but are not retained. LogTail.logs(address) returns the latest
500 entries for that service address in sequence order. LogTail.latest(address)
exposes the retained tail and future entries as a non-blocking
Flow<LogTailEntry>. The writer, retention store, and entry model belong to the
ifx.actuator module; ifx.logging only provides structured tags and the generic
writer installation point. The retained tail is deliberately process-wide, so multiple
actuators in one process expose the same entries. Additional writers receive a removable
registration from installLogWriter() and are isolated so their failures cannot fail service code.
Every hosted service also reports non-cancellation exceptions that escape its server invocation. The error log carries the service interface, implementation class, and operation as its structured path before the original exception is re-thrown to the active transport. Exception reporting never replaces the original RPC failure or changes the transport's error response.
Register the separate actuator service to expose that flow through the normal service transport. Callers use the generated actuator client or a Carbide proxy; there is no separate HTTP streaming endpoint:
host.registerActuator()
val actuator = proxyFactory.create<IActuator>()
val catalog = actuator.catalog()
actuator.logTail<AwesomeService>().collect { entry ->
println(entry.message)
}ifx.telemetry.otel provides tracing without depending on a platform-specific
OpenTelemetry SDK. It propagates W3C traceparent/tracestate headers and exports
OTLP/HTTP JSON through Ktor on JVM and macOS.
val exporter = OtlpHttpSpanExporter(
endpoint = "http://localhost:4318/v1/traces",
)
val spanProcessor = BatchSpanProcessor(
exporter = exporter,
onDroppedSpans = { dropped ->
Log("OpenTelemetry").warn {
"Dropped ${dropped.count} spans: ${dropped.reason}"
}
},
)
val rpcMetrics = RpcMetrics(
exporter = OtlpHttpMetricExporter(
endpoint = "http://localhost:4318/v1/metrics",
),
onExportFailure = { error ->
Log("OpenTelemetry").warn(error) { "Failed to export RPC metrics" }
},
)
val telemetry = TelemetryRuntime(
spanProcessor = spanProcessor,
resource = TelemetryResource(
serviceName = "sales-manager",
serviceNamespace = "commerce",
serviceVersion = "2.1.0",
serviceInstanceId = instanceId,
deploymentEnvironmentName = "production",
attributes = mapOf("cloud.region" to "eu-west-1"),
),
rpcMetrics = rpcMetrics,
)
val interceptors = listOf(
telemetry.rpcInterceptor(logRpcCalls = true),
Encryption,
)
host.addInterceptors(interceptors)
proxyFactory.addInterceptors(interceptors)The same runtime exposes the tracer used by RPC instrumentation. Manual spans inherit the active RPC span and automatically update log correlation:
telemetry.tracer.span("load-products") {
setAttribute("product.count", products.size)
repository.loadProducts()
}
repository.products()
.inSpan(telemetry.tracer, "stream-products")
.collect { product -> consume(product) }Use links for asynchronous or many-to-many causality where parent/child nesting would be misleading:
telemetry.tracer.span(
name = "process orders",
kind = SpanKind.CONSUMER,
links = listOf(SpanLink(messageCreationContext)),
) {
process(messages)
}Links supplied at creation are visible to the sampler. A recording span can also call addLink(...)
when a relationship is discovered later. Each tracer retains at most 128 links per span by default;
configure TelemetryRuntime(maxLinksPerSpan = ...) to change the bound. Additional links are reported
through the OTLP droppedLinksCount field.
Install the optional OpenTelemetryClientPlugin from ifx.telemetry.otel on application HTTP
clients to create client spans and inject W3C trace context automatically:
val httpClient = HttpClient {
install(OpenTelemetryClientPlugin) {
tracer = telemetry.tracer
}
}Do not install the plugin on the OTLP exporter's own client. shouldInstrument can exclude collector,
health-check, or other requests that should not be traced. HTTP spans end after response headers are
received; later response-body consumption is not included.
The default sampler is ParentBasedSampler(AlwaysOnSampler): root traces are sampled and child
spans preserve the upstream sampled flag. To sample ten percent of new traces while preserving
upstream decisions, configure
sampler = ParentBasedSampler(ProbabilitySampler(probability = 0.1)). AlwaysOffSampler is also
available for disabling root trace export without disabling trace-context propagation.
Set logRpcCalls = true to emit correlated RPC request and response logs from the telemetry
interceptor. Their structured tags carry trace_id, span_id, and trace_flags; do not add a
separate RPC logging interceptor. RPC diagnostics are written to the console but are not retained in
actuator log tails, which contain service application logs only.
The Log severity methods are suspending, so application logs emitted inside an instrumented RPC
automatically receive the same correlation: log.info { "Loading products" }. Third-party logging
interfaces that cannot suspend use the isolated log.synchronous escape hatch.
Place telemetry before interceptors that encode or encrypt message headers. The
server reverses the list, so the same ordering decrypts traceparent before the
telemetry layer extracts it.
BatchSpanProcessor places completed spans on a bounded queue, exports full or scheduled batches,
and keeps collector latency out of the RPC path. Queue overflow, shutdown rejection, export timeout,
and export failure are reported through onDroppedSpans; the processor never retries inside the
application. Call flush() to export queued spans without stopping, and call suspending shutdown()
from the application lifecycle to drain the queue and close the HTTP exporter. TelemetryRuntime
combines span and RPC metric flush/shutdown when both are configured. Spans completed after shutdown
are rejected and reported.
Passing a SpanExporter directly to OpenTelemetryRpcInterceptor remains available for intentionally
synchronous export. The serviceName constructor remains as shorthand for a resource containing only
service.name.
RpcMetrics records rpc.client.call.duration and rpc.server.call.duration as cumulative
histograms in seconds. Recording only updates an in-process aggregate; OtlpHttpMetricExporter sends
it periodically, on flush(), and during suspending shutdown(). Call telemetry.shutdown() from
the application lifecycle after stopping RPC traffic.
Service modules do not generate descriptors or proxies. Modules that declare interfaces
inheriting IService apply the index processor, which generates only a small contract
index:
settings:
kotlin:
ksp:
processors:
- io.carbide-ifx:ifx.contract.ksp:0.1.0A subsystem's KSP run reads the reachable contract indexes and generates one Kotlin descriptor and proxy beside each contract name. No descriptor or proxy is generated in the service module, and no aggregate runtime registry is generated.
Only subsystem/application modules apply the RPC generator and compiler plugin:
settings:
kotlin:
ksp:
processors:
- io.carbide-ifx:ifx.subsystem.ksp:0.1.0
# Optional: generate TypeScript contracts and wire types.
- io.carbide-ifx:ifx.rpc.typescript.ksp:0.1.0
compilerPlugins:
- id: ifx.rpc.compiler
dependency: io.carbide-ifx:ifx.rpc.compiler:0.1.0The subsystem dependency graph is the contract manifest. Contract modules depend only on
ifx.service at runtime; that dependency exports the common context, logging, and standard-library
facilities, while contract modules do not generate RPC bindings or depend on protocol code.
Adding or removing a service-module dependency changes the generated descriptors without a
second service list or annotation. Apply the index processor only to modules that declare
service contracts; subsystem and unrelated infrastructure modules do not need it.
Host.development is the standard application factory from ifx.subsystem. The compiler
plugin supplies its generated IActuator descriptor and rewrites typed registerService<T> and
IProxyFactory.create<T> calls to pass the matching generated ServiceDescriptor<T>
directly. Proxy factories created with forHost obtain only the host address and
interceptors; descriptor selection remains compile-time:
import ifx.subsystem.development
val host = Host.development(name = "Test System")
host.registerService<IProductAccess> { ProductAccessEmulator() }Reusable helpers can accept a defaulted ServiceDescriptor<T> parameter. The compiler
plugin fills that argument in the consuming subsystem, which is how Host.development() and
registerActuator() remain usable while actuator itself continues to generate only a
contract index.
Code compiled without the plugin can use the low-level APIs by passing a generated descriptor explicitly:
host.registerService(IProductAccessDescriptor) { ProductAccessEmulator() }
val client = proxyFactory.create(IProductAccessDescriptor)In a multiplatform application, KSP emits individual descriptors into each platform source set and the compiler plugin links them directly on JVM and Native. Keep host assembly in the corresponding platform source sets. On Native, the processor first generates an empty package anchor, then discovers dependency KLIB indexes in the following KSP round. This keeps dependency aggregation automatic without an explicit contract list.
Generation does not expose a contract. Only registerService publishes an endpoint.
Descriptor linking uses no runtime lookup, classpath scanning, reflection, or
associated-object mutation of dependency contracts.
The optional ifx.rpc.typescript.ksp processor generates a TypeScript service
interface, operation request/response aliases, and all reachable serializable
types. User-defined request and response types must use @Serializable and
custom or contextual serializers are rejected because their wire shape cannot
be inferred from KSP symbols. It shares the canonical ifx.rpc.schema.ksp model
with descriptor generation, so Kotlin descriptors, TypeScript SDKs, gateway SDKs,
and OpenAPI documents do not maintain competing interpretations of the contract.
The ifx.host.webapp module provides a general WebApp host extension for
mounting a built web application directory on any listener. The web build remains
an ordinary npm, Vite, esbuild, or other frontend build; this extension only serves
its output and does not know about RPC services or tooling.
val host = Host(name = "Example") {
listen(RSocketServerProtocol(), port = 8080) {
install(WebApp(directory = "webapp/dist"))
}
}The ifx.service.explorer module bundles the Service Explorer's npm build. The
explorer targets an RSocket listener because its browser client invokes services
and streams logs through RSocket. Host.development() always installs it; callers do
not configure or package a frontend directory. Custom hosts can install
ServiceExplorer directly. The landing page obtains the host catalog and
per-service health from the registered IActuator utility service; no separate
HTTP catalog endpoint is exposed. Selecting a component opens its operations,
generates request controls from the serialized wire types, and displays
request/response, fire-and-forget, and streaming results. The standard host also
publishes Kubernetes-compatible JSON probes at /ifx/health/ready,
/ifx/health/live, and /ifx/health on its RSocket HTTP listener.
Set drainDelay on Host.development() to the deployment's endpoint-propagation window; it defaults
to zero so local shutdown is immediate. requestDrainTimeout bounds how long shutdown waits for
accepted calls and streams before stopping services and listeners.
For example, a host resolved to port 8080 exposes the UI at
http://localhost:8080/. The webapp calls IActuator.catalog() through the
ordinary generated service SDK.
val host = Host.development(
name = "Test System",
rsocketPort = 8080,
)The frontend build is published inside ifx.service.explorer for JVM and Native.
JVM uses ordinary JAR resources; Native uses a generated compressed asset
projection because Native library resources require application-level packaging.
Running the frontend build updates these projections and its local dist/
directory.
Generated TypeScript contracts export a {Service}Description value in
addition to their typed SDK. It contains the same operations and runtime
wire-type schema used by the hosted explorer, so other development tools can
reuse the metadata without attempting to reflect on erased TypeScript types.
Each generated contract also contains a protocol-neutral concrete
{Service}Sdk. Choose a separate protocol package when connecting it. The
protocol SDK entrypoint appends the generated service address to its base URL,
while the generated service SDK sends the exact Kotlin operation signatures
through the selected binding:
import { RSocketSdk } from "@carbide-ifx/rpc-sdk-rsocket";
import { JsonRpcSdk } from "@carbide-ifx/rpc-sdk-jsonrpc";
import { ISalesManagerSdk } from "./generated/ISalesManager";
const streamingSdk = await RSocketSdk.connect(
ISalesManagerSdk,
"ws://localhost:7000",
);
const jsonRpcSdk = await JsonRpcSdk.connect(
ISalesManagerSdk,
"http://localhost:7001",
);
try {
for await (const product of streamingSdk.listProducts()) {
console.log(product)
}
} finally {
streamingSdk.close()
jsonRpcSdk.close()
}@carbide-ifx/rpc-sdk contains only the shared binding, generated SDK, service
description, header, and interceptor contracts. @carbide-ifx/rpc-sdk-rsocket owns
RSocket/WebSocket dependencies and supports all interaction types.
@carbide-ifx/rpc-sdk-jsonrpc uses Fetch and supports notifications and
request/response; request streams fail explicitly because JSON-RPC over HTTP
has no standard streaming interaction. The RSocket dependencies remain pinned
to 1.0.0-alpha.3; this upstream API is still an alpha.
ifx.Kotlin
ifx.cloud
ifx.office
Service discovery Test client with UI /Transparent/ proxy
In the name of efficiency, effectiveness and productivity:
Infrastructure. For code
Hand off point Framework for running and testing services Communication layer - isolate business (service) code Formalized guidelines - contstraints Hosting Flow Rules Security
![[Pasted image 20221025080516.png]]
![[Pasted image 20221025080654.png]]
JVM .NET (some way to call out to python)
Request/response Fire-and forget Streaming
ProxyFactory Invocation Call Serve
Endpoints
RSocket JSON-RPC gRPC?
Platforms Dapr/Kubernets Local single executable Net Jvm
Message bus
Workflow
Encrypted calls
Authentication
Identity propagation
Authorization
Security audits
Transactions propagation
Transactions voting
Calls timeout
Reliability
Tracing and logging
Profiling and instrumentation
Instance management
Durability
Error masking
Fault isolation
Channel faulting
Buffering and throttling
Data versioning tolerance
Synchronization and synchronization context
MBV
Remotability
Interoperability
Queuing
Service bus
Discovery
Serialization ![[Pasted image 20221031111311.png]]
![[Pasted image 20221031114433.png]]