WASM for the Browser
The oldest use case for WASM is including in the browser. Emscripten (C++) was the first system to popularize it. Browser WASM can be written as regular Rust, with a few exceptions---notably threads don't work in current browser setups.
I recommend keeping this reference handy: https://rustwasm.github.io/wasm-bindgen/introduction.html
To work with Rust in the browser, you need two components:
Installing Required Components
- WASM compiler toolchain. You can add it with
rustup target add wasm32-unknown-unknown
. - WASM Bindgen, which generates JavaScript/Typescript bindings connecting Rust to the browser. You can install it with
cargo install wasm-bindgen-cli
.
Your project will also need to include wasm-bindgen
in its dependencies. Note that when you upgrade wasm-bindgen
, you need to also update wasm-bindgen-cli
to the matching version.
Testbed Server
Browsers don't like running WASM from
localhost
, it violates the sandbox rules. So you typically need a webserver from which to test your code. I often keep a small server likenginx
around while I'm developing WASM for the browser for quick turnaround.
In this case, let's build ourselves a mini Axum server that serves a directory. You can serve a folder named web
with this short program:
use axum::Router; use std::net::SocketAddr; use tower_http::services::ServeDir; #[tokio::main] async fn main() { let app = Router::new() .fallback_service(ServeDir::new("web")); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); }
And the Cargo.toml
:
[package]
name = "wasm_web_server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.6.18"
tokio = { version = "1.28.2", features = ["full"] }
tower-http = { version = "0.4.0", features = ["fs", "trace", "cors"] }
Using the fallback_service
and ServeDir
lets you serve a file by name if it didn't match any routes. Since we didn't define any roots, it'll serve any file with a matching name from the web
directory.
Let's add a file, web/index.html
:
<html>
<head>
<title>Hello World</title>
</head>
<body>
<p>Hello, World!</p>
</body>
</html>
Run the project with cargo run
, and visit http://localhost:3001 to verify that the server works.
Creating a Rust Function to Call From JavaScript
Let's create a project with cargo new --lib wasm_lib
.
Our Cargo.toml
file will need a wasm-bindgen
dependency:
[package]
name = "wasm_lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2.86"
Note that we have to build a cdylib
- a C compatible dynamic library. Otherwise, we'll get a statically linkable rlib
(Rust library format) and no .wasm
file will be created.
In our lib.rs
, we'll start with the following:
#![allow(unused)] fn main() { use wasm_bindgen::prelude::*; #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = console)] fn log(s: &str); } #[wasm_bindgen] pub fn hello_js() { log("Hello from Rust!"); } }
There's a few parts here:
- We're importing the prelude of
wasm_bindgen
- useful imports. - We have an
extern
block decorated withwasm_bindgen
- the bindings generator will use this to map calls. - We defined a
log
function, and indicated that its in the JavaScript namespaceconsole
. This adds a Rust function namedlog
, which is equivalent to callingconsole.log
in JavaScript. - Then we build a regular Rust function that calls it. Decorating the function with
[wasm_bindgen]
instructs thewasm_bindgen
system to generate a matching call within the generated web assembly wrapper to allow JavaScript to call it.
Now we have to build it. We can instruct Cargo to use the correct output with the target
flag:
cargo build --release --target wasm32-unknown-unknown
In your target/wasm32-unknown-unknown/release
directory, you will see libwasm_lib.*
. This provides raw WASM, but doesn't provide any browser help (you can't really run it yet). You have to use wasm-bindgen
to read the project, and create the JavaScript for the browser. By default, it will also generate TypeScript and use modern JS modules. We're going to keep it simple today.
mkdir -p out
wasm-bindgen target/wasm32-unknown-unknown/release/wasm_lib.wasm --out-dir out --no-modules --no-typescript
In your out
folder, you will see two files: wasm_lib_bg.wasm
(a processed .wasm
file) and wasm_lib.js
(a JavaScript binding library to use it).
Now in our webserver, we'll make a quick placeholder to use it:
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
</head>
<body>
<script src="./wasm_lib.js"></script>
<script>
window.addEventListener("load", async () => {
await wasm_bindgen("./wasm_lib_bg.wasm");
wasm_bindgen.hello_js();
});
</script>
</body>
</html>
Put this file along with the two generated files into the web
directory. Open http://localhost:3001/hello_wasm.html and check the web console - you will see the message Hello from Rust!
. That worked, you've called a Rust function from JavaScript --- which in turn has called a JavaScript function. That gives you the basis of calling functions back and forth.
Passing Types
Now let's add a simple math function:
#![allow(unused)] fn main() { #[wasm_bindgen] pub fn add(a: i32, b: i32) -> i32 { a + b } }
Modify index.html
to also call:
console.log(wasm_bindgen.add(5, 10));
Go through the same build setup:
cargo build --release --target wasm32-unknown-unknown
mkdir -p out
wasm-bindgen target/wasm32-unknown-unknown/release/wasm_lib.wasm --out-dir out --no-modules --no-typescript
cp out/* ../wasm_web_server/web/
And sure enough, your math function outputs 15
. So primitive types work fine. How about strings?
Add another function:
#![allow(unused)] fn main() { #[wasm_bindgen] pub fn greet(s: String) -> String { format!("Hello {s}") } }
And add a line of JavaScript:
console.log(wasm_bindgen.greet("Herbert"));
How about vectors?
#![allow(unused)] fn main() { #[wasm_bindgen] pub fn sum(arr: &[i32]) -> i32 { arr.iter().sum() } }
console.log(wasm_bindgen.sum([1, 2, 3, 4]));
Custom Types
In other words, normal Rust works very smoothly. What if you want to define a type? That starts to get more complicated. The JS browser environment only has very limited types: classes, 64-bit signed integers and 64-bit floats (there are also some typed memory buffers). Rust has lots of types. So when you pass data between the two contexts, you find yourself needing some conversion code.
Classes
If you'd like to represent struct + implementations as JavaScript classes, wasm-bindgen
can help you. For example:
#![allow(unused)] fn main() { #[wasm_bindgen] pub struct Person { pub name: String, pub age: u8, } #[wasm_bindgen] impl Person { #[wasm_bindgen(constructor)] pub fn new(name: String, age: u8) -> Self { Self { name, age } } pub fn greet(&self) -> String { format!("Hello, my name is {} and I am {} years old", self.name, self.age) } pub fn set_age(&mut self, age: u8) { self.age = age; } pub fn get_age(&self) -> u8 { self.age } } }
Note that you're marking wasm_bindgen
on both the structure and its implementation, and have to tag the constructor. Now let's take a look at this from the JavaScript side:
let person = new wasm_bindgen.Person("Herbert", 48);
console.log(person.greet());
console.log(person.age);
console.log(person.get_age());
Creating the Person
works, and calling greet
and get_age
work. But referencing person.age
does not work! You don't get an automatic bridge to fields, because of type conversion requirements. Getters will do the work for you---but you are back to writing lots of getters and setters.
Arbitrary Data with Serde
You can work around this by using Serde, and Serde JSON to build a bridge between the systems. Add serde
and serde_json
to your project:
cargo add serde -F derive
cargo add serde_json
And now we can serialize our person and return JSON:
#![allow(unused)] fn main() { use serde::Serialize; #[derive(Serialize)] #[wasm_bindgen] pub struct Person { name: String, age: u8, } #[wasm_bindgen] impl Person { #[wasm_bindgen(constructor)] pub fn new(name: String, age: u8) -> Self { Self { name, age } } pub fn greet(&self) -> String { format!("Hello, my name is {} and I am {} years old", self.name, self.age) } pub fn set_age(&mut self, age: u8) { self.age = age; } pub fn get_age(&self) -> u8 { self.age } } #[wasm_bindgen] pub fn serialize_person(person: &Person) -> String { serde_json::to_string(person).unwrap() } }
Now in your JavaScript you can use JSON to fetch the person without having to worry about getters/setters:
let person_json = wasm_bindgen.serialize_person(person);
let person_deserialized = JSON.parse(person_json);
console.log(person_deserialized);
You can use this to handle passing complicated types to and from JavaScript via the built-in JSON system. serde_json
is really fast, but there is a performance penalty to transitioning data between the WASM sandbox and the browser.
Communicating with Servers via REST
You can handle the API side of things directly from the WASM part of the browser.
Let's add a little more functionality to our webserver. In our wasm_web_server
project, let's add Serde with cargo add serde -f derive
. Then we'll add a simple JSON API.
use axum::{Router, routing::get}; use std::net::SocketAddr; use tower_http::services::ServeDir; use serde::Serialize; #[derive(Serialize)] struct HelloJson { message: String, } async fn say_hello_json() -> axum::Json<HelloJson> { axum::Json(HelloJson { message: "Hello, World!".to_string(), }) } #[tokio::main] async fn main() { let app = Router::new() .route("/json", get(say_hello_json)) .fallback_service(ServeDir::new("web")); let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); }
Our WASM library needs some JavaScript imports to use the JS fetch
API. You also need to add wasm-bindgen-futures
. In Cargo.toml
add:
[dependencies]
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
wasm-bindgen = "0.2.89"
wasm-bindgen-futures = "0.4.39"
[dependencies.web-sys]
version = "0.3.4"
features = [
'Headers',
'Request',
'RequestInit',
'RequestMode',
'Response',
'Window',
]
In our WASM library, we can now add the following to call the JSON API:
#![allow(unused)] fn main() { use wasm_bindgen_futures::JsFuture; use web_sys::{Request, RequestInit, RequestMode, Response}; #[wasm_bindgen] pub async fn fetch_hello_json() -> Result<JsValue, JsValue> { let mut opts = RequestInit::new(); opts.method("GET"); opts.mode(RequestMode::Cors); let url = format!("/json", repo); let request = Request::new_with_str_and_init(&url, &opts)?; request .headers() .set("Accept", "application/vnd.github.v3+json")?; let window = web_sys::window().unwrap(); let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; // `resp_value` is a `Response` object. assert!(resp_value.is_instance_of::<Response>()); let resp: Response = resp_value.dyn_into().unwrap(); // Convert this other `Promise` into a rust `Future`. let json = JsFuture::from(resp.json()?).await?; // Send the JSON response back to JS. Ok(json) } }