Guides
Ports & adapters
The core never touches the outside world directly. Time, randomness, the network, storage and the app's lifecycle all come through ports: traits the platform implements and the core calls.
What a port is#
A port is a Rust trait marked #[undra::port]. The platform implements it (an adapter), or a Rust fake does in a test. The core calls it through the context, for example ctx.http().request(req).await, and does not know or care what is behind it. That is what keeps the core deterministic (rule R12): it has no wall clock, no random source and no threads outside the Clock, Rng and Timer ports and the runtime.
There are three kinds, and the kind decides how a call travels:
| Kind | Meaning |
|---|---|
| sync | the platform answers before the call returns. A port is sync only if every method is sync |
| async | the platform answers later; the core's task waits, and can be cancelled |
| event | fire and forget from the platform into the core, such as "connectivity changed" |
The ten standard ports#
| Port | Kind | Methods |
|---|---|---|
Clock | sync | now_ms() -> i64, monotonic_ns() -> u64 |
Rng | sync | fill(len) -> Bytes |
Log | sync | log(level, target, message) |
Http | async | request(HttpRequest) -> Result<HttpResponse, HttpError> |
Kv | async | get, set, delete, list(prefix) |
SecureStore | async | the same shape as Kv, for secrets |
Fs | async | read, write, delete, list, each returning Result<_, FsError> |
Timer | sync | set(timer_id, delay_ms); the platform answers later with a timer-fired event |
Connectivity | event | changed(online, kind) with NetKind of Wifi, Cellular, Wired, Unknown or None |
Lifecycle | event | changed(state) with AppState of Active, Inactive or Background |
They exchange a small set of types: HttpRequest (method, url, headers, optional body, optional timeout), HttpResponse (status, headers, body), HttpError (Network, Timeout, Cancelled, InvalidUrl), Header, FsError (NotFound, Denied, Io), NetKind and AppState.
These ports and types ship inside each platform runtime and are not generated into your app's bindings. Every core links undra-ports, so its schema contains them and the schema hash covers them, but the generator leaves them out and the generated code refers to the runtime's own types. That is why your app can have its own record called Timer or Log.
Default adapters#
Each runtime ships an adapter for every port, so a new app works without adapter code.
| Port | Swift | Kotlin (Android) | TypeScript (browser) |
|---|---|---|---|
Http | URLSession | HttpURLConnection | fetch |
Kv | files in Application Support | files in filesDir | IndexedDB |
SecureStore | Keychain | AES-256-GCM under an Android Keystore key | IndexedDB with WebCrypto |
Fs | FileManager | Context.filesDir | OPFS |
Clock, Rng, Log | Foundation, SecRandom, os_log | System, SecureRandom, android.util.Log | built in |
Timer | DispatchQueue | ScheduledExecutor | setTimeout |
Connectivity, Lifecycle | NWPathMonitor, scenePhase | ConnectivityManager, activity lifecycle callbacks | navigator.onLine, visibilitychange |
Every default store is per core namespace (<namespace>/kv under the app's undra directory in Application Support or filesDir, a Keychain service or Keystore alias and an IndexedDB database named after it), so two cores of one app never read each other's keys, secrets or files; an adapter you pass yourself keeps the location you gave it.
On Android one call installs all ten, AndroidPlatformDefaults.install(core, context); the runtime alone installs only Clock, Rng, Log and Timer. On the JVM without Android, Connectivity and Lifecycle are stubs. On the web, Clock, Rng and Log are bound to imports of the wasm module, so a web app needs no adapter code for them.
Under React Native, loadNative installs all ten too: Kv, SecureStore, Fs and Connectivity are native code in @undra/react-native (files in the app's private storage, the Keychain or the Android Keystore, NWPathMonitor or ConnectivityManager, with the Swift and Android adapters' file layouts), Http is React Native's fetch and Lifecycle its AppState (docs/REACT_NATIVE.md).
Supplying your own adapter#
You replace a default when you load the core. The playground replaces Http with an in-memory server (so it needs no backend) and Kv with a map:
const adapters = { http: server, kv: memoryKv() };
await UndraPlaygroundCore.load({
mode: "wasm-main",
wasm: new URL(wasmUrl, location.href),
adapters,
});let adapters = Adapters.platformDefault
.removing(portId: fnv1a32("port.Connectivity"))
.replacing(PlaygroundNetwork.shared)
.replacing(KvAdapter(directory: store))
try UndraPlaygroundCore.load(.inproc(adapters: adapters))val core = UndraPlaygroundCore.load(
LoadOptions(mirror = MirrorOptions(framePacer = ChoreographerFramePacer())),
)
// Http, Kv, SecureStore, Fs, Connectivity and Lifecycle: the real adapters, nothing faked.
AndroidPlatformDefaults.install(core, this)On Android the playground keeps every default: its Remote tab talks to a small HTTP server on the device's loopback interface, through the real Http adapter. To replace one port there, register another implementation after install (core.registerPort(StandardPorts.Http.PORT_ID, impl)).
Register the ports your core needs when you load it. A core's start-up work (query hydration reads the Kv port) runs as soon as the runtime exists. It asks for Kv every 100 ms for five seconds, so adapters registered right after the load (Android's install) are in time; a port registered later than that is missed, and the cache then starts empty.
Your network stack inside#
The defaults above are the platform's own. If your app has a network stack of its own (a token that is refreshed, interceptors, tracing, certificate pinning), a request the core makes through a default adapter does not go through it: on Android HttpURLConnection is OkHttp underneath, but the platform's instance, not your OkHttpClient. Hand your stack to the three network ports instead (Http, WebSocket, Sse) and every request the core makes goes through it. You do not write the adapters: the contract the core relies on (typed errors, a timeout that covers the body, cancellation that closes the socket, a size cap, streams that stop the server when the core stops reading) is already implemented, and the same test suites check it on each.
| Platform | You pass | Your stack applies |
|---|---|---|
| Android | your OkHttpClient, to the optional okhttp-adapters module | interceptors, Authenticator (a 401 is answered with a refreshed token and the request goes again), event listeners, CertificatePinner, Dns, proxy, cookie jar, dispatcher (a WebSocket's upgrade is the exception OkHttp makes: your application interceptors see it, your network interceptors and event listener do not) |
| iOS and macOS | your URLSession, to HttpAdapter, URLSessionWebSocketAdapter and URLSessionSseAdapter | its configuration and URLProtocol classes, its delegate (server trust and pinning, authentication challenges), proxy, cookies |
| Web | your fetch and WebSocket, to fetchHttp, fetchSse and browserWebSocket | whatever your wrapper does to a request: a token, a retry on 401, a trace header |
| React Native | nothing: reactNativeHttp() is React Native's fetch | React Native's own networking module, so what the app configures there |
// In place of AndroidPlatformDefaults.install(core, this): the same ten ports, and Http, WebSocket and Sse go through your client.
AndroidPlatformDefaults.installWithOkHttp(core, this, appGraph.okHttpClient)let adapters = Adapters.platformDefault
.replacing(HttpAdapter(session: session))
.replacing(URLSessionWebSocketAdapter(session: session))
.replacing(URLSessionSseAdapter(session: streamingSession)) // same delegate, a configuration that lets a stream be quietconst adapters = { ...browserAdapters(), http: fetchHttp({ fetch: appFetch }) };
const ports = {
[OptInPortIds.WebSocket.portId]: webSocketPort(browserWebSocket({ WebSocket: AppWebSocket })),
[OptInPortIds.Sse.portId]: ssePort(fetchSse({ fetch: appFetch })),
};The pieces and the places they differ from the defaults are documented where they live: the okhttp-adapters README (redirects, retries and timeouts are your client's; a WebSocket on OkHttp repairs malformed UTF-8 in a text frame instead of failing the connection, and OkHttp tells neither your event listener nor your network interceptors about a WebSocket's upgrade; a stream is opened on a client derived from yours with no read timeout and HTTP/1.1), and the cookbook recipe, with the code of all three. The decision is ADR-060.
When a port is missing#
An unavailable port is a normal outcome, not a bug. A port nobody registered behaves as unavailable, a call can be cancelled, and a host can reply with bytes that do not decode. The core handles all three without panicking:
- If the port method returns
Result<T, E>, the failure becomes anE.HttpErrorandFsErrorimplementFrom<PortError>, so an unavailableHttpport isNetwork("the Http port has no adapter registered (E0062: register one, see ..)"), with the code and the link to the fix. Your own error type needs the same conversion, and the macro checks it at compile time (E0033). - If the method has no error channel, there is no typed way to say "unavailable", so the call panics with a message that names the port and method and says how to bind one (
E0062, in the shape of every diagnostic: what, why, fix, docs link). The runtime contains that panic at the boundary. Give optional ports aResult, or register them.
Writing your own port#
Mark a trait with #[undra::port]. The macro registers it in the schema and generates a proxy type, so the core calls your trait like any other, and each platform gets a protocol or interface to implement and an adapter to register. The cookbook's Haptics port is the whole story on three platforms (the recipe); the code below is the code that compiles there.
/// A tap the user feels: the platform's haptics engine.
#[undra::port(sync)]
pub trait Haptics {
/// One tap, `strength` from 0 (nothing) to 255 (the strongest the device has).
fn tap(&self, strength: u8);
}/// Confirms an action the user just took: one light tap through the [`Haptics`] port.
#[undra::api]
pub fn confirm(ctx: &Ctx) {
haptics(ctx).tap(LIGHT);
}A port method's parameters must be wire types (E0030), and an event port's methods cannot return a value (E0031). A port with no Result is a port every platform registers: a call on one nobody registered is E0062 (above).
Swift: closures, or a class#
The generated Ports.swift holds three things for the port: the protocol (public protocol Haptics: UndraPort, Sendable, each requirement nonisolated), hapticsPortImpl(_:) for a class that conforms, and hapticsPortImpl(tap:), which takes one closure per method. The core calls a port from its own thread. That is why a class that conforms is nonisolated, and why, under Xcode 26's default main-actor isolation (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, what Xcode 26 sets for a new project), the compiler refuses a var in it (stored property 'store' of 'Sendable'-conforming class 'MyKv' is mutable) and a read of main-actor state in a synchronous method (call to main actor-isolated .. in a synchronous nonisolated context). The closure form has no state to own and the same thread rule: a closure literal passed to a @Sendable parameter is nonisolated whatever the file's default, an async closure reads main-actor state with await MainActor.run { .. }, and a synchronous one is refused the read (main actor-isolated property .. can not be referenced from a Sendable closure). A closure for a method with a typed error names it in its clause ({ req async throws(HttpError) in .. }), since Swift infers any Error otherwise. The file that registers the port needs import UndraRuntime.
// From closures: nothing conforms, and the form that compiles unchanged under default main-actor isolation
// (Xcode 26). The closure runs on the core's thread.
let haptics = hapticsPortImpl(tap: { strength in Haptic.play(strength) })
// Or a class. A class that conforms to a port protocol is nonisolated, since the core calls it from its own
// thread: it keeps its state in `let`s or behind a lock, and reads main-actor state from an async port only,
// with `await MainActor.run { .. }`.
final class AppHaptics: Haptics {
func tap(strength: UInt8) { Haptic.play(strength) }
}
// Registered with the rest of the adapters, before the core loads: `load(.inproc(adapters: adapters))`.
let adapters = Adapters.platformDefault
.replacing(PortImplAdapter(portId: UndraIds.Ports.Haptics.portId, impl: haptics))Kotlin#
An object (or any class) that implements the generated interface, registered after the platform defaults. A method of a sync port runs on the thread the core calls from; a suspend method of an async port is answered later, as a coroutine.
val haptics = object : Haptics {
override fun tap(strength: UByte) = vibrator.tap(strength)
}
// After AndroidPlatformDefaults.install(core, this): served from Kotlin, on the thread the core calls from.
fun registerHaptics(core: UndraCore) {
core.registerPort(UndraIds.Ports.Haptics.PORT_ID, hapticsPortImpl(haptics))
}TypeScript#
An object literal, passed in ports when the core loads. A sync port is answered where the core runs: with the core in a worker (wasm-worker), it goes in the worker module's ports instead (workers).
const haptics: Haptics = {
tap(strength) {
navigator.vibrate(strength > 128 ? 40 : 15);
},
};
// A sync port is answered where the core runs: with the core on the page, in `ports`; with the core in a
// worker, in the worker module's `ports` (`worker.ports`).
export const core = await UndraCookbook.load({
mode: "wasm-main",
wasm: new URL("cookbook.wasm", import.meta.url),
ports: { [UndraIds.Ports.Haptics.portId]: hapticsPortImpl(haptics) },
});Testing with fakes#
Every standard port has a deterministic Rust fake in undra::ports::fakes. They are all Send + Sync, keep state behind a lock, and never read the system clock, a random source or a thread.
| Fake | Port | Behaviour |
|---|---|---|
FakeHttp | Http | scripted replies chosen by a matcher; every request recorded |
MemKv, MemSecureStore | Kv, SecureStore | in-memory ordered maps; operations recorded |
MemFs | Fs | in-memory tree with the platform adapters' error semantics |
FakeClock | Clock and Timer | settable time; advance fires due timers |
SeededRng | Rng | xorshift64*; the same seed gives the same bytes |
CaptureLog | Log | keeps every record |
ScriptedConnectivity, ScriptedLifecycle | Connectivity, Lifecycle | push scripted events into a runtime |
TestRuntime::new() runs the executor on the test thread and lets you drive it with run_until(fut) and run_pending(). Installing the fakes binds all of them into it. The doctest of FakeHttp shows the style:
use undra_ports::{Http, HttpError, HttpRequest, HttpResponse};
use undra_ports::fakes::FakeHttp;
use undra_runtime::testing::TestRuntime;
let http = FakeHttp::new();
// A flaky endpoint: two timeouts, then success for ever after.
http.respond_sequence("https://x.test/a", [Err(HttpError::Timeout), Err(HttpError::Timeout)]);
http.respond("https://x.test/a", HttpResponse::new(200, b"ok".to_vec()));
let t = TestRuntime::new();
let get = || HttpRequest::get("https://x.test/a");
assert_eq!(t.run_until(http.request(get())), Err(HttpError::Timeout));
assert_eq!(t.run_until(http.request(get())), Err(HttpError::Timeout));
assert_eq!(t.run_until(http.request(get())).unwrap().status, 200);
assert_eq!(http.call_count(), 3);Time works the same way. FakeClock is the single source of time for both Clock and Timer, so ctx.sleep asks for a timer and advancing the fake clock serves it: sleeps wake at exactly their deadline. On a runtime with the fakes installed, advance time with Fakes::advance, not TestRuntime::advance.
The platforms get the same treatment. The contract scenarios run every platform runtime against the real core with a manual clock, an in-memory Http server, an in-memory Kv and a test that emits connectivity events itself.
The test kit puts these fakes behind the real core in Swift, Kotlin and TypeScript too, with a manual clock, and replays recorded port traffic: Testing your app.