🔨 Getting Started with Rust and WebAssembly

Jan. 22 2022

I have this perverse but pronounced desire to write Rust code that runs in my browser. It's not too hard to get going with wasm-bindgen, but I found this experience a little too magical. In pursuit of better understanding of the underlying technologies, I put together a minimal wasm generation example that depends only on the rustc compiler and the WebAssembly browser API. Although it's hard to imagine that the shittiest Hello World demo written the most impossibly obtuse way (literally using three and a half different programming languages ((including html!) kindof!!) will be useful to anyone else, I am nonetheless leaving this little breadcrumb for others who may be following this trail. If you haven’t already looked at the amazing tutorial for wasm-bindgen, definitely start there. It’s very full-featured and it introduces the long toolchain that is oriented towards shipping production-grade Rust to the web browser.

Ok enough, with the reminiscing of when my grandma taught me this recipe for delicious wasm. Start with a little Rust function that I want to make available to JavaScript

File: bones.rs
#[no_mangle]
pub extern fn add(x: i32, y: i32) -> i32 {
    x + y
}

I'm a little scared of cargo so I want to use rustc directly.

# the rustup command is only necessary once
rustup target add wasm32-unknown-unknown
rustc --target wasm32-unknown-unknown bones.rs

If this works without error, it'll produce a bones.wasm file in the directory where you run it. The last thing I'll need is an html page which runs some JavaScript that loads the WebAssembly module. Something like this:

File: index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Barebones WebAssembly Example</title>
    <script src="bones.js"></script>
</head>
<body>
</body>
</html>
File: bones.js
WebAssembly.instantiateStreaming(
    fetch("bones.wasm"))
    .then(m => {
        const add = m.instance.exports.add;
        console.log("add(2,3)", add(2, 3));
    });

You might think that's enough to get going, but it's not. Because of the rules around Cross-Origin Resource Sharing (CORS), the only way to get wasm into your browser is to run a server. There are endless ways to do that, and if you're good with Rust, you can use the clever warp API to quickly put together a file server. However, as a Rust newcomer, I found warp a little hard to understand when I wandered off the paved path. As little as I like Python, I think it offers the easiest way to get a server going:

python3 -m http.server

Now, watch out!! ☠️ This command serves files with cache headers, and there is nothing more confusing than unexpected caching. You have to use hard refresh (Shift+Reload) to get your latest version each time you run. If that bothers you as much as it bothers me, this little Python script will add no-cache headers to your simple server.

This is enough to get started, but I wanted to share a couple more things I figured out the hard way.

Passing dynamic-length data

You're going to want to be able to log from Rust, since there's no other way to debug wasm code. This means creating a string in Rust that JavaScript can read. The magic of wasm-bindgen can do this for you, but if you want to roll your own, may I suggest the humble, null-terminated, UTF-8 encoded CString? It has the virtues of representing variable-length data with a single value and of being well supported in Rust's standard library.

In order to give Rust the ability to log to the console, we need to provide it with an extern function to call. We pass the consoleLog function in the importObject argument so it can be dynamically linked to the new WebAssembly module. The "env" name is arbitrary, but it is defined as the default.

Since we are planning to receive in JavaScript a string that was allocated in Rust, we need some way to tell Rust when we are done with it, otherwise this interface will leak memory. We'll plan to expose a dealloc_cstring function from Rust to do just that.

File: bones.js (continued)
var wasmModule = null;
const importObject = {
    env: {
        consoleLog: function (ptr) {
            console.log("wasm says:", fromCString(ptr));
        }
    }
}

WebAssembly.instantiateStreaming(
    fetch("bones.wasm"), importObject)
    .then(m => {
        wasmModule = m;
        const add = m.instance.exports.add;
        console.log("add(2,3)", add(2, 3));
    });

function fromCString(ptr) {
    const m = new Uint8Array(
        wasmModule.instance.exports.memory.buffer, ptr);
    let s = "";
    while (m[s.length] != 0) {
        s += String.fromCharCode(m[s.length]);
    }
    wasmModule.instance.exports.dealloc_cstring(ptr);
    return s;
}

In Rust, in addition to declaring the new consoleLog extern binding, we implement a littler helper, along with the dealloc_cstring function.

File: bones.rs (continued)
extern {
    fn consoleLog(p: *mut c_char);
}

#[no_mangle]
pub extern fn add(x: i32, y: i32) -> i32 {
    log(format!("add {} {}", x, y));
    x + y
}

fn log(s: String) {
    let c_string = CString::new(s).unwrap();
    let p: *mut c_char = c_string.into_raw();
    unsafe {
        consoleLog(p);
    }
}

#[no_mangle]
fn dealloc_cstring(p: *mut c_char) {
    let _ = unsafe {
        CString::from_raw(p)
    };
}

Passing a String from JavaScript to Rust works in reverse; we write a function in Rust that allocates a Vec of bytes and then tells the allocator to forget about them. Later, when we receive the string in Rust, we take ownership with CString::from_raw.

Using Rust data structures

It took me forever to figure out how to use a Rust data structure in the wasm context that I could hold onto between invocations. It didn't even occur to me to try and use the static keyword until I had banged my head against all kinds of crazy byte encoding schemes and rebuilding the world from a byte array on every invocation. The only really bad thing about using a mutable static for this purpose is the ugly unsafe blocks that surround access to it. But I forgive Rust for not knowing that this code can never run multi-threaded, and I appreciate that Rust is generally telling me not to do this. I generally wouldn't. There are plenty of industrial-strength solutions to this problem, including the lazy-static crate, but ultimately I convinced myself that it was ok to just use a static singleton.

File: bones.rs (continued)
static mut MAP: Option<HashMap<String, String>> = None;
...

fn ensure_map() {
    unsafe {
        if MAP.is_none() {
            MAP = Some(HashMap::new());
            log("Initialized map".to_string());
        }
    }
}

#[no_mangle]
pub extern fn write(
    c_key: *mut c_char, c_value: *mut c_char) -> bool {
    ensure_map();
    let result: Option<String> = unsafe {
        let key = CString::from_raw(c_key)
            .into_string().unwrap();
        let value = CString::from_raw(c_value)
            .into_string().unwrap();
        log(format!("write {} {}", key, value));
        if let Some(map) = &mut MAP {
            map.insert(key, value)
        } else {
            panic!("Map uninitialized.");
        }
    }
}