Skip to content

Native → JS (reverse)

Native consumes · JS provides

In the reverse direction, JS registers an implementation and native calls into it. All four markers — Async, Void, Stream, State — work native → JS over the real transport, proven on-device on both Android (Kotlin) and iOS (Swift).

The demo contract bridgekit.demo-reverse exercises every one:

// demo-reverse.contract.ts — provided by JS, consumed by NATIVE
export const useDemoReverse = defineContract('bridgekit.demo-reverse', {
methods: {
greetFromJs: Async<{ name: string }, string>(), // native awaits a JS result
onNativeEvent: Void<{ type: string; payload?: unknown }>(), // fire-and-forget into JS
},
streams: {
jsCounter: Stream<number>(), // native collects JS-emitted values
},
state: {
jsStatus: State<string>('js-idle'), // native observes JS-owned state
},
});

Generated outbound code calls into a frozen OutboundCaller interface, implemented by OutboundCallerImpl. It is the native → JS call engine:

@BridgeKitGeneratedApiV1
interface OutboundCaller {
suspend fun invoke(member: String, payload: Map<String, Any?>?): Any?
fun invokeSync(member: String, payload: Map<String, Any?>?): Any?
fun fire(member: String, payload: Map<String, Any?>?)
fun stream(member: String, payload: Map<String, Any?>?): Flow<Any?>
fun state(member: String): StateFlow<BridgeValue<Any?>>
}

Every outbound op first calls awaitDispatcher() — it parks until the JS dispatcher is connected (bounded by a readiness timeout) before sending anything across.

val reply: String = demoReverse.greetFromJs(GreetFromJsParams(name = "Android"))

OutboundCallerImpl.invoke() parks on awaitDispatcher(), builds a call envelope, and hands it to JsDispatcherCallbacks.onInvoke. The result is collected through a CompletableDeferred guarded by withTimeout(callTimeoutMs).

useDemoReverse.useProvide({
greetFromJs: ({ name }) => Promise.resolve(`Hi ${name}, from JS!`),
});
demoReverse.onNativeEvent(OnNativeEventParams(type = "native-tap", payload = "button_a"))

OutboundCallerImpl.fire() launches a coroutine on a supervised scope and swallows all errors.

useDemoReverse.useProvide({
onNativeEvent: ({ type, payload }) => { console.log(`event: ${type}`); },
});

Stream — native subscribes to a JS-provided stream

Section titled “Stream — native subscribes to a JS-provided stream”
demoReverse.jsCounter().take(5).onEach { v -> Log.i(TAG, "tick=$v") }.launchIn(appScope)

OutboundCallerImpl.stream() returns a callbackFlow. After awaitDispatcher() it generates a streamId, maps it to a Channel in the router, and calls callbacks.onStreamOpen(envelope) to start the JS producer.

useDemoReverse.useProvide({
jsCounter: () => streamSource<number>((emit, end) => {
let n = 0;
const iv = setInterval(() => emit(n++), 1000);
return () => clearInterval(iv); // teardown on close
}),
});

On collector cancellation (break or scope cancel), onStreamClose is sent to JS.

reverse.jsStatus.onEach { bv ->
when (bv) {
is BridgeValue.Available -> Log.i(TAG, "state=${bv.value}")
is BridgeValue.Initial -> Log.i(TAG, "initial(${bv.value})")
is BridgeValue.Replacing -> Log.i(TAG, "replacing(${bv.lastKnown})")
is BridgeValue.Unprovided -> Log.i(TAG, "unprovided(${bv.lastKnown})")
}
}.launchIn(appScope)

OutboundCallerImpl.state(member) returns router.stateStore.getFlow(...) as a StateFlow<BridgeValue<T>>.

// JS pushes state to native:
const binding = bk.provide(useDemoReverse, demoReverseImpl);
binding.setState('jsStatus', 'js-ready');

Under the hood, binding.setState() triggers transport.pushProviderState(...), which routes to StateStore.writeFromJs() on the native side. The router rejects with NOT_PROVIDER if native already owns a binding for this contract — single-writer is enforced in both directions. Binding close transitions the native-side stream to Unprovided.


When the consumer and provider are both in JS, none of this transport machinery runs — see Local-first resolution.