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!
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!
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.