Exploring WASM support for Rid to build Flutter/Dart apps with Rust app logic running in the browser
When I demonstrated the initial Rid version I immediately got requests to add WASM support so apps built with it can run in the browser as well. At this point I figured it would be as simple as adding another FFI shim, but this turned out to not be entirely true.
As always I drove my explorations via an example, a Todo app using bloc/cubit with all logic implemented in Rust. Feel free to click below, it's not a screenshot, but a fully functioning app minus the feature requiring threading (more about that below).
Flutter app is loading ...
Yes it's an interactive embedded Flutter app built with Rid, just click around
You can also try the standalone version
Standing On the Shoulders ...
Lots of projects stand on the shoulders of giants. This is true for Rid as well as it wouldn't be possible or at the very least a lot harder without libraries like syn and ffigen just to name a few.
Compiling Rust code to WASM was the easy part. The giants are pretty much built into the Rust compiler and tools to optimize the results for size and/or speed are available as explained in this chapter of the Rust and WebAssembly book.
However when it comes to loading and interacting with WASM from Dart there aren't that many shoulders to stand on yet. There is the dart-lang/wasm library, but if you read the fine print closely
Built on top of the Wasmer runtime
you'll realize that it loads that runtime as a binary. Obviously that doesn't work when running in the browser and being able to run in the browser is why we want to add WASM support to Rid in the first place.
After spending too much time catching on to that 😜 I found wasm_interop. This module uses browser APIs in order to load and interact with WASM modules. Just what I needed.
Well almost, the gluecode that ffigen generates won't work for WASM because it depends on libraries that aren't available when building Dart for the Web. For a moment I thought web_ffi would help me here until I read
Currently, only WebAssembly compiled with emscripten is usable
At that point I realized that Eric Seidel was right on the 💰 when he responded to my tweet in which I presented a small working example of a Dart + WASM web app.
Agreed! CC @MiSvTh
— kevmoo (@kevmoo) July 1, 2021
The Missing Pieces
I decided to build the missing pieces myself, one of which would be a library like ffigen except that it generates code to interface with wasm_interop. I called it wasmjsgen.
One feature of this library is that the code that consumes those ffi calls
does not have change when interfacing with WASM vs. Native bindings. Therefore all function
signatures have to match and types need to at least have the same name. Conditional
imports can
then be used to import the correct ffi bindings. In short the wasmjsgen API matches the
ffigen API exactly down to mimicking Pointer
types
so that the wrapper code generated by Rid can be used across platforms.
If you're wondering why this library doesn't wrap ffigen to change behavior via composition or inheritance please have a look at my failed attempts of extending ffigen as well as wrapping it. The fact that the data types storing parse results include the code generation methods directly made it impossible to get in the middle and change it to generate WASM bindings instead. At some point I gave up on the correct way of doing this and basically copy/pasted the library to modify code generation directly.
Once I had wasmjsgen in place I was able to create the glue code for the WASM version of the todo cubit example and only had to modify the Rid wrapper code slightly to have the example app running in the browser with practically no change to existing app code.
The config of wasmjsgen is identicical to ffigen's except for the allocate function that is necessary to send Strings to Rust and optionally dealloate and reallocate functions.
These allocation functions needs to be implemented in Rust similarly to the implementation included with the example
Challenges
Some surprises awaited though when I tried to use all features of the application.
No Reply Channel with WASM
First off, the allo_isolate crate which Rid uses to relay messages from Rust to Dart does not work with WASM. I worked around this by using a polling implementation instead, i.e. the Dart end frequently queries the Rust end for any replies to messages it should to process.
For now I created a custom reply channel implementation which uses a timer on the Dart side.
On the Rust side I used a simple data structure to queue up and expose replies. It's using a
Vec
for simplicity since order doesn't matter in this simple case. The code would be improved
and generated by Rid of course. For now I added it to the
example.
No Threads out of the Box
When testing the Todo expiry feature the app crashed when attempting to create a thread on the
Rust end since this is not supported when running WASM. Fortunately in this case the error
message "failed to spawn thread"
made it very easy to confirm the origin of the problem.
// std/src/thread/mod.rs
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
Builder::new().spawn(f).expect("failed to spawn thread")
}
After reading this very informative post about the state of multi-threading in rust-compiled WASM, I realized that while possible, making this work would require a considerable amount effort at this point. However there are lots of other features I'd like to add to Rid. I hope that when I revisit this task the wasm-mt or similar libraries have paved the way to make this easier.
For now I removed todo expiry from the WASM version of the sample app.
Issues sending Structs
The Todo example app allows users to select a filter to show All, Completed or Pending Todos. After the filter is updated the Dart end invokes the below Rust method in order to get the Todos that match it.
#[rid::export]
#[rid::structs(Todo)]
impl Store {
#[rid::export]
fn filtered_todos(&self) -> Vec<&Todo> {
let mut vec: Vec<&Todo> = match self.filter {
Filter::Completed => self.todos.iter().filter(|x| x.completed).collect(),
Filter::Pending => self.todos.iter().filter(|x| !x.completed).collect(),
Filter::All => self.todos.iter().collect(),
};
vec.sort();
vec
}
}
The returned Vec
is actually wrapped in a struct that includes length
and capacity
information as well as a pointer to the first item. It also includes a method to free
it when
the Dart end processed it.
It turns out returning such a struct when using WASM is not trivial if even possible at all. At the very least, I could not get it to work at all and the error messages aren't very helpful. It actually made me wonder if WASM provides any other error message than the below. 😎
RuntimeError: unreachable
I spent way too much time trying to make this work and tried returning other struct types as well without any success, but at least I learned a bunch about compiling Dart apps with dart2js and how debugging a Dart Web app compares to debugging a Flutter app running in Chrome.
In the end I decided to work around this issue by wrapping the above method in one that returns a JSON string containing the filtered Todos that it then deserializes on the Dart end.
To that end I used serde to make the Todo struct serializable and serde_json to output JSON.
#[rid::export]
impl Store {
#[rid::export]
fn filtered_todos_string(&self) -> String {
serde_json::to_string(&self.filtered_todos()).expect("Unable JSON stringify filtred todos")
}
}
In Closing
Exploring WASM support surfaced a few things:
- Using Rid to build Flutter apps running in the browser will definitely be possible eventually
- The ecosystem to do so is missing a lot of pieces, especially on the Dart side
- Many of the limitations I encountered will be overcome when the ecosystem around WASM evolves
- All other limitations can be worked around at possible performance cost, i.e. serialize/deserialize Data Structures
Therefore it is a bit too early to properly add this feature to Rid and more efficient to focus on other features that need to be added. In the meantime the ecosystem around WASM support will evolve and I will try to help where I can.
👉 If you liked this post and want to help me evolve Rid, please consider becoming a sponsor