FFI bridge

In the previous chapter, we saw how the capability runtime facilitates the orchestration of effect processing by the shell. We looked at the simpler scenario where the shell was built in Rust. Now we'll extend this to the more common scenario where the shell is written in a different language and the core APIs are called over a Foreign Function Interface, passing events, requests and responses back and forth, serialised as bytes.

The FFI bridge

The FFI bridge has two key parts, the serialisation part converting from typed effect requests to serializable types, and the FFI implementation itself, facilitated by UniFFI.

The serialisation part is facilitated by the Bridge. It is a wrapper for the Core with its own definition of Request. Its API is very similar to the Core API - it has an identical set of methods, but their type signatures are different.

For example, here is Core::resolve

    pub fn resolve<Op>(&self, request: &mut Request<Op>, result: Op::Output) -> Vec<Ef>
    where
        Op: Operation,

and here's its counterpart, Bridge::handle_response

    pub fn handle_response(&self, uuid: &[u8], output: &[u8]) -> Vec<u8>

where the core expects to be given a Request<Op> to resolve, the bridge expects a uuid - a unique identifier of the request being resolved.

This makes sense - the Requests include callback closures working with the capability runtime, they can't be easily serialised and sent back and forth across the language boundary. Instead, the bridge "parks" them in a registry, to be picked up later. Like in a theatre cloakroom, the registry returns a unique number under which the request is stored.

The implementation of the serialization/deserialization process is slightly complicated by the fact that Crux allows you to supply your own serializer and deserializer should you need to, so the actual bridge implementation does not work on bytes but on serializers. The Bridge type used in examples and all the documentation is a default implementation, which uses bincode serialization, which is also supported by the type generation subsystem.

We won't go into the detail of working with Serde and the erased_serde crate to make all the serialization happen without leaking deserialization lifetimes out of the bridge. You can read the implementation of BridgeWithSerializer if you're interested in the gory details. For our purposes, the type definition will suffice.

pub struct BridgeWithSerializer<Eff, A>
where
    Eff: Effect,
    A: App,
{
    core: Core<Eff, A>,
    registry: ResolveRegistry,
}

The bridge holds an instance of the Core and a ResolveRegistry to store the effect requests in.

The processing of the update loop is quite similar to the Core update loop:

  • When a serialized event arrives, it is deserialized and passed to the Core's process_event
  • When a request response arrives, the uuid is forwarded to the ResolveRegistry's resume method, and the Core's process method is called to run the capability runtime

You may remember that both these calls return effect requests. The remaining step is to store these in the registry, using the registry's register method, exchanging the core Request for a bridge variant, which looks like this:

#[derive(Debug, Serialize, Deserialize)]
pub struct Request<Eff>
where
    Eff: Serialize,
{
    pub uuid: Vec<u8>,
    pub effect: Eff,
}

Unlike the core request, this does not include any closures and is fully serializable.

ResolveRegistry

It is worth pausing for a second on the resolve registry. There is one tricky problem to solve here - storing the generic Requests in a single store. We get around this by making the register method generic and asking the effect to "serialize" itself.

    pub fn register<Eff>(&self, effect: Eff) -> Request<Eff::Ffi>
    where
        Eff: Effect,
    {
        let uuid = *Uuid::new_v4().as_bytes();
        let (effect, resolve) = effect.serialize();

        self.0
            .lock()
            .expect("Registry Mutex poisoned.")
            .insert(uuid, resolve);

        Request {
            uuid: uuid.to_vec(),
            effect,
        }
    }

this is named based on our intent, not really based on what actually happens. The method comes from an Effect trait:

pub trait Effect: Send + 'static {
    /// Ffi is an enum with variants corresponding to the Effect variants
    /// but instead of carrying a `Request<Op>` they carry the `Op` directly
    type Ffi: Serialize;

    /// Converts the `Effect` into its FFI counterpart and returns it alongside
    /// a deserializing version of the resolve callback for the request that the
    /// original `Effect` was carrying.
    ///
    /// You should not need to call this method directly. It is called by
    /// the [`Bridge`](crate::bridge::Bridge)
    fn serialize(self) -> (Self::Ffi, ResolveSerialized);
}

Like the Effect type which implements this trait, the implementation is macro generated, based on the Capabilities used by your application. We will look at how this works in the Effect type chapter.

The type signature of the method gives us a hint though - it converts the normal Effect into a serializable counterpart, alongside something with a ResolveSerialized type. This is stored in the registry under a randomly generated uuid, and the effect and the uuid are returned as the bridge version of a Request.

The definition of the ResolveSerialized type is a little bit convoluted:

type ResolveOnceSerialized = Box<dyn FnOnce(&mut dyn erased_serde::Deserializer) + Send>;
type ResolveManySerialized =
    Box<dyn Fn(&mut dyn erased_serde::Deserializer) -> Result<(), ()> + Send>;

/// A deserializing version of Resolve
///
/// ResolveSerialized is a separate type because lifetime elision doesn't work
/// through generic type arguments. We can't create a ResolveRegistry of
/// Resolve<&[u8]> without specifying an explicit lifetime.
/// If you see a better way around this, please open a PR.
pub enum ResolveSerialized {
    Never,
    Once(ResolveOnceSerialized),
    Many(ResolveManySerialized),
}

but the gist of it is that it is a mirror of the Resolve type we already know, except it takes a Deserializer. More about this serialization trickery in the next chapter.

FFI interface

The final piece of the puzzle is the FFI interface itself. All it does is expose the bridge API we've seen above.

Note

You will see that this part, alongside the type generation, is a fairly complicated constellation of various existing tools and libraries, which has a number of rough edges. It is likely that we will explore replacing this part of Crux with a tailor made FFI bridge in the future. If/when we do, we will do our best to provide a smooth migration path.

Here's a typical app's shared crate src/lib.rs file:

pub mod app;

use lazy_static::lazy_static;
use wasm_bindgen::prelude::wasm_bindgen;

pub use crux_core::{bridge::Bridge, Core, Request};

pub use app::*;

// TODO hide this plumbing

uniffi::include_scaffolding!("shared");

lazy_static! {
    static ref CORE: Bridge<Effect, App> = Bridge::new(Core::new::<Capabilities>());
}

#[wasm_bindgen]
pub fn process_event(data: &[u8]) -> Vec<u8> {
    CORE.process_event(data)
}

#[wasm_bindgen]
pub fn handle_response(uuid: &[u8], data: &[u8]) -> Vec<u8> {
    CORE.handle_response(uuid, data)
}

#[wasm_bindgen]
pub fn view() -> Vec<u8> {
    CORE.view()
}

Ignore the TODO, we will get to that eventually, I promise. There are two forms of FFI going on - the wasm_bindgen annotations on the three functions, exposing them when built as webassembly, and also the line saying

uniffi::include_scaffolding!("shared");

which refers to the shared.udl file in the same folder

namespace shared {
  bytes process_event([ByRef] bytes msg);
  bytes handle_response([ByRef] bytes uuid, [ByRef] bytes res);
  bytes view();
};

This is UniFFI's interface definition used to generate the scaffolding for the FFI interface - both the externally callable functions in the shared library, and their counterparts in the "foreign" languages (like Swift or Kotlin).

The scaffolding is built in the build.rs script of the crate

fn main() {
    uniffi::generate_scaffolding("./src/shared.udl").unwrap();
}

The foreign language code is built by an additional binary target for the same crate, in src/bin/uniffi-bindgen.rs

fn main() {
    uniffi::uniffi_bindgen_main()
}

this builds a CLI which can be used as part of the build process for clients of the library to generate the code.

The details of this process are well documented in UniFFI's tutorial.