Message interface between core and shell

So far in this book, we've been taking the perspective of being inside the core looking out. It feels like it's now time to be in the shell, looking in.

Note

Interestingly, we think this is also the way to approach building apps with Crux. For any one feature, start in the middle and get your behaviour established first. Write the tests without the UI and the other side-effects in the way. Give yourself maximum confidence that the feature works exactly as you expect before you muddy the water with UI components, and their look and feel.

OK, let's talk about the shell.

The shell only has two responsibilities:

  1. Laying out the UI components
  2. Supporting the app's capabilities

We'll look at these separately. But first let's remind ourselves of how we interact with the core (now would be a good time to read Shared core and types if you haven't already).

The message protocol

The interface is message based, and uses serialization to pass data back and forth. The core exports the types for all the data so that it can be used and created on the shell side with safety.

An Event can be passed in directly, as-is. Processing of Effects is a little more complicated, because the core needs to be able to pair the outcomes of the effects with the original capability call, so it can return them to the right caller. To do that, effects are wrapped in a Request, which tags them with a UUID. To respond, the same UUID needs to be passed back in.

Requests from the core are emitted serialized, and need to be deserialized first. Both events and effect outputs need to be serialized before being passed back to the core.

Sharp edge

It is likely that this will become an implementation detail and instead, Crux will provide a more ergonomic shell-side API for the interaction, hiding both the UUID pairing and the serialization (and allowing us to iterate on the FFI implementation which, we think, could work better).

The core interface

There are only three touch-points with the core.

#![allow(unused)]
fn main() {
pub fn process_event(data: &[u8]) -> Vec<u8> { todo!() }
pub fn handle_response(uuid: &[u8], data: &[u8]) -> Vec<u8> { todo!() }
pub fn view() -> Vec<u8> { todo!() }
}

The process_event function takes a serialized Event (from a UI interaction) and returns a serialized vector of Requests that the shell can dispatch to the relevant capability's shell-side code (see the section below on how the shell handles capabilities).

The handle_response function, used to return capability output back into the core, is similar to process_event except that it also takes a uuid, which ties the output (for example a HTTP response) being submitted with it's original Effect which started it (and the corresponding request which the core wrapped it in).

The view function simply retrieves the serialized view model (to which the UI is bound) and is called by the shell after it receives a Render request. The view model is a projection of the app's state – it reflects what information the Core wants displayed on screen.

You're probably thinking, "Whoa! I just see slices and vectors of bytes, where's the type safety?". Well, the answer is that we also generate all the types that pass through the bridge, for each language, along with serialization and deserialization helpers. This is done by the serde-generate crate (see the section on Create the shared types crate).

Sharp edge

For now we have to manually invoke the serialization code in the shell. At some point this may be abstracted away.

In this code snippet from the Counter example, notice that we call processEvent and handleResponse on the core depending on whether we received an Event from the UI or from a capability, respectively. Regardless of which core function we call, we get back a bunch of requests, which we can iterate through and do the relevant thing (the following snippet triggers a render of the UI, or makes an HTTP call, or launches a task to wait for Server Sent Events, depending on what the core requested):

sealed class Outcome {
    data class Http(val res: HttpResponse) : Outcome()
    data class Sse(val res: SseResponse) : Outcome()
}

sealed class CoreMessage {
    data class Event(val event: Evt) : CoreMessage()
    data class Response(val uuid: List<UByte>, val outcome: Outcome) : CoreMessage()
}

class Model : ViewModel() {
    var view: MyViewModel by mutableStateOf(MyViewModel("", false))
        private set

    suspend fun update(msg: CoreMessage) {
        val requests: List<Req> =
            when (msg) {
                is CoreMessage.Event ->
                    Requests.bincodeDeserialize(
                    processEvent(msg.event.bincodeSerialize().toUByteArray().toList())
                            .toUByteArray()
                            .toByteArray()
                    )
                is CoreMessage.Response ->
                    Requests.bincodeDeserialize(
                        handleResponse(
                            msg.uuid.toList(),
                            when (msg.outcome) {
                                is Outcome.Http -> msg.outcome.res.bincodeSerialize()
                                is Outcome.Sse -> msg.outcome.res.bincodeSerialize()
                            }.toUByteArray().toList()
                        ).toUByteArray().toByteArray()
                    )
            }

        for (req in requests) when (val effect = req.effect) {
            is Effect.Render -> {
                this.view = MyViewModel.bincodeDeserialize(view().toUByteArray().toByteArray())
            }
            is Effect.Http -> {
                val response = http(httpClient, HttpMethod(effect.value.method), effect.value.url)
                update(
                    CoreMessage.Response(
                        req.uuid.toByteArray().toUByteArray().toList(),
                        Outcome.Http(response)
                    )
                )
            }
            is Effect.ServerSentEvents -> {
                viewModelScope.launch {
                    sse(sseClient, effect.value.url) { event ->
                        update(
                            CoreMessage.Response(
                                req.uuid.toByteArray().toUByteArray().toList(),
                                Outcome.Sse(event)
                            )
                        )
                    }
                }
            }
        }
    }
}

The UI components

Crux can work with any platform-specific UI library. We think it works best with modern declarative UI frameworks such as SwiftUI on iOS, Jetpack Compose on Android, and React/Vue or a Wasm based framework (like Yew) on the web.

These frameworks are all pretty much identical. If you're familiar with one, you can work out the others easily. In the examples on this page, we'll work in an Android shell with Kotlin.

The components are bound to the view model, and they send events to the core.

We've already seen a "hello world" example when we were setting up an Android project. Rather than print that out again here, we'll just look at how we need to enhance it to work with Kotlin coroutines. We'll probably need to do this with any real shell, because the update function that dispatches side effect requests from the core will likely need to be suspend.

This is the View from the Counter example in the Crux repository.

@Composable
fun View(model: Model = viewModel()) {
    val coroutineScope = rememberCoroutineScope()
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier
            .fillMaxSize()
            .padding(10.dp),
    ) {
        Text(text = "Crux Counter Example", fontSize = 30.sp, modifier = Modifier.padding(10.dp))
        Text(text = "Rust Core, Kotlin Shell (Jetpack Compose)", modifier = Modifier.padding(10.dp))
        Text(text = model.view.text, color = if(model.view.confirmed) { Color.Black } else { Color.Gray }, modifier = Modifier.padding(10.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
            Button(
                onClick = { coroutineScope.launch { model.update(CoreMessage.Event(Evt.Decrement())) } },
                colors = ButtonDefaults.buttonColors(containerColor = Color.hsl(44F, 1F, 0.77F))
            ) { Text(text = "Decrement", color = Color.DarkGray) }
            Button(
                onClick = { coroutineScope.launch { model.update(CoreMessage.Event(Evt.Increment())) } },
                colors =
                ButtonDefaults.buttonColors(
                    containerColor = Color.hsl(348F, 0.86F, 0.61F)
                )
            ) { Text(text = "Increment", color = Color.White) }
        }
    }
}

Notice that the first thing we do is create a CoroutineScope that is scoped to the lifetime of the View (i.e. will be destroyed when the View component is unmounted). Then we use this scope to launch asynchronous tasks to call the update method with the specific event. Button(onClick = { coroutineScope.launch { model.update(CoreMessage.Event(Evt.Increment())) } }). We can't call update directly, because it is suspend so we need to be in an asynchronous context to do so.

The capabilities

We want the shell to be as thin as possible, so we need to write as little platform-specific code as we can because this work has to be duplicated for each platform.

In general, the more domain-aligned our capabilities are, the more code we'll write. When our capabilities are generic, and closer to the technical end of the spectrum, we get to write the least amount of shell code to support them. Getting the balance right can be tricky, and the right answer might be different depending on context. Obviously the Http capability is very generic, but a CMS capability, for instance, might well be much more specific.

The shell-side code for the Http capability can be very small. A (very) naive implementation for Android might look like this:

package com.example.counter

import com.example.counter.shared_types.HttpHeader
import com.example.counter.shared_types.HttpRequest
import com.example.counter.shared_types.HttpResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.headers
import io.ktor.client.request.request
import io.ktor.http.HttpMethod
import io.ktor.util.flattenEntries

suspend fun requestHttp(
    client: HttpClient,
    request: HttpRequest,
): HttpResponse {
    val response = client.request(request.url) {
        this.method = HttpMethod(request.method)
        this.headers {
            for (header in request.headers) {
                append(header.name, header.value)
            }
        }
    }
    val bytes: ByteArray = response.body()
    val headers = response.headers.flattenEntries().map { HttpHeader(it.first, it.second) }
    return HttpResponse(response.status.value.toShort(), headers, bytes.toList())
}

The shell-side code to support a capability (or "Port" in "Ports and Adapters"), is effectively just an "Adapter" (in the same terminology) to the native APIs. Note that it's the shell's responsibility to cater for threading and/or async coroutine requirements (so the above Kotlin function is suspend for this reason).

The above function can then be called by the shell when an effect is emitted requesting an HTTP call. It can then post the response back to the core (along with the uuid that is used by the core to tie the response up to its original request):

for (req in requests) when (val effect = req.effect) {
    is Effect.Http -> {
        val response = http(
            httpClient,
            HttpMethod(effect.value.method),
            effect.value.url
        )
        update(
            CoreMessage.Response(
                req.uuid.toByteArray().toUByteArray().toList(),
                Outcome.Http(response)
            )
        )
    }
    // ...
}