Nursing Code

Using WebAssembly in Ember.js (Rust + wasm-pack)


Brief introduction to WebAssembly

WebAssembly (wasm) is a technology which aims to allow the use of 'high' level languages (like Rust, C and C++) to create programs that are to be run in a 'web' environment, whether that be in a client or on a server.

For a great and more detailed explanation, I highly recommend this talk by Lin Clark

WebAssembly with wasm-pack

So just because you can use C or C++ doesn't mean you should! C and C++ allow you to achieve all kinds of amazing things, but with the tradeoff that they are many ways in which you can cause security issues or serious bugs. Using wasm as the target, many of the security problems are dealt with through things like linear memory, but you still have the problem of program level crashes.

Rust as an alternative, presents compile time guarantees for memory safeness and correctness of your code. For me, I value that level of safety and guidance, but the learning curve can be very steep. If you wanna learn Rust you can get started via The Rust Programming Language

wasm-pack helps you to compile Rust into wasm and also addresses how to package and consume it in your browser or on the server. We'll be using wasm-pack and rust for this project so follow the guides they have to get your tooling installed.

We're only going to look at using it in browser.

Using wasm in Ember.js

We're going to look at how we can use wasm in Ember.js. For the purpose of this work, I'm using Ember 3.16 + Ember CLI 3.16. We're going to heavily leverage ember-auto-import and I've included ember-cli-typescript in the project because with wasm-pack we get type definitions generated by default, so we might as well use them! The final project is on github.

The two libraries we're going to build don't offer a slam dunk use case for wasm, you could achieve the same end results with plain javascript and a lot less work. Appropriateness is not the point though! This is about how to get things working with your Ember apps. I hope that you'll do some amazing things with the new knowledge!

Starting a new project

I initialized a new project with ember new ember-wasm --yarn.

Then I installed helper libraries ember install ember-cli-typescript ember-ref-modifier.

We'll need to be able to do dynamic imports eventually so need to tweak ember-cli-build.js. Add the following to allow dynamic imports.

module.exports = function(defaults) {
  let app = new EmberApp(defaults, {
    babel: {
      plugins: [require.resolve("ember-auto-import/babel-plugin")]
    }
  });

  return app.toTree();
};

We also need to update tsconfig.json, updating the module property

    "module": "esnext",

This will prevent complaints about dynamic imports not be available with es6 as the module type.

Our first Rust to wasm extension

For convenience, I'm going to add our libraries within the same repo, but the principle will be the same if you want it to be separate or imported via an npm package.

Generating UUIDs is something that's not particularly challenging, but it makes for a good first library and we can use the uuid library which has built in support for wasm

At the top level of your ember app run wasm-pack new rust-uuid, this will generate the bones of your Rust library for you. It automatically initializes a new git repo, which is not needed, so go ahead and delete the .git directory from the rust-uuid folder.

Now open the newly created folder in your text editor. If you're using vs-code, the I'd recommend installing the Rust language server which will give you some great auto completion and inline error highlighting.

Adding dependencies

We're going to add the following to our Cargo.toml file. The additional features mean we're going to use v4 of the UUID spec, serde is used for serializing and deserializng data across the JS / wasm boundary.

[dependencies]
wasm-bindgen = "0.2"
uuid = { version = "0.8", features = ["serde", "v4", "wasm-bindgen"] }

now in lib.rs, you'll see the boilerplate code generated for you.

mod utils;

use wasm_bindgen::prelude::*;

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, rust-uuid!");
}

wee_alloc, from their documentation is

a tiny allocator designed for WebAssembly that has a (pre-compression) code-size footprint of only a single kilobyte.

It's essentially there for memory management.

The next block we see is annotated with the attribute #[wasm_bindgen], this is an instruction for the compiler to generate bindings for wasm. The extern block then describes which functions from js that we wish to make available in rust! In this instance, the uber useful alert.

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

Finally we see the function that will be made available in js from rust. Note the pub annotation. This means the function is publicly available, without this, it would not get exported to our wasm module.

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, rust-uuid!");
}

Anyway, we're chuck most of this stuff away now!

Rust UUID generation code

We'll update our imports, making the uuid crate available and importing Uuid.

extern crate uuid;
mod utils;
use uuid::Uuid;

Then we'll define our UUID generation function.

#[wasm_bindgen]
pub fn gen_v4() -> String {
  let v4 = Uuid::new_v4();
  v4.to_hyphenated().to_string()
}

The function takes no inputs and returns a string, pub fn gen_v4() -> String {.

We then build a new instance of a v4 UUID.

Then we hypenate the UUID and convert it to a string. This basically makes it easier for humans to read. v4.to_hyphenated().to_string(). If you're new to rust, you may be wondering why there's no ; on that last line. It's a convenience which makes the line equivalent to return v4.to_hyphenated().to_string();

With that, we're done!

Building the library

There are various targets you can compile to, but we're going to use the default which is bundler. wasm-pack build will default to building for a bundler, so we don't need to pass a target.

You'll see now that there's a pkg folder with the wasm, some type definitions and a js file that we'll use to import and instantiate our library.

Using it in Ember

Add the following to package.json

    "rust-uuid": "./rust-uuid/pkg",

and run yarn install

Now we can start our ember server and work on importing it.

A component

I used ember g component uuid --pod to generate the ts and hbs files in a co-located manner. --pod is probably going away in the future, so your mileage may vary.

This is what the js class looks like.

import Component from "@glimmer/component";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";

interface UuidArgs {}

export default class Uuid extends Component<UuidArgs> {
  @tracked uuid?: string;
  @tracked showButton = false;
  uuidLib: any;
  constructor(owner: any, args: UuidArgs) {
    super(owner, args);
    this.initUUID();
  }
  async initUUID() {
    this.uuidLib = await import("rust-uuid");
    /* We want to ensure users cannot interact with the button
     * before the wasm lib is loaded, so set this flag to show button
     * after module is loaded
     */
    this.showButton = true;
  }

  @action
  genV4() {
    this.uuid = this.uuidLib.gen_v4();
  }
}

Using the constructor we run an async function to import the wasm library and assign it to a class property. Because this process is async we hide the button until the library is loaded and ready.

The template simply displays a button and a generated UUID.

{{#if this.showButton}}
  <button type="button" {{on "click" this.genV4}}>
    Generate UUID v4
  </button>
{{/if}}
<p>
  Generated UUID:
  <span>
    {{this.uuid}}
  </span>
</p>

And now we can run it in our browser!

uuid

Boom, our first usage of wasm in Ember.

One very pleasant side benefit of this approach is you only load the module when it's actually being consumed. If your users rarely visit the portion of your app that consumes this code, they don't have to download the extra payload unless they need to use it!

Mandlebrot use plotters

Plotters is a library for charting, written in Rust and supporting wasm as a target. We're going to use the example from their library of generating a Mandlebrot set.

In the top level of your ember app wasm-pack new rust-plotter. Again, remove the auto created .git directory.

In Cargo.toml we're gonna add

plotters = "^0.2.12"
web-sys = { version = "0.3.4", features = ["HtmlCanvasElement"] }

And in lib.rs

extern crate wee_alloc;
extern crate web_sys;

use wasm_bindgen::prelude::*;
use web_sys::HtmlCanvasElement;
use plotters::prelude::*;
use std::ops::Range;

#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

/// Type alias for the result of a drawing function.
pub type DrawResult<T> = Result<T, Box<dyn std::error::Error>>;

/// Type used on the JS side to convert screen coordinates to chart
/// coordinates.
#[wasm_bindgen]
pub struct Chart {
    convert: Box<dyn Fn((i32, i32)) -> Option<(f64, f64)>>,
}

/// Result of screen to chart coordinates conversion.
#[wasm_bindgen]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[wasm_bindgen]
impl Chart {
    /// Draw Mandelbrot set on the provided canvas element.
    /// Return `Chart` struct suitable for coordinate conversion.
    pub fn mandelbrot(canvas: HtmlCanvasElement) -> Result<Chart, JsValue> {
        let map_coord = draw(canvas).map_err(|err| err.to_string())?;
        Ok(Chart {
            convert: Box::new(map_coord),
        })
    }
}



/// Draw Mandelbrot set
pub fn draw(element: HtmlCanvasElement) -> DrawResult<impl Fn((i32, i32)) -> Option<(f64, f64)>> {
    let backend = CanvasBackend::with_canvas_object(element).unwrap();

    let root = backend.into_drawing_area();
    root.fill(&WHITE)?;

    let mut chart = ChartBuilder::on(&root)
        .margin(20)
        .x_label_area_size(10)
        .y_label_area_size(10)
        .build_ranged(-2.1..0.6, -1.2..1.2)?;

    chart
        .configure_mesh()
        .disable_x_mesh()
        .disable_y_mesh()
        .draw()?;

    let plotting_area = chart.plotting_area();

    let range = plotting_area.get_pixel_range();
    let (pw, ph) = (range.0.end - range.0.start, range.1.end - range.1.start);
    let (xr, yr) = (chart.x_range(), chart.y_range());

    for (x, y, c) in mandelbrot_set(xr, yr, (pw as usize, ph as usize), 100) {
        if c != 100 {
            plotting_area.draw_pixel((x, y), &HSLColor(c as f64 / 100.0, 1.0, 0.5))?;
        } else {
            plotting_area.draw_pixel((x, y), &BLACK)?;
        }
    }

    root.present()?;
    return Ok(Box::new(chart.into_coord_trans()));
}

fn mandelbrot_set(
    real: Range<f64>,
    complex: Range<f64>,
    samples: (usize, usize),
    max_iter: usize,
) -> impl Iterator<Item = (f64, f64, usize)> {
    let step = (
        (real.end - real.start) / samples.0 as f64,
        (complex.end - complex.start) / samples.1 as f64,
    );
    return (0..(samples.0 * samples.1)).map(move |k| {
        let c = (
            real.start + step.0 * (k % samples.0) as f64,
            complex.start + step.1 * (k / samples.0) as f64,
        );
        let mut z = (0.0, 0.0);
        let mut cnt = 0;
        while cnt < max_iter && z.0 * z.0 + z.1 * z.1 <= 1e10 {
            z = (z.0 * z.0 - z.1 * z.1 + c.0, 2.0 * z.0 * z.1 + c.1);
            cnt += 1;
        }
        return (c.0, c.1, cnt);
    });
}

Much of the code here is 'uninteresting' in the context of our wasm learning.

This is the main interesting part

#[wasm_bindgen]
impl Chart {
    /// Draw Mandelbrot set on the provided canvas element.
    /// Return `Chart` struct suitable for coordinate conversion.
    pub fn mandelbrot(canvas: HtmlCanvasElement) -> Result<Chart, JsValue> {
        let map_coord = draw(canvas).map_err(|err| err.to_string())?;
        Ok(Chart {
            convert: Box::new(map_coord),
        })
    }
}

We'll be exporting an object called Chart, which will have the method mandelbrot.

pub fn mandelbrot(canvas: HtmlCanvasElement) -> Result<Chart, JsValue> {, as you see here, it expects to receive as it's only argument a <canvas> element from JS. Because the operation could fail, the function specifies a Result return type. A Result type will be either Ok(Chart) or Err(some JsValue).

Being forced to acknowledge and explicitly handle errors is a great feature when programming rust with Result types. There is also the ? operator that basically says, if this preceding operation fails, return the error type, otherwise assign the result of the function call to the map_coord variable.

Using it in Ember

Build the library using wasm-pack build.

Add the following to package.json

    "rust-plotter": "./rust-plotter/pkg",

and run yarn install

Then we'll create a component with ember g component mandelbrot --pod.

import Component from "@glimmer/component";

interface MandelbrotArgs {}

export default class Mandelbrot extends Component<MandelbrotArgs> {
  canvas?: HTMLCanvasElement;
  constructor(owner: any, args: MandelbrotArgs) {
    super(owner, args);
    this.initMandelbrot();
  }

  async initMandelbrot() {
    const { Chart } = await import("rust-plotter");
    Chart.mandelbrot(this.canvas);
  }
}

Here we're declaring that we'll eventually get a HTMLCanvasElement. In our constructor we again call out to an async initialization function.

Because we exported a struct called Chart from rust, we can destructure our import. Once we've done that we can run the mandelbrot function, passing it the canvas element.

We get the canvas element through the use of the ref modifier.

<canvas class="chart" height="400" width="600" {{ref this "canvas"}}></canvas>

And with that, we can now render a Mandelbrot set!

mandelbrot

Update

After feedback from @chriskrycho and @lifeart on the Ember discord server, here's an even simpler invocation.

import Component from "@glimmer/component";

export default class Mandelbrot extends Component {
  async initMandelbrot(canvas: HTMLCanvasElement) {
    const { Chart } = await import("rust-plotter");
    Chart.mandelbrot(canvas);
  }
}

and the template

<canvas class="chart" height="400" width="600" {{ref this.initMandelbrot}}></canvas>

I like this version a lot!

@chriskrycho also suggested that it could be a template only component with the use of a custom modifier, but I'll leave that as an exercise for the reader!

Fin

Hope you enjoyed this post and find some really good use cases for using wasm with your Ember projects.