Cookbook

Your network stack

Your app already has one: a token it refreshes, interceptors, tracing, certificate pinning. Hand it to the core's network ports and every request the core makes goes through it, with nothing Undra-specific to configure.

The problem#

The core never makes a request; the platform does, through the Http port (and WebSocket and Sse where the core asks for them). The default adapters use the platform's own client. That is not your client: on Android HttpURLConnection is OkHttp underneath, but the platform's instance, so a request from the core carries no token, shows in no trace and is not pinned. The fix is not an adapter of your own (the contract is hundreds of lines: typed errors, a timeout that covers the body, cancellation that closes the socket, streams that stop the server when the core stops reading). It is passing your client to the adapter that already is that contract.

The recipe#

Show the Swift, Kotlin and TypeScript
swift
/// The app's session delegate: pinning and authentication challenges are answered here, for every request the core makes.
final class AppSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        // Your pinning goes here: compare challenge.protectionSpace.serverTrust with the certificate you ship.
        return (.performDefaultHandling, nil)
    }
}

/// The ports that carry the core's traffic, over the app's own session. Event streams get a session of their own, with the
/// same delegate and a configuration that lets a stream stay quiet.
func appAdapters(configuration: URLSessionConfiguration, delegate: AppSessionDelegate) -> Adapters {
    configuration.httpAdditionalHeaders = ["X-App-Version": "1.4.2"]   // on everything the core sends
    let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)

    let streaming = (configuration.copy() as? URLSessionConfiguration) ?? .default
    streaming.timeoutIntervalForRequest = 24 * 60 * 60
    streaming.httpMaximumConnectionsPerHost = 1_024
    let streams = URLSession(configuration: streaming, delegate: delegate, delegateQueue: nil)

    return Adapters.platformDefault
        .replacing(HttpAdapter(session: session))
        .replacing(URLSessionWebSocketAdapter(session: session))
        .replacing(URLSessionSseAdapter(session: streams))
}
kotlin
/** The app's one client: a token on every request, a refresh when the server says 401, and pinning. */
fun appClient(tokens: TokenStore): OkHttpClient =
    OkHttpClient.Builder()
        .addInterceptor { chain ->
            val signed = chain.request().newBuilder().header("Authorization", "Bearer ${tokens.access}").build()
            chain.proceed(signed)
        }
        .authenticator { _, response ->
            // OkHttp asks on a 401. A request that is already a retry gives up: the refresh did not help.
            if (response.priorResponse != null) return@authenticator null
            val fresh = tokens.refresh() ?: return@authenticator null
            response.request.newBuilder().header("Authorization", "Bearer $fresh").build()
        }
        .certificatePinner(CertificatePinner.Builder().add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").build())
        .build()

/** In `Application.onCreate`, in place of `AndroidPlatformDefaults.install(core, this)`: Http, WebSocket and Sse go through [client]. */
fun installNetwork(core: UndraCore, context: Context, client: OkHttpClient) {
    AndroidPlatformDefaults.installWithOkHttp(core, context, client)
}
tsx
// The app's fetch: a token on every request, a refresh when the server says 401. The core's requests go through it too.
const appFetch: typeof fetch = async (input, init) => {
  const send = () => {
    const headers = new Headers(init?.headers);
    headers.set("authorization", `Bearer ${session.accessToken}`);
    return fetch(input, { ...init, headers });
  };
  const first = await send();
  if (first.status !== 401) return first;
  await session.refresh();
  return send();
};

export const adapters = { ...browserAdapters(), http: fetchHttp({ fetch: appFetch }) };
export const ports = {
  [OptInPortIds.WebSocket.portId]: webSocketPort(browserWebSocket({ WebSocket: AppWebSocket })),
  [OptInPortIds.Sse.portId]: ssePort(fetchSse({ fetch: appFetch })),
};

What goes through your stack, and what is yours#

PortKotlin on OkHttpSwift on URLSession
Httpthe whole call, as the app's own requests gothe whole task
WebSocketthe upgrade request (OkHttp gives its listener no WebSocket events)the upgrade: the connection is a task of your session
Ssethe request, on a client derived from yours with no read timeout and HTTP/1.1the request, on the session you give the Sse adapter

What your client decides stays yours: redirects, retries, the timeouts when a request has none of its own, compression, caching, how many calls run at once (the core's requests wait their turn with yours). The core's contract holds on top: an error status is a response, a failure is a typed error, the request's own timeout bounds the whole exchange, cancelling the call closes the socket.

What the tests prove#

Limits#

Source: okhttp-adapters/README.md and ADR-060. The ports: Your network stack inside.

Edit this page on GitHub