Calling Rust through Wasm from Elm

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:

video demonstrating how the search work

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 the add function for our Wasm library, and send the result to the addReturn 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!