Threading model
BridgeKit is opinionated about threads because the predecessors’ threading bugs were the expensive ones (ANRs, dead-callback pumping, crashes on teardown). The rules differ between Android and iOS because the concurrency primitives differ, but the principle is the same: the engine is thread-agnostic; it never dispatches to the main thread on your behalf.
Encoding ≠ validation
Section titled “Encoding ≠ validation”Two distinct layers, often confused:
- Encoding is always on, production included. The codec walks declared fields only,
skipping
undefinedand functions — O(declared fields), no JSON round-trip. Raw user objects never reach the AnyMap converter, which kills thesanitizeParamscrash class at the root. - Validation (
VALIDATION_FAILEDon type mismatch) is a dev-only layer on top.
Android — Kotlin concurrency
Section titled “Android — Kotlin concurrency”Provider methods run on Dispatchers.Default. Providers may switch context freely. There is
no runBlocking anywhere in BridgeKit.
The main-thread ANR guard
Section titled “The main-thread ANR guard”The host has a documented runBlocking habit. A blocking consume + a 10s readiness wait on
the main looper would be a guaranteed ANR. So BridgeKit’s blocking wait path detects the
main looper and fails fast with a loud error naming the contract — a clear exception instead
of a frozen UI.
// runBlocking around a bridgekit call is documented as forbidden.// Use the suspending or explicit alternatives instead:val lia = bridgekit.tryConsume(LiaFeatureContract) // non-suspendingif (bridgekit.isProvided(LiaFeatureContract)) { /* ... */ }bridgekit.awaitProvided(LiaFeatureContract, timeout) // explicit waitInbound async invoke() runs on CoroutineScope(SupervisorJob() + Dispatchers.Default).
Sync invokeSync runs on the calling (JS) thread — the provider must not block.
fire() runs on a supervised fireScope and swallows errors. Stream pumps run on
engineScope; state observers are called inline on the triggering thread
(typically Dispatchers.Default).
iOS — Swift concurrency
Section titled “iOS — Swift concurrency”Provider methods run in whatever context the engine’s Task starts on. BridgeKit iOS uses
Swift Task / async-await throughout. There is no runBlocking equivalent, and there are
no @MainActor annotations inside the engine.
Router is NOT an actor
Section titled “Router is NOT an actor”Router is guarded by an NSRecursiveLock, not a Swift actor. An actor would serialize
all calls to its executor, which would deadlock querySync specs: the JS thread calls
into the Nitro C++ seam synchronously, which calls Router — an actor hop would park the
call on a task executor that is not the JS thread, causing a deadlock before the sync call
could return.
NSRecursiveLock is reentrant on the same thread, allowing sync paths to nest correctly.
OnceContinuation guards every CheckedContinuation
Section titled “OnceContinuation guards every CheckedContinuation”Swift crashes if a CheckedContinuation is resumed more than once. OnceContinuation
(ios/engine/OnceContinuation.swift) wraps every CheckedContinuation used in the engine
(ParkBuffer, OutboundCallerImpl, stream end signals) and ignores any attempt at double-resume.
invokeSync toward JS-provided contracts is NOT_SUPPORTED
Section titled “invokeSync toward JS-provided contracts is NOT_SUPPORTED”OutboundCallerImpl.invokeSync throws BridgeKitError.NOT_SUPPORTED. JS cannot be called
synchronously from native on iOS (or Android). If you need a synchronous result, restructure
as a query and use try await client.method().
withBridgeTimeout
Section titled “withBridgeTimeout”Outbound calls are raced against Task.sleep via withBridgeTimeout
(OutboundCallerImpl.swift). This is the Swift equivalent of the Kotlin withTimeout wrapper.
iOS minimum version: 15.1
Section titled “iOS minimum version: 15.1”BridgeKit iOS requires iOS 15.1+. The engine uses the closure-based
AsyncStream { continuation in … } initializer (not the AsyncStream.makeStream() API
introduced in iOS 16).
// Consuming an async-provided contract from Swift:let client: any ConnectHostClient = BridgeKitRuntime.default.consume(ConnectHostContract())let version = try await client.getAppVersion()
// State — iterate on a background Task, dispatch to main when needed:Task { for await bv in client.connectivity { await MainActor.run { self.updateUI(bv) } }}Readiness timeout vs call timeout (both platforms)
Section titled “Readiness timeout vs call timeout (both platforms)”Two separate budgets:
- Readiness timeout — how long a call waits for (dispatcher connected AND contract
provided) before giving up. Default 5000ms (10s readiness poll on iOS via 10ms tick in
awaitDispatcher). - Call timeout — how long the provider has to produce a result once it’s running.
30s default on Android (
withTimeout);withBridgeTimeouton iOS.
A JS call into a not-yet-provided contract waits, bounded (covering the
Activity/scene-recreation gap) instead of failing instantly. A binding closing with reason
replacing holds incoming calls for a short grace window; only a final close yields
CONTRACT_NOT_PROVIDED.
Cancellation
Section titled “Cancellation”JS proxies accept an AbortSignal:
const controller = new AbortController();const p = connect.installEsim({ url, iccId }, { signal: controller.signal, timeoutMs: 30_000 });controller.abort(); // propagates CANCELLED into the provider's coroutine / TaskOn Android, cancelling the JS caller propagates CANCELLED to the provider’s Kotlin
coroutine. On iOS, the corresponding Task is cancelled. On the native consume side,
cancelling the collecting Task triggers the stream close path, which runs the JS producer’s
teardown.
The dispatcher never rejects
Section titled “The dispatcher never rejects”The JS dispatcher catches everything and resolves { ok: false, … }; it never throws at the
transport level. The native router wraps every native → JS dispatch in a timeout + try/catch,
mapping dead-runtime throws, never-settling promises, and JS rejections to envelope codes.
The Nitro double-await() / double-Promise of value-returning JS callbacks is encapsulated
in one router function so feature code never sees it.