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, id: u32, output: &[u8]) -> Vec<u8>
where the core expects to be given a Request<Op>
to resolve, the bridge
expects a id
- a unique identifier of the request being resolved.
This makes sense - the Request
s 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
'sprocess_event
- When a request response arrives, its id is forwarded to the
ResolveRegistry
'sresume
method, and theCore
'sprocess
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 id: EffectId,
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 Request
s 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 (effect, resolve) = effect.serialize();
let id = self
.0
.lock()
.expect("Registry Mutex poisoned.")
.insert(resolve);
Request {
id: EffectId(id.try_into().expect("EffectId overflow")),
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 an id, and the effect and the id 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 FnMut(&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.
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());
}
#[wasm_bindgen]
pub fn process_event(data: &[u8]) -> Vec<u8> {
CORE.process_event(data)
}
#[wasm_bindgen]
pub fn handle_response(id: u32, data: &[u8]) -> Vec<u8> {
CORE.handle_response(id, 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(u32 id, [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.