From Rust, compiled in Wasm, bound in JavaScript to an Elm port.
TL;DR: here is the example
Motivation
I’ve wanted for some time to try something in Wasm, but didn’t have the motivation as I didn’t really have a need. I’ve been recently building a new UI for Kodi, as on the TV interface is slow and it can be quite hard to “discover” movies once you have many, and having its videos database on another host probably adds delays. I have now mostly reached my goal as I have an UI in Elm that helps me navigate through my movies, and also fix my library by listing wrong identifications or typos in filenames. You can see the full project here.
This is now working correctly, but I now reached a point where the next thing to improve is searching through the movie library. This is done for now by exact search through the movie title, tags or cast, but I would like to have something closer to fuzzy search. I found ElmTextSearch which seems to do what I want, but building the index is too slow to do it on demand. The index can be built once and saved, but I would need to have a backend able to run Elm which I want to avoid. So applying the same ideas to my backend of choice, Rust, it means I would then have to load my index built in Rust in the frontend, so Wasm time. Yay!
Here is an example of the search I currently have:
Some setup
Rust backend server
We’re going to build our backend using actix-web to simply serve static files for now
mkdir my-wasm-elm-app
cd my-wasm-elm-app
mkdir web # for later
cargo new backend
Let’s add a few dependencies to our backend:
actix-web = "2"
actix-rt = "1.1"
actix-files = "0.2"
and the code for serving files:
use actix_files::Files;
use actix_web::{App, HttpServer};
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(Files::new("/", "./web/").index_file("index.html")))
.bind("0.0.0.0:8080")?
.run()
.await
}
Elm frontend application
For the front end, starting from the root project directory:
elm init
mv src frontend
sed -i "" 's/"src"/"frontend"/' elm.json # on linux, sed uses different flag to update in place
This set up an elm project with its sources in the frontend
folder.
For now, we will write a small Elm
program that adds two integers and display the result. This program will display
two input fields, and display the result of the addition when those fields are updated.
In the file frontend/Main.elm
:
module Main exposing (main)
import Browser
import Html exposing (div, input, text)
import Html.Attributes exposing (style, type_, value)
import Html.Events exposing (onInput)
import Task
type alias Model =
-- Simple model that holds two integer and the result
{ a : Int
, b : Int
, res : Int
}
type Msg
= SetA Int -- When the first integer changes
| SetB Int -- When the second integer changes
| AddResult Int -- When the result changes
init : () -> ( Model, Cmd Msg )
init _ =
-- We start with all valuees at 0, and no command
( Model
0
0
0
, Cmd.none
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- When the first integer changes, save it in our model
-- Also send a command with the result of the addition - this will be replaced by Wasm later
SetA a ->
( { model | a = a }
, Task.succeed (AddResult (a + model.b))
|> Task.perform identity
)
-- When the second integer changes, save it in our model
-- Also send a command with the result of the addition - this will be replaced by Wasm later
SetB b ->
( { model | b = b }
, Task.succeed (AddResult (model.a + b))
|> Task.perform identity
)
-- When receiving the result of the addition, save it in our model
AddResult return ->
( { model
| res = return
}
, Cmd.none
)
{-| Display a simple view for addition
It has two input fields that accepts integer and trigger messages when their values change
And it displays the result from the model
-}
view : Model -> Browser.Document Msg
view model =
Browser.Document
"Tests"
[ div [ style "display" "flex" ]
[ input
[ type_ "text"
, value (String.fromInt model.a)
, onInput (\val -> SetA (Maybe.withDefault 0 (String.toInt val)))
]
[]
, text " + "
, input
[ type_ "text"
, value (String.fromInt model.b)
, onInput (\val -> SetB (Maybe.withDefault 0 (String.toInt val)))
]
[]
, div [] [ text "=" ]
, div [] [ text (String.fromInt model.res) ]
]
]
main : Program () Model Msg
main =
Browser.document
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
subscriptions : Model -> Sub Msg
subscriptions _ =
-- no subscription for now
Sub.none
And a very basic HTML file to load the Elm application, in web/index.html
:
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
</head>
<body>
<div id="elm-app-is-loaded-here"></div>
<script src="/elm.js"></script>
<script>
var app = Elm.Main.init({
node: document.getElementById("elm-app-is-loaded-here")
});
</script>
</body>
</html>
Running it
Setting up the project as a Cargo Workspace will make things easier when running commands, this is done with this
simple Cargo.toml
:
[workspace]
members = [
"backend",
]
Finally, let’s build our application and run it:
elm make frontend/Main.elm --output web/elm.js
cargo run
That’s it! You can now do additions at http://localhost:8080.
Building Wasm from Rust
But adding two integers is way too slow in Elm, right? We should do it in Wasm to make sure we can really add integers… Here is the very sophisticated Rust code to do that:
fn add(a: u32, b: u32) -> u32 {
a + b
}
We will need to build this code as a Wasm library. Luckily, this is now very easy thanks to a lot of work on the tooling, namely wasm-pack and wasm-bindgen. Let’s add our shared library to our workspace, and then create and build it:
sed -i "" 's/,/,"shared-lib"/' Cargo.toml # on linux, sed uses different flag to update in place
wasm-pack new shared-lib
wasm-pack build --target web shared-lib --out-dir ../web/shared-lib/
We now have a library created according to the default template,
that, when build as a Wasm file, exposes a greet
method that we’re not going to use. Looking at the code in
shared-lib/src/lib.rs
, we can see
that there are two things needed to have a function available in the Wasm library:
- our function needs to be
pub
- and it need to be annotated with
#[wasm_bindgen]
That’s it! So let’s add our updated function to the library, and rebuild it:
#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
a + b
}
Calling Wasm from Elm
To call the Wasm function, we need to load it in JavaScript, and create ports in Elm to call the code and get a result.
Elm ports
Elm ports allow communication between Elm and JavaScript. A port can go only one way, which mean we will need to declare two to send the call to JavaScript and to retrieve the result.
First, we need to change the module
to a port module
and add our two ports:
port module Main exposing (main)
[...]
port addCall : ( Int, Int ) -> Cmd msg
port addReturn : (Int -> msg) -> Sub msg
Then our update
method need to call the addCall
port instead of doing the addition:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SetA a ->
( { model | a = a }
, addCall ( a, model.b )
)
SetB b ->
( { model | b = b }
, addCall ( model.a, b )
)
[...]
You may have noticed that the addCall
takes a tuple parameter made of the two integers to add. That’s because a
port can have only one parameter, So we have to send a record or a tuplee to pass multiple parameters.
And lastly, we need to listen from results from the addReturn
port with a subscription:
subscriptions : Model -> Sub Msg
subscriptions _ =
addReturn AddResult
Binding Elm ports to Wasm function in JavaScript
If we build and run our application now, it would display but won’t do the addition. For that, we need to bind the
outgoing port to call our Wasm function, and send the result to the ingoing port. In the web/index.html
file, we need
to change the script loading the elm application:
<script type="module">
var app = Elm.Main.init({
node: document.getElementById("elm-app-is-loaded-here")
});
import init, * as shared_lib from './shared-lib/shared_lib.js';
async function setup() {
await init();
}
setup();
app.ports.addCall.subscribe(function (input) {
app.ports.addReturn.send(shared_lib.add(input[0], input[1]));
});
</script>
There are three parts to our changes:
- The script was made of type
module
. This let us import code from our Wasm library easily. - We import and init the Wasm module. This is explained in more details in wasm-bindgen documentation.
- We subscribe to the
addCall
port, call theadd
function for our Wasm library, and send the result to theaddReturn
port.
Trying it out
Let’s build everything and try it out:
wasm-pack build --target web shared-lib --out-dir ../web/shared-lib/
elm make frontend/Main.elm --output web/elm.js
cargo run
Go to http://localhost:8080 and try a few additions. So much better results! Now we’re cooking with fire!
Sidetrack: easily have everything updated during development
We’re going to use cargo-make to rebuild anything that changes. Let’s install it:
cargo install cargo-make
And then add a Makefile.toml
:
[tasks.watch-backend]
command = "cargo"
args = ["run"]
watch = { watch = ["./backend/"] }
workspace = false
[tasks.watch-frontend]
command = "elm"
args = ["make", "frontend/Main.elm", "--output", "web/elm.js"]
watch = { watch = ["./frontend/"] }
workspace = false
[tasks.watch-shared-lib]
command = "wasm-pack"
args = ["build", "--target", "web", "shared-lib", "--out-dir", "../web/shared-lib/"]
watch = { watch = ["./shared-lib/"] }
workspace = false
We can now run in three different sessions each command, and our application will be rebuild with the latest code changes:
cargo make watch-backend
cargo make watch-frontend
cargo make watch-shared-lib
Complex objects passing
Rust to JavaScript
The following commented code exposes the Movie
type and a get_movies
method:
/// This struct will be exposed in Wasm, it needs to be `pub` and annotated with `#[wasm_bindgen]`
#[wasm_bindgen]
pub struct Movie {
/// Only pub fields will be exposed in Wasm
pub rating: f32,
/// `String` fields can't be exposed as is in Wasm, they need `setter` and `getter` declared
/// see below in the `impl Movie`
title: String,
/// This field will not be visible to Wasm
year: u32,
}
/// The implementation of struct needs also to be annotated with `#[wasm_bindgen]` for its methods
/// to be exposed
#[wasm_bindgen]
impl Movie {
/// To expose a field whose type can't be used directly in Wasm, we need to add `setter` and `getter`
/// See https://rustwasm.github.io/docs/wasm-bindgen/reference/attributes/on-js-imports/getter-and-setter.html
#[wasm_bindgen(getter)]
pub fn title(&self) -> String {
self.title.clone()
}
/// This method will also be exposed to Wasm
pub fn about(&self) -> String {
format!("{} - {}", self.title.clone(), self.rating)
}
}
/// This function will not be visible in the Wasm library. However, as its public, we could call it
/// from Rust code importing this crate
/// It can't be exposed as is to Wasm, as `Vec` doesn't work directly in Wasm
pub fn get_movies() -> Vec<Movie> {
vec![
Movie {
title: "A Great Movie".to_string(),
year: 1998,
rating: 3.7,
},
Movie {
title: "Another movie".to_string(),
year: 2020,
rating: 2.0,
},
]
}
/// We need to declare a type alias that will allow us to keep the object type instead of
/// returning and `[object]`
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "Array<Movie>")]
pub type MovieArray;
}
/// We need to have this trait in scope to let us cast into our newly created type alias
/// https://docs.rs/wasm-bindgen/0.2.65/wasm_bindgen/trait.JsCast.html
use wasm_bindgen::JsCast;
/// This is a wrapper around the `get_movies` function that will cast the `Vec<Movie>` into
/// our `MovieArray` type
#[wasm_bindgen]
pub fn get_movies_array() -> MovieArray {
get_movies()
/// For each `Movie`
.into_iter()
/// We cast it into a `JsValue`
.map(JsValue::from)
/// Collect them into an `Array`
.collect::<js_sys::Array>()
/// And then cast it into a `MovieArray`
.unchecked_into::<MovieArray>()
}
This works very well to expose our types in JavaScript or TypeScript, but it’s not the way we are going to use as those types don’t transfer directly to Elm types and it may require a lot of extra work to convert some types.
Rust to Elm
Luckily for us there is a simpler way: just send json objects around! Using serde
and serde-wasm-bindgen, any struct that implement Serialize
can
easily be sent to JavaScript, and then Elm through a port. Using this method will expose every fields that is exposed
by the Serialize
implementation, and will lose all impl
of the struct.
#[derive(Serialize)]
pub struct Movie {
title: String,
year: u32,
rating: f32,
}
pub fn get_movies() -> Vec<Movie> {
vec![
Movie {
title: "A Great Movie".to_string(),
year: 1998,
rating: 3.7,
},
Movie {
title: "Another movie".to_string(),
year: 2020,
rating: 2.0,
},
]
}
#[wasm_bindgen]
pub fn get_movies_js() -> Result<JsValue, JsValue> {
Ok(serde_wasm_bindgen::to_value(&get_movies())?)
}
We then just need to declare a mathing type in Elm, and a port that accepts the data:
type alias Movie =
{ title : String
, year : Int
, rating : Float
}
port getMoviesReturn : (List Movie -> msg) -> Sub msg
Calling Wasm during Elm init
As the Wasm code is loaded asynchronously, it may not be ready when our Elm application is started. In our case
it’s not an issue, as we call the Wasm function only on user input and a user will probably always be slower than
loading the Wasm library, but if we were to add a command to the init
of our Elm application, it would fail.
To solve this, we just have to add an incoming elm port that will be called on the JavaScript side when the Wasm library is ready:
-- new port called when the Wasm library has finished it's init
port wasmReady : (Bool -> msg) -> Sub msg
type alias Model =
{ a : Int
, b : Int
, res : Int
, wasm_ready : Bool -- We keep track of wether the Wasm library is ready
}
type Msg
= SetA Int
| SetB Int
| AddResult Int
| WasmReady Bool -- When the Wasm library is ready
init : () -> ( Model, Cmd Msg )
init _ =
( Model
7 -- We now have starting values different than 0
12
0 -- But we don't know the result of this addition, we will need to call our Wasm library
False -- At start, it's not ready
, Cmd.none
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
WasmReady ready ->
( { model | wasm_ready = ready }
, addCall ( model.a, model.b ) -- Here, we can call an outgoing port to our Wasm function
)
[...]
subscriptions : Model -> Sub Msg.Msg
subscriptions _ =
Sub.batch
[ wasmReady WasmReady -- We subscribe to updates from this port
, addReturn AddResult
]
And we need to update the initialization of the Wasm library to also notify our Elm application once it’s ready:
import init, * as shared_lib from './shared-lib/shared_lib.js';
async function setup() {
await init();
app.ports.wasmReady.send(true); // We call our port to say the library is ready
}
setup();
Complete example
Here is the complete code for this. Congratulations for reading this far!