Rust and WebAssembly
WebAssembly (Wasm) is, in the words of the Wasmtime docs, “a binary instruction format that is designed to be a portable compilation target for programming languages”. A host (a browser, Node, or a runtime such as Wasmtime) loads a .wasm file and calls into it. Rust gets there by choosing a target. This page builds one small library, loads it from Node 24 with only WebAssembly.instantiate, then adds wasm-bindgen, wasm-pack and WASI.
Everything ran in the image behind this site (Linux ARM64, Rust 1.99.1). The image ships only the native Rust target and none of the Wasm tools, so sessions start with rustup target add and some download a pinned tool; those lines say needs network. The image has no browser, so nothing here ran in one.
RememberA WebAssembly function takes and returns numbers. Text and arrays travel as bytes in the module’s memory plus an address, and either you write that glue or a tool such as wasm-bindgen writes it.
wasm-lab/ ├── adder/ exports and one import, plain extern "C" │ ├── src/ │ │ └── lib.rs │ └── Cargo.toml ├── assemblyscript/ │ ├── add.ts │ └── package.json ├── bindgen/ the same ideas with wasm-bindgen │ ├── src/ │ │ └── lib.rs │ └── Cargo.toml ├── go/ │ ├── go.mod │ └── main.go ├── goexport/ │ ├── go.mod │ └── main.go ├── gotcha/ the gotcha │ ├── src/ │ │ └── main.rs │ └── Cargo.toml ├── tiny/ just add, for the size table │ ├── src/ │ │ └── lib.rs │ └── Cargo.toml ├── wasi/ a program with main │ ├── src/ │ │ └── main.rs │ └── Cargo.toml ├── callAdd.mjs ├── Cargo.lock ├── Cargo.toml ├── run.mjs the Node host for adder ├── setup.sh downloads one pinned tool ├── use.js └── wasi.mjs
Three targets
Section titled “Three targets”wasm32-unknown-unknown assumes nothing about the host; the rustc book says it “does not import any functions from the host for the standard library”. wasm32-wasip1 assumes the WASI preview 1 system calls (files, arguments, environment, clocks). wasm32-wasip2 is built on the component model; it appears once, in the WASI section. The cfg lines show 32-bit pointers, so usize is 4 bytes.
$ rustup target add wasm32-unknown-unknown wasm32-wasip1 # needs network $ rustup target list --installed # at this point: the wasip2 target is added in a later session aarch64-unknown-linux-gnu wasm32-unknown-unknown wasm32-wasip1 $ rustc --print cfg --target wasm32-unknown-unknown | grep -E 'target_(arch|os|family|pointer_width)' target_arch="wasm32" target_family="wasm" target_os="unknown" target_pointer_width="32"
Plain extern "C", no tools
Section titled “Plain extern "C", no tools”A crate with crate-type = ["cdylib"] has no main. Each #[unsafe(no_mangle)] pub extern "C" fn becomes an export under its own name; edition 2024 writes no_mangle inside unsafe(...) (unsafe, and C and C++ for the same attribute on native code). A function declared in an unsafe extern "C" block is an import: something the host must supply.
[package]name = "adder"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib"]// A WebAssembly function can take and return only numbers (i32, i64, f32, f64).#[unsafe(no_mangle)]pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b}
#[unsafe(no_mangle)]pub extern "C" fn fib(n: u32) -> u64 { let (mut a, mut b) = (0u64, 1u64); for _ in 0..n { (a, b) = (b, a + b); } a}
// The host (JavaScript) supplies this function when it creates the module.#[link(wasm_import_module = "env")]unsafe extern "C" { fn host_log(ptr: *const u8, len: usize);}
fn log(msg: &str) { unsafe { host_log(msg.as_ptr(), msg.len()) }}
// Text and arrays cross as a pointer and a length into the module's linear memory.// The host asks the module for room, writes the bytes there, and calls with the address.#[unsafe(no_mangle)]pub extern "C" fn alloc(len: usize) -> *mut u8 { Box::into_raw(vec![0u8; len].into_boxed_slice()) as *mut u8}
/// # Safety/// `ptr` and `len` must be exactly what `alloc` (or `shout`) handed out, once.#[unsafe(no_mangle)]pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) { drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });}
/// # Safety/// `ptr..ptr+len` must be bytes the host wrote into memory from `alloc`; `out_len`/// must point at 4 writable bytes.#[unsafe(no_mangle)]pub unsafe extern "C" fn shout(ptr: *const u8, len: usize, out_len: *mut usize) -> *mut u8 { let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; let Ok(text) = std::str::from_utf8(bytes) else { log("shout: not UTF-8"); unsafe { *out_len = 0 }; return std::ptr::null_mut(); }; log(&format!("shout got {len} bytes")); let result = format!("{}!", text.to_uppercase()).into_bytes().into_boxed_slice(); unsafe { *out_len = result.len() }; Box::into_raw(result) as *mut u8}
/// # Safety/// `ptr..ptr+len` must be `len` i32 values in memory.#[unsafe(no_mangle)]pub unsafe extern "C" fn sum(ptr: *const i32, len: usize) -> i64 { unsafe { std::slice::from_raw_parts(ptr, len) }.iter().map(|&x| x as i64).sum()}
#[unsafe(no_mangle)]pub extern "C" fn divide(a: i32, b: i32) -> i32 { a / b}import { readFileSync } from 'node:fs'
const file = process.argv[2] ?? 'target/wasm32-unknown-unknown/release/adder.wasm'
let memory // set after instantiation; host_log reads the text from itconst imports = { env: { host_log(ptr, len) { console.log(' [from wasm]', new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, len))) }, },}
const module = await WebAssembly.compile(readFileSync(file))console.log('imports:', WebAssembly.Module.imports(module).map(i => `${i.module}.${i.name} (${i.kind})`).join(', '))console.log('exports:', WebAssembly.Module.exports(module).map(e => `${e.name} (${e.kind})`).join(', '))const { exports: w } = await WebAssembly.instantiate(module, imports)memory = w.memory
// Numbers cross as numbers. An i64 arrives in JavaScript as a BigInt.console.log('add(2, 3) =', w.add(2, 3))console.log('fib(50) =', w.fib(50), typeof w.fib(50))console.log('add(2147483647, 1) =', w.add(2147483647, 1))
// Text: copy the UTF-8 bytes into memory the module gave us, call, copy the answer out, free both.function shout(text) { const input = new TextEncoder().encode(text) const inPtr = w.alloc(input.length) new Uint8Array(memory.buffer, inPtr, input.length).set(input) const lenPtr = w.alloc(4) // room for one usize (4 bytes on wasm32) const outPtr = w.shout(inPtr, input.length, lenPtr) const outLen = new Uint32Array(memory.buffer, lenPtr, 1)[0] const out = new TextDecoder().decode(new Uint8Array(memory.buffer, outPtr, outLen)) w.dealloc(outPtr, outLen) w.dealloc(lenPtr, 4) w.dealloc(inPtr, input.length) return out}console.log(shout('straße'))
// An array: write four i32 values into memory, pass the address.const nums = Int32Array.of(10, 20, 30, 40)const numsPtr = w.alloc(nums.byteLength)new Int32Array(memory.buffer, numsPtr, nums.length).set(nums)console.log('sum =', w.sum(numsPtr, nums.length))w.dealloc(numsPtr, nums.byteLength)
// A panic in Rust is a trap: the call throws.try { w.divide(1, 0)} catch (e) { console.log(e.name + ':', e.message)}
// A view onto memory goes stale when the module's memory grows (MDN: grow() detaches the old buffer).const view = new Uint8Array(memory.buffer)console.log('view covers all of memory:', view.length === memory.buffer.byteLength)const big = w.alloc(8 * 1024 * 1024)console.log('old view length after alloc(8 MiB):', view.length)console.log('a fresh view covers the new memory:', new Uint8Array(memory.buffer).length > 8 * 1024 * 1024)w.dealloc(big, 8 * 1024 * 1024)imports: env.host_log (function) exports: memory (memory), add (function), alloc (function), dealloc (function), divide (function), fib (function), shout (function), sum (function) add(2, 3) = 5 fib(50) = 12586269025n bigint add(2147483647, 1) = -2147483648 [from wasm] shout got 7 bytes STRASSE! sum = 100n RuntimeError: unreachable view covers all of memory: true old view length after alloc(8 MiB): 0 a fresh view covers the new memory: true
- Numbers cross, nothing else does.
addandfibtake and return numbers. Theu64fromfibarrived in JavaScript as abigint. - Text is bytes in memory.
shoutcannot take a&str. The host asks the module for room withalloc, writes UTF-8 intomemory.buffer, and passes an address and a length. The answer comes back as an address, with its length written through a second pointer. The host mustdeallocall three: the module built them withBox::into_raw, so its owndeallochas to free them (Smart pointers). The length slot is 4 bytes becauseusizeis 32 bits here. - An import is a function you hand in.
host_logis looked up in theenvobject passed toinstantiate. The#[link(wasm_import_module = "env")]line names that module. - A panic is a trap.
divide(1, 0)threwRuntimeError: unreachable. The message is that one word, with no Rust panic text. The rustc book page above says that on this target “println!does nothing”. - Views of memory go stale. MDN says
Memory.grow()“detaches the previous buffer”. The last lines show the oldUint8Arrayreporting length 0 after the module allocated 8 MiB. Make a new view after any call that may allocate. - Release wraps, debug traps.
add(2147483647, 1)gave-2147483648in this release build. Try it, task 1, runs the debug build (Types has the same rule on native code).
How big is the module?
Section titled “How big is the module?”Two crates, three builds each. tiny exports just add; adder also has the allocator and the text code. small is the profile in the workspace Cargo.toml (opt-level = "z", lto, codegen-units = 1, strip; Performance).
$ stat -c '%s bytes %n' target/wasm32-unknown-unknown/*/*.wasm # Linux ARM64, rustc 1.99.1 1862903 bytes target/wasm32-unknown-unknown/debug/adder.wasm 32617 bytes target/wasm32-unknown-unknown/release/adder.wasm 23795 bytes target/wasm32-unknown-unknown/small/adder.wasm 1693973 bytes target/wasm32-unknown-unknown/debug/tiny.wasm 319 bytes target/wasm32-unknown-unknown/release/tiny.wasm 62 bytes target/wasm32-unknown-unknown/small/tiny.wasm $ cargo build -p adder -p tiny --target wasm32-unknown-unknown --config profile.dev.debug=0 # debug build again, without debug information $ stat -c '%s bytes %n' target/wasm32-unknown-unknown/debug/*.wasm 53091 bytes target/wasm32-unknown-unknown/debug/adder.wasm 20294 bytes target/wasm32-unknown-unknown/debug/tiny.wasm $ sh setup.sh wasm-opt # needs network: downloads binaryen version_133 $ wasm-opt -Oz -o adder-opt.wasm target/wasm32-unknown-unknown/small/adder.wasm # first line of the output [wasm-validator error in function 2] unexpected false: memory.fill operations require bulk memory [--enable-bulk-memory-opt], on $ cargo build -p adder --target wasm32-unknown-unknown --profile small --config profile.small.strip=false --target-dir unstripped # the same profile without strip $ wasm-opt -Oz -o unstripped-opt.wasm unstripped/wasm32-unknown-unknown/small/adder.wasm # no --all-features this time wasm-opt finished with no error $ rustc --print cfg --target wasm32-unknown-unknown | grep target_feature target_feature="bulk-memory" target_feature="multivalue" target_feature="mutable-globals" target_feature="nontrapping-fptoint" target_feature="reference-types" target_feature="sign-ext" $ wasm-opt -Oz --all-features -o adder-opt.wasm target/wasm32-unknown-unknown/small/adder.wasm $ stat -c '%9s bytes %n' adder-opt.wasm 20461 bytes adder-opt.wasm $ node run.mjs adder-opt.wasm # keeping the add and sum lines add(2, 3) = 5 add(2147483647, 1) = -2147483648 sum = 100n
This output changes from run to run. This is one real run.
- The debug builds are by far the largest, about 1.7 MB for one function. With
profile.dev.debug=0it was about 20 KB, so most of that was debug information. wasm-opt(from Binaryen) rejected thesmallmodule. The cause isstripin that profile: it removes the section that records which features the module uses, sowasm-optdoes not know bulk memory is allowed. The same profile built withstrip=falsepasses with no flag. For the stripped file,--all-featureswas the shortest fix (the six features printed are the ones rustc enables for this target), and the result still ran.- These counts come from one toolchain (rustc 1.99.1, Binaryen version_133). The debug counts also change with the directory you build in, and every size changes with the tool versions. What a size costs depends on where the file is served from, which I did not run.
wasm-bindgen: strings, classes and errors
Section titled “wasm-bindgen: strings, classes and errors”wasm-bindgen is a tool that, its guide says, “allows JS/wasm to communicate with strings, JS objects, classes, etc, as opposed to purely integers and floats”. It has two halves: the wasm-bindgen crate, whose macros you write, and a command that reads the compiled module and writes the JavaScript glue and a .d.ts file. The docs install the command with cargo install; the session downloads the prebuilt release of the same version instead (0.2.129, matching the crate pinned in Cargo.toml).
[package]name = "greeter"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib"]
[dependencies]wasm-bindgen = "=0.2.129"use wasm_bindgen::prelude::*;
// A JavaScript function Rust can call: console.log.#[wasm_bindgen]unsafe extern "C" { #[wasm_bindgen(js_namespace = console)] fn log(message: &str);}
// &str in, String out: the generated glue copies the text across for you.#[wasm_bindgen]pub fn shout(text: &str) -> String { log(&format!("shout got {} bytes", text.len())); format!("{}!", text.to_uppercase())}
// A slice in; JavaScript passes an Int32Array.#[wasm_bindgen]pub fn sum(values: &[i32]) -> i64 { values.iter().map(|&v| v as i64).sum()}
// Err becomes a thrown JavaScript Error.#[wasm_bindgen]pub fn parse(text: &str) -> Result<i32, JsError> { text.trim().parse::<i32>().map_err(|e| JsError::new(&e.to_string()))}
// A struct becomes a JavaScript class that owns Rust memory.#[wasm_bindgen]pub struct Counter { count: u32,}
#[wasm_bindgen]impl Counter { #[wasm_bindgen(constructor)] pub fn new() -> Counter { Counter { count: 0 } }
pub fn bump(&mut self) -> u32 { self.count += 1; self.count }}
impl Default for Counter { fn default() -> Self { Self::new() }}const { shout, sum, parse, Counter } = require('./bindgen/pkg/greeter.js')
console.log(shout('straße'))console.log('sum =', sum(Int32Array.of(10, 20, 30, 40)))console.log('parse(" 42 ") =', parse(' 42 '))try { parse('4x2')} catch (e) { console.log(`${e.name}: ${e.message}`)}const c = new Counter()console.log(c.bump(), c.bump())c.free() // Rust memory is not garbage collected until you free it (or a FinalizationRegistry runs)$ sh setup.sh wasm-bindgen # needs network: downloads the prebuilt 0.2.129 command $ wasm-bindgen --version wasm-bindgen 0.2.129 $ cargo build -p greeter --target wasm32-unknown-unknown --release # needs network: downloads the wasm-bindgen crates $ wasm-bindgen --target nodejs --out-dir bindgen/pkg target/wasm32-unknown-unknown/release/greeter.wasm $ stat -c '%s bytes %n' bindgen/pkg/* 297 bytes bindgen/pkg/greeter.d.ts 5977 bytes bindgen/pkg/greeter.js 35932 bytes bindgen/pkg/greeter_bg.wasm 813 bytes bindgen/pkg/greeter_bg.wasm.d.ts $ cat bindgen/pkg/greeter.d.ts /* tslint:disable */ /* eslint-disable */ export class Counter { free(): void; [Symbol.dispose](): void; bump(): number; constructor(); } export function parse(text: string): number; export function shout(text: string): string; export function sum(values: Int32Array): bigint; $ node use.js shout got 7 bytes STRASSE! sum = 100n parse(" 42 ") = 42 Error: invalid digit found in string 1 2
The .d.ts file is what a TypeScript caller sees: shout(text: string): string, and sum returns bigint because the Rust side returns i64. The Result<i32, JsError> became a thrown Error carrying Rust’s own message. Counter became a class, and use.js calls its free() to release the Rust value; I did not test skipping it. --target nodejs made a CommonJS module; the deployment page lists the others, including web and bundler for browsers, which I did not run.
wasm-pack wraps the same steps. It built the crate, fetched wasm-bindgen, ran wasm-opt and wrote a package.json. Its greeter_bg.wasm is 26,286 bytes against 35,932 from the command above; the extra step in its output is wasm-opt, and wasm-pack build --no-opt gave 35,932 again.
wasm-pack uses a wasm-bindgen or wasm-opt it finds on your PATH instead of downloading its own. The session hides one step: it first ran rm -f /usr/local/bin/wasm-opt /usr/local/bin/wasm-bindgen, so wasm-pack downloaded both. In a check outside the page, with Binaryen version_133 and wasm-bindgen on the PATH, the Installing line was missing and the file was 26,191 bytes: 26,286 belongs to wasm-pack’s own wasm-opt.
$ rustup target add wasm32-unknown-unknown # needs network $ sh setup.sh wasm-pack # needs network: downloads the prebuilt 0.15.0 command $ wasm-pack build bindgen --target nodejs --release # needs network (with no wasm-bindgen or wasm-opt on the PATH it downloads its own); the 'Done in' line is left out because its time varies [INFO]: 🎯 Checking for the Wasm target... [INFO]: 🌀 Compiling to Wasm... [INFO]: ⬇️ Installing wasm-bindgen... [INFO]: Optimizing wasm binaries with `wasm-opt`... [INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended [INFO]: 📦 Your wasm pkg is ready to publish at bindgen/pkg. $ stat -c '%s bytes %n' bindgen/pkg/* 297 bytes bindgen/pkg/greeter.d.ts 5977 bytes bindgen/pkg/greeter.js 26286 bytes bindgen/pkg/greeter_bg.wasm 813 bytes bindgen/pkg/greeter_bg.wasm.d.ts 175 bytes bindgen/pkg/package.json $ ls ~/.cache/.wasm-pack # where wasm-pack keeps the tools it downloaded wasm-bindgen-b32d3a9b2bb775a3 wasm-opt-e5a53a595abae3fc
WASI: a module with main
Section titled “WASI: a module with main”A WASI module is a program: it has main, arguments, an environment, stdin and stdout, and files. wasm32-wasip1 imports those from wasi_snapshot_preview1. The rustc book says the standard library works there in part: “spawning a thread will always return an error” and so will spawning a process, while “opening a file” calls WASI functions.
use std::io::Read;use std::{env, fs, io};
fn main() { // A WASI module is a program: it has main, arguments, environment, stdin and stdout. println!("args: {:?}", env::args().skip(1).collect::<Vec<_>>()); println!("GREETING = {:?}", env::var("GREETING"));
let mut input = String::new(); io::stdin().read_to_string(&mut input).unwrap(); println!("stdin had {} bytes", input.len());
// Files: only the directories the host hands over ("preopens") exist for the module. println!("write sandbox/note.txt: {:?}", fs::write("sandbox/note.txt", input.to_uppercase())); println!("read sandbox/note.txt: {:?}", fs::read_to_string("sandbox/note.txt")); println!("read /etc/hostname: {:?}", fs::read_to_string("/etc/hostname").map_err(|e| e.kind()));}$ rustup target add wasm32-wasip1 # needs network $ cargo build -p wasi-demo --target wasm32-wasip1 --release $ sh setup.sh wasmtime # needs network: downloads the prebuilt v50.0.0 command $ mkdir sandbox $ echo "hello wasi" | wasmtime run --dir sandbox --env GREETING=hi target/wasm32-wasip1/release/wasi-demo.wasm one two args: ["one", "two"] GREETING = Ok("hi") stdin had 11 bytes write sandbox/note.txt: Ok(()) read sandbox/note.txt: Ok("HELLO WASI\n") read /etc/hostname: Err(NotFound) $ cat sandbox/note.txt HELLO WASI $ echo "hello wasi" | wasmtime run target/wasm32-wasip1/release/wasi-demo.wasm args: [] GREETING = Err(NotPresent) stdin had 11 bytes write sandbox/note.txt: Err(Os { code: 44, kind: NotFound, message: "No such file or directory" }) read sandbox/note.txt: Err(Os { code: 44, kind: NotFound, message: "No such file or directory" }) read /etc/hostname: Err(NotFound) $ sh setup.sh wasmer # needs network: downloads the prebuilt v7.5.0 command $ echo "hello wasi" | wasmer run --volume sandbox:/sandbox target/wasm32-wasip1/release/wasi-demo.wasm -- one two args: ["one", "two"] GREETING = Err(NotPresent) stdin had 11 bytes write sandbox/note.txt: Ok(()) read sandbox/note.txt: Ok("HELLO WASI\n") read /etc/hostname: Err(NotFound) $ echo "hello wasi" | node --no-warnings wasi.mjs target/wasm32-wasip1/release/wasi-demo.wasm args: ["one", "two"] GREETING = Ok("hi") stdin had 11 bytes write sandbox/note.txt: Ok(()) read sandbox/note.txt: Ok("HELLO WASI\n") read /etc/hostname: Err(NotFound) $ stat -c '%s bytes %n' target/wasm32-wasip1/release/wasi-demo.wasm 109682 bytes target/wasm32-wasip1/release/wasi-demo.wasm
The same .wasm file ran under three hosts. The first run gave the module a directory (--dir sandbox) and a variable. The second gave it neither, so the module saw no variable and could not open sandbox/note.txt: a WASI module gets what the host hands over. Wasmer printed the same lines except GREETING, which I did not pass. Node’s node:wasi printed the same lines as Wasmtime; its docs mark it “Stability: 1 - Experimental” and say not to rely on it to run untrusted code, so the session runs it with --no-warnings, which hides the experimental warning.
The source also builds for wasm32-wasip2 and ran under Wasmtime with the same kinds of lines as the Wasmer run (argument x, no variable). I did not look inside the file; the rustc book says the WASI standard effort was “rebased” on the Component Model and released as 0.2.0 (“WASIp2”).
$ cargo build -p wasi-demo --target wasm32-wasip2 --release $ echo "hello wasi" | wasmtime run --dir sandbox target/wasm32-wasip2/release/wasi-demo.wasm x args: ["x"] GREETING = Err(NotPresent) stdin had 11 bytes write sandbox/note.txt: Ok(()) read sandbox/note.txt: Ok("HELLO WASI\n") read /etc/hostname: Err(NotFound)
The same job in your language
Section titled “The same job in your language”Each row says how a language reaches Wasm and what ran here. Where the image has the tool, I built an add function and loaded it with one Node script (callAdd.mjs).
| Language | Way to WebAssembly | What ran here |
|---|---|---|
| Rust | rustup target add, then cargo build --target |
everything above |
| Go | GOOS=js GOARCH=wasm (the Node script wasm_exec_node.js ships with Go), or GOOS=wasip1 GOARCH=wasm; //go:wasmexport since Go 1.24 (blog). TinyGo has its own Wasm guide (docs) |
all three of Go’s; TinyGo not run |
| TypeScript | the host side here is JavaScript (run.mjs); a TypeScript host was not run. AssemblyScript is a language with TypeScript-like syntax that compiles to Wasm |
AssemblyScript 0.28.20 add, called from Node |
| Kotlin | Kotlin/Wasm (docs) | not run |
| C# | Blazor WebAssembly runs on “a WebAssembly-based .NET runtime” (docs) | not run |
| C and C++ | Emscripten, “a complete Open Source compiler toolchain to WebAssembly” (docs); for wasm32-wasip1 the rustc book says the target is meant to interoperate with C and C++, and recommends the wasi-sdk if you build a staticlib or use an external -Clinker |
not run: the image’s gcc builds only for this machine |
| Python, Java | not covered on this page | not run |
// Only `add`: no text, no allocation, no imports.#[unsafe(no_mangle)]pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b}package main
// The Go counterpart of adder's add: a function the host can call (go:wasmexport, Go 1.24+).
//go:wasmexport addfunc add(a, b int32) int32 { return a + b }
// main is required but is not run in this mode.func main() {}// AssemblyScript: a TypeScript-like syntax that compiles to WebAssembly.export function add(a: i32, b: i32): i32 { return a + b}target/wasm32-unknown-unknown/small/tiny.wasm: 62 bytes, 0 imports (none), add(2, 3) = 5 goexport/goexport.wasm: 1908098 bytes, 10 imports (wasi_snapshot_preview1), add(2, 3) = 5 assemblyscript/add.wasm: 55 bytes, 0 imports (none), add(2, 3) = 5
The output has Rust’s 62-byte add, Go’s module with 10 WASI imports, and AssemblyScript’s 55 bytes. Each module holds different things, so these compare three builds, not three languages. Go’s GOOS=js hello world, run by wasm_exec_node.js:
$ GOOS=js GOARCH=wasm go build -o main.wasm . $ node $(go env GOROOT)/lib/wasm/wasm_exec_node.js main.wasm one two hello from Go, args: [one two] $ stat -c '%s bytes %n' main.wasm 2501067 bytes main.wasm
The gotcha is in std
Section titled “The gotcha is in std”On Linux or macOS (here, in the image) the probe below prints Ok; I did not run it on Windows, where echo is a shell built-in:
$ cargo run -q -p probe
run echo: Ok(true)
spawn thread: Ok(2)
std compiles for the target, then the call fails
Looks fine, and it compiles in Rust too. But the two give different results.
Not run in Go on this page.Not run in Kotlin on this page.Not run in Python on this page.Not run in TypeScript on this page.Not run in Java on this page.Not run in C# on this page.Not run in C on this page.Not run in C++ on this page.use std::process::Command;use std::thread;
fn main() { let echo = Command::new("echo").arg("hi").output(); println!("run echo: {:?}", echo.map(|out| out.status.success()));
let worker = thread::Builder::new().spawn(|| 1 + 1); println!("spawn thread: {:?}", worker.map(|handle| handle.join().unwrap()));}There is no Go version of this one.
There is no Kotlin version of this one.
There is no Python version of this one.
There is no TypeScript version of this one.
There is no Java version of this one.
There is no C# version of this one.
There is no C version of this one.
There is no C++ version of this one.
run echo: Err(Error { kind: Unsupported, message: "operation not supported on this platform" })
spawn thread: Err(Os { code: 58, kind: Unsupported, message: "Not supported" })Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
Command::output and thread::Builder::spawn are ordinary std functions. They compile for the WebAssembly target as they do for your own machine, and the run on Linux or macOS succeeds.
The standard library is present on wasm32-wasip1, but there the rustc book says spawning a thread or a process always returns an error (a separate wasm32-wasip1-threads target exists for threads; I did not run it). Both functions return Err (ErrorKind::Unsupported) instead of failing to compile, because their signatures already allow an error. The compiler checked the types, not what the target can do.
The rustc book's page for the target says that "spawning a thread will always return an error" and "spawning a process will always return an error", while operations such as opening a file call the WASI functions. The page for wasm32-unknown-unknown says std::thread::spawn will panic there. The fix is to treat the error as an answer for that target: check for ErrorKind::Unsupported, and do the work on the calling thread or ask the host.
The fix
Compiles and runs.
use std::io::ErrorKind;use std::process::Command;use std::thread;
fn main() { // Treat "this target cannot do that" as an answer, not a surprise. match Command::new("echo").arg("hi").output() { Ok(out) => print!("echo said: {}", String::from_utf8_lossy(&out.stdout)), Err(e) if e.kind() == ErrorKind::Unsupported => println!("no processes on this target"), Err(e) => println!("echo failed: {e}"), }
let work = || 1 + 1; let answer = match thread::Builder::new().spawn(work) { Ok(handle) => handle.join().unwrap(), Err(_) => work(), // no threads here: do the work on the calling thread }; println!("answer: {answer}");}no processes on this target answer: 2
Try it yourself
Section titled “Try it yourself”- Build
adderwithout--releaseand runnode run.mjson the debug file. After the first two lines comesRuntimeError: unreachable, and the stack namespanic_const_add_overflow: the overflow check is compiled in. - Pass
{}as the imports toWebAssembly.instantiate. Node refuses with aTypeErrorthat names import #0,"env". - Delete the
#[link(wasm_import_module = "env")]line and build. The linker fails withundefined symbol: host_log.
$ cargo build -p adder --target wasm32-unknown-unknown $ node run.mjs target/wasm32-unknown-unknown/debug/adder.wasm # keeping the lines that name the trap add(2, 3) = 5 fib(50) = 12586269025n bigint RuntimeError: unreachable panic_const_add_overflow
$ node -e 'WebAssembly.instantiate(require("fs").readFileSync("target/wasm32-unknown-unknown/release/adder.wasm"), {}).catch(e => console.log(e.name + ": " + e.message))'
TypeError: WebAssembly.instantiate(): Import #0 "env": module is not an object or function
$ sed -i '/wasm_import_module/d' adder/src/lib.rs; cargo build -p adder --target wasm32-unknown-unknown --release # the line of the linker error that names the problem
undefined symbol: host_log
Read more
Section titled “Read more”- The rustc book:
wasm32-unknown-unknown,wasm32-wasip1andwasm32-wasip2 - The wasm-bindgen guide and wasm-pack
- MDN: WebAssembly concepts and
WebAssembly.Memory - WASI, Wasmtime, Wasmer and Node’s
node:wasi - Other languages: Go and WebAssembly, TinyGo, AssemblyScript
- In this course: unsafe, C and C++ interop, Performance
Tool versions, all pinned in setup.sh or the manifests: wasm-bindgen crate and command 0.2.129, wasm-pack 0.15.0, Wasmtime v50.0.0, Wasmer 7.5.0, Binaryen version_133, AssemblyScript 0.28.20.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.