Wasm Pack is a flexible framework in the Rust ecosystem to compile code to WebAssembly. It allows web developers to outsource their most demanding data processing tasks to some safe and high-performance Rust code. But it also provides Rust developers with an easy access to Web technologies for creating GUIs for their libraries. Elm is a fairly new language that has revolutionised the way highly interactive web pages are built. This tutorial presents a way to use Elm in conjunction with Rust to produce powerful web applications.
Project Architecture
At its core, Elm is a functional programming language that compiles into pure Javascript (JS). It can provide in a single code file, with no external dependencies, the functionalities provided by UI frameworks like Angular.js or Vue.js. Since JS acts as an interface to WebAssembly (WASM) as well, the easiest way to interface it all together is to use a JS run-time environment for the web server. The Express Node.js web application framework is a very popular solution for this purpose. The full code for this project is available on the Vegapit Github, so please feel free to inspect, install and run. It consists of a web page that passes the values of multiple input fields to a WASM function, and instantly displays the formatted calculation results.
Exposing Rust functions via WebAssembly
To move from a standard Rust library to a WASM library, small changes need to made to the basic Cargo.toml
configuration file. Here is an extract of the one used in the project:
[lib]
crate-type = ["cdylib"]
[profile.release]
lto = true
opt-level = "s"
[dependencies]
js-sys = "0.3"
wasm-bindgen = "0.2"
The crate type needs to be set to cdylib
and in the dependencies section, the wasm-bindgen
and js-sys
need to be imported. These crates respectively allow to declare the functions to be exported, and to create the JS variable types to return. The tags added to profile.release
force the compiler to optimise the size of the final WASM file, which can be critical in some use cases.
The function of interest is defined in the lib.rs
file:
use wasm_bindgen::prelude::*;
use js_sys::Map;
use crate::black76::{Black76, CallPut};
#[wasm_bindgen]
pub fn pricer_black76(callput: &str, s: f64, k: f64, t: f64, v: f64, r: f64) -> Map {
let cp = match callput {
"Call" => CallPut::Call,
"Put" => CallPut::Put,
_ => panic!("Option type unknown")
};
let opt = Black76::new(cp, s, k, t, v, r);
let res = Map::new();
res.set( &JsValue::from_str("price"), &JsValue::from_f64(opt.price()) );
res.set( &JsValue::from_str("delta"), &JsValue::from_f64(opt.delta()) );
res
}
The #[wasm_bindgen]
annotation is applied to every function that needs to be exported to WASM. Since the pricer_black76
function generates multiple outputs, a JS Map
type is used in its implementation. It has to be constructed from Rust types using the JsValue
casting functions. This is pretty much all that we need to create a WASM function library.
To build the module, the following command can be run from the project root:
wasm-pack build --release --target nodejs
This creates a directory pkg
in the root folder, which contains all the files needed for exporting the functions as a Node.js module.
Importing WASM functions in Node.js
The Node.js application lives in the www
folder. The WASM and helper files that were created by the Rust compiler need to be registered with the npm
utility in order to be accessible as a Node.js module. The package.json
file needs to be manually modified for this purpose:
{
...
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1",
"express-handlebars": "^3.1.0",
"rust-elm-gui": "file:../pkg"
}
}
The newly compiled WASM module rust-elm-gui
is declared in the dependencies along with its source folder. Running npm install
on this configuration file will enable to register the WASM related files as modules in Node.js. The WASM module can then be imported in the Node.js main file by writing:
const wasm = require('rust-elm-gui')
It is important to note that such module can only be accessed through server-side code. So, in order to use the function from the client side, the easiest way is to encapsulate it in a REST API function. Here is how it is defined in the code:
app.post('/api/calculate', function(req,res) {
let obj = req.body // BodyParser converting directly to JSON
let calc = wasm.pricer_black76(
obj.cp,
parseFloat(obj.s),
parseFloat(obj.k),
parseFloat(obj.t),
parseFloat(obj.v),
parseFloat(obj.r)
)
obj.price = calc.get("price").toFixed(4)
obj.delta = calc.get("delta").toFixed(4)
res.json(obj)
})
This route manages POST requests collecting a JSON object and returning its modified version. We can now focus on writing some Elm code to generate the user interface.
Elm programming
The Elm file is stored in www/elm
folder. For a programmer used to object orientation, the hard part in learning Elm is to start thinking in purely functional code. The syntax of the language is quite limited however, which greatly helps in understanding it.
In our example, the user is presented with a form to fill and a button to launch the calculation. This means that we can use Elm to generate these particular elements of the HTML document, as opposed to the full page. For this purpose, we assign the main
variable of our Elm program to a Browser.element record which has the following signature:
Browser.element
{ init : flags -> ( model, Cmd msg )
, view : model -> Html msg
, update : msg -> model -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
}
This record requires 4 functions to be defined. Here is a short description of what each do:
-
The
init
function is called when the element is loaded and basically initialises our program. -
The
view
function generates the HTML elements to be displayed. -
The
update
function defines the procedures for responding to user actions. -
The
subscriptions
function defines any signals other than GUI generated that would need to trigger an update e.g. clock tick or Websocket message.
In order to implement these functions, we can see that 2 important types need to be defined in our Elm program. First is the Model
which contains all the variables that need to be tracked throughout the execution of the program. In our case, it reads:
type alias Model =
{
cp: String
, s: String
, k: String
, t: String
, v: String
, r: String
, price: String
, delta: String
}
Second is the Msg
which defines the different signals that could get generated by the user through the GUI.
type Msg
= SetCallPut String
| SetS String
| SetK String
| SetT String
| SetV String
| SetR String
| Calculate
| ReceivedCalculation (Result Http.Error Model)
For example, the variant Calculate
is generated when the button is pressed, or SetK
when the Strike field is modified. Here is what the implementation of the update
variable looks like:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SetCallPut cp ->
( { model | cp = cp, price = "", delta = "" }, Cmd.none )
SetS s ->
( { model | s = s, price = "", delta = "" }, Cmd.none )
SetK k ->
( { model | k = k, price = "", delta = "" }, Cmd.none )
SetT t ->
( { model | t = t, price = "", delta = "" }, Cmd.none )
SetV v ->
( { model | v = v, price = "", delta = "" }, Cmd.none )
SetR r ->
( { model | r = r, price = "", delta = "" }, Cmd.none )
Calculate ->
let
encodedModel = model
|> encoderModel
in
( model, apirequest encodedModel )
ReceivedCalculation res ->
case res of
Ok data -> ( data, Cmd.none )
Err _ -> ( model, Cmd.none )
It is worth noting that as opposed to processing all information when the button is pressed, information is processed as the user interacts with the GUI. This allows a better control over the information displayed as seen in this video example of the final product:
Compiling Elm and HTML integration
To integrate our Elm program into an HTML page, we first need to compile it into JS. Invoking the Elm compiler allows to do this, and in our case, we run from the www
folder the following command:
elm make elm/Index.elm --output=public/js/index.js
Adding the generated code into an HTML file is done through a script in the receiving HTML file:
<div class="container" id="elm"></div>
<script>
let app = Elm.Index.init({
node: document.getElementById('elm')
})
</script>
If you like this post, follow me on Twitter and get notified on the next posts.