Here's the breakdown, since there's quite a bit of magic:
Rust --target wasm32-unknown-unknown
will generate a .wasm file where:
-
every extern { fn foo(); }
will generate a import for a function foo
, and every import is in the env
import module. List these with WebAssembly.Module.imports(module)
call.
- You can override this name with
#[link_name = "bar"]
-
every #[no_mangle] fn foo()
(anywhere!) will generate an export for a function foo
in the module. List these with WebAssembly.Module.exports(module)
.
- You can override the export name with
#[export_name = "bar"]
, unsurprisingly, instead of using #[no_mangle]
-
Near as I can tell, you can't export or import WASM globals, other than the built in __data_end
and __heap_base
pointers?
-
WASM only supports 32 and 64 bit number types (u32, f64, etc), so every structure gets flattened to separate arguments, so as &str
is a slice, which is internally represented as a pointer and length pair, it turns into a ptr, len pair.
- But this layout is not guaranteed, so you get a "&str is not FFI-safe" warning - instead in real code, manually pass in or out the
ptr: *const u8, len: usize
pair, get them with as_ptr()
and .len()
, and get a &str
back with std::str::from_utf8(std::slice::from_raw_parts(ptr, len))
(perhaps using the _unchecked
variant if you can prove it won't ever not be UTF-8)
-
It will only actually generate a .wasm
file if the crate type is bin
or cdylib
, so either using src/main.rs
with #![no_main]
if you're dirty, or src/lib.rs
with [lib] crate-type = ["cdylib"]
in Cargo.toml if you are doing the right thing works. Weirdly the latter will replace -
with _
in the file name.
So, in summary, better Rust code would look like:
// declare a wrapper `console::log` for the WASM import `console_log`.
mod console {
// Import a javascript function to log values
// extern type doesn't really matter here, AFAICT
extern "system" {
#[link_name = "console_log"]
fn _log(ptr: *const u8, len: usize);
}
// wrap the import with a nicer interface.
pub fn log(str: &str) {
// need to use unsafe for any extern fn in rust, but there's nothing unsafe
// about the call itself, just what the external code might do.
unsafe { _log(str.as_ptr(), str.len()) }
}
}
// export the allocator so javascript can allocate and free memory in rust
mod alloc {
// note that *any* pub fn
#[no_mangle]
unsafe fn alloc(size: usize, align: usize) -> *mut u8 {
match std::alloc::Layout::from_size_align(size, align) {
Ok(layout) => std::alloc::alloc(layout),
Err(_) => std::ptr::null_mut(),
}
}
#[no_mangle]
unsafe fn dealloc(ptr: *mut u8, size: usize, align: usize) {
if let Ok(layout) = std::alloc::Layout::from_size_align(size, align) {
std::alloc::dealloc(ptr, layout);
}
}
}
// export a function for javascript to call, using FFI-safe types
#[export_name = "say_hello"]
unsafe fn _say_hello(name_ptr: *const u8, name_len: usize) {
// This assumes that JS passed us a valid pointer and length to some UTF-8.
// This is not safe in general, so only do this if you also wrap up the JS side!
let name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(name_ptr, name_len));
// Call the "real" Rust function
say_hello(name)
}
// Do something in Rust
fn say_hello(name: &str) {
console::log(&format!("Hello, {name}!"));
}
And the node.js code with some commentary:
// import built-in Node.js modules
import assert from "node:assert";
import fs from "node:fs";
// read the bytes of the WASM file we built in Rust
const bytes = fs.readFileSync("./target/wasm32-unknown-unknown/debug/little_wasm.wasm");
// explicitly parse and validate the WASM module...
const module = new WebAssembly.Module(bytes);
// then create an instance from that module, and an import map:
const instance = new WebAssembly.Instance(module, {
env: {
// this is the implementation for the extern console::_log in Rust.
// if you don't provide it, creating the instance will fail with a LinkError
console_log(ptr, len) {
// grab the ArrayBuffer representing the WASM memory space.
// in my experience, WASM doesn't like it if you keep this around,
// though I don't know why, exactly.
const buffer = instance.exports.memory.buffer;
// ptr is a simple JS number which is an index into that memory space,
// so new Uint8Array(buffer, offset, length) nicely creates a byte array
// representing the bytes in the rust memory space backing &str that
// we passed to console::log in say_hello:
const bytes = new Uint8Array(buffer, ptr, len);
// Note that these are dynamically allocated in Rust
// due to the format!(), and will be freed (in Rust) when we return, so
// be sure to copy them out if you want to keep it around, for example:
//
// copy = new Uint8Array(bytes);
//
// which is shorthand for:
//
// copyBuffer = new ArrayBuffer(bytes.length); // allocate new buffer
// copy = new Uint8Array(copyBuffer); // wrap buffer for byte access
// copy.set(bytes); // copy each entry from bytes into copy
// new TextDecoder().decode(bytes) will get the string for UTF-8 bytes.
console.log(new TextDecoder().decode(bytes));
},
},
});
// Get the UTF-8 bytes for a string
const nameBytes = new TextEncoder().encode("Simon");
// be sure to get the byte length, not the string length, which is different
// for non-ASCII text.
const nameLen = nameBytes.length;
// call the rust allocator to get some temporary rust memory you are
// allowed to write to
const namePtr = instance.exports.alloc(nameLen, 1);
// check the allocation worked
assert(namePtr);
// create a Uint8Array representing the entire WASM memory space,
// then write the nameBytes starting at the address namePtr we just allocated
new Uint8Array(instance.exports.memory.buffer).set(nameBytes, namePtr);
// call the _say_hello export with a pointer, length pair
instance.exports.say_hello(namePtr, nameLen);
// free the Rust memory for nameBytes we allocated.
instance.exports.dealloc(namePtr, nameLen, 1);
Note that this is basically what wasm_bindgen is doing for you, along with all the object lifetime stuff (where it basically uses a JS array of objects and indexes in rust into it).