Skip to content

Live events

Instead of polling, subscribe to a context’s real-time events. mero.events(contextIds) opens a Server-Sent-Events stream and emits a ContextEvent for each node emission — new messages, application-version flips, and other context activity — so a screen can react as changes arrive.

events(contextIds) returns a cold Flow<ContextEvent>. Collect it inside a coroutine you can cancel:

val job = scope.launch {
mero.events(contextIds = listOf(contextId)).collect { event ->
when (event.kind) {
"StateMutation" -> reloadMessages() // the context state moved
"SyncStatus" -> updateSyncIndicator(event) // syncing / waitingForPeers / …
else -> println("event on ${event.contextId}: ${event.kind}")
}
}
}

Each event is:

data class ContextEvent(
val contextId: String,
val kind: String, // "StateMutation" | "SyncStatus" | …
val payload: JsonElement, // raw event JSON
)

For a contract emission (kind == "StateMutation") the encoded contract events are nested under payload’s data.events[].data; each carries its own kind (MessageSent above) and a byte array.

The stream reconnects automatically ~3 seconds after a drop and re-subscribes to your context ids (the node persists the session’s subscriptions). You don’t need to rebuild the flow on a transient network blip — the collector keeps delivering events across reconnects.

A 403 is the exception, and it ends the flow rather than restarting it. Two things produce one: a token that never carried context:subscribe, and a refresh family that was revoked (core answers that with x-auth-error and an empty body). Neither is fixed by waiting, so retrying every three seconds is not resilience — it is a silent outage, a live-looking subscription delivering nothing while no exception ever reaches the collector. Instead:

  • a revoked family throws AuthRevokedException — route the user back to login;
  • any other 403 throws HttpException(status = 403) — the token is missing context:subscribe.
scope.launch {
try {
mero.events(listOf(contextId)).collect { reload() }
} catch (e: AuthRevokedException) {
forceReLogin()
} catch (e: HttpException) {
if (e.status == 403) reportMissingSubscribeGrant()
}
}

The stream lives for as long as its collecting coroutine — cancel that job (or the enclosing scope) to close the SSE connection:

job.cancel() // closes the connection

LaunchedEffect(contextId) ties collection to a composable’s lifetime — it starts when the composable enters composition and is cancelled when contextId changes or it leaves:

@Composable
fun MessagesScreen(mero: Mero, contextId: String) {
var messages by remember { mutableStateOf(emptyList<Message>()) }
LaunchedEffect(contextId) {
mero.events(contextIds = listOf(contextId)).collect {
messages = loadMessages()
}
}
MessageList(messages)
}