WASI: Wasm Beyond the Browser
This chapter covers WASI, the system interface that lets Wasm modules read files, write stdout, and behave like real processes on a server.
Why WASI Exists
Bare Wasm has no concept of the outside world. No files, no network, no stdout. Everything goes through host-provided imports, and each host invents its own set.
The result: a "Wasm module" that runs in Wasmtime doesn't run in Wasmer without tweaks, because they imported different things.
WASI (the WebAssembly System Interface) solves this. It's a standard set of imports, designed like a minimal POSIX, that every runtime implements the same way. Compile to WASI once, run anywhere.
Preview 1 and Preview 2
WASI has had two major generations.
WASI Preview 1 (sometimes "WASI 0.1"): the first widely deployed version. POSIX-ish syscalls (fd_read, fd_write, path_open, environ_get, and friends). Stable, mature, supported by every server runtime.
WASI Preview 2 (or "WASI 0.2"): the next generation, built on the Component Model (Chapter 10). Better ergonomics, typed interfaces, HTTP support, pluggable subsystems. Newer, less universally supported, but the future.
This chapter uses Preview 1 because it's the most practical target. Preview 2 is covered in Chapter 10.
The Preview 1 Surface
Preview 1 gives you:
- Stdio. Read stdin, write stdout and stderr.
- Filesystem. Open, read, write, seek, close. Scoped to directories the host pre-opens.
- Environment. Read env variables.
- Arguments. Read command-line args.
- Random.
random_getreturns bytes from a secure RNG. - Clocks. Monotonic and real-time clocks.
- Pollable I/O. Basic poll for async-ish work.
Conspicuously missing: networking. Preview 1 has no sockets. Some runtimes extend it with custom imports (wasmtime has a wasi-sockets proposal), and Preview 2 standardizes it.
Compiling Rust for WASI
Use the wasm32-wasip1 target:
rustup target add wasm32-wasip1
Build any Rust program:
use std::fs;
fn main() {
let contents = fs::read_to_string("/input.txt").unwrap();
println!("file contents: {contents}");
}
cargo build --target wasm32-wasip1 --release
That's a real Rust program. println! works. fs::read_to_string works. The standard library calls WASI imports under the hood.
Run it:
wasmtime --dir=. target/wasm32-wasip1/release/my_app.wasm
--dir=. pre-opens the current directory, letting the module see ./input.txt as /input.txt (WASI uses a slightly different path model).
Capability-Based Security
WASI is capability-based: a module can only access what the host explicitly grants.
wasmtime run my_app.wasm: no filesystem, no env, no args.wasmtime --dir=. my_app.wasm: can read/write the current directory.wasmtime --env KEY=value my_app.wasm: can seeKEY.wasmtime -- my_app.wasm arg1 arg2: can see args.wasmtime --dir=/tmp::/home/user my_app.wasm: preopens/home/useras/tmp.
The module has no default access. Every capability is an explicit flag.
This is the main difference from running a native binary. A native binary has full access to your filesystem, environment, network. A WASI binary has exactly what you grant.
A Real Example: Word Count
use std::io::Read;
fn main() {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).unwrap();
let chars = input.chars().count();
let words = input.split_whitespace().count();
let lines = input.lines().count();
println!("{lines} {words} {chars}");
}
Build:
cargo build --target wasm32-wasip1 --release
Run:
echo "hello world\nhow are you" | wasmtime target/wasm32-wasip1/release/wc.wasm
# 2 5 24
Real Rust. Real stdio. Real Wasm. The binary runs on any WASI runtime.
Arguments and Environment
fn main() {
let args: Vec<String> = std::env::args().collect();
for (i, arg) in args.iter().enumerate() {
println!("arg {i}: {arg}");
}
for (k, v) in std::env::vars() {
println!("env {k}={v}");
}
}
wasmtime --env GREETING=hello my_app.wasm -- one two three
Arguments after -- are passed to the module. --env sets env vars.
Reading and Writing Files
use std::fs;
use std::io::Write;
fn main() {
let content = "hello from wasm";
let mut file = fs::File::create("/out/greeting.txt").unwrap();
file.write_all(content.as_bytes()).unwrap();
let read_back = fs::read_to_string("/out/greeting.txt").unwrap();
println!("read back: {read_back}");
}
mkdir -p out
wasmtime --dir=./out::/out my_app.wasm
cat out/greeting.txt
# hello from wasm
The --dir=host::guest syntax maps a host path to a guest path. The module sees /out; the host sees ./out.
What WASI Can't Do (Preview 1)
- Sockets. No TCP, no UDP, no HTTP. Preview 2 adds these.
- Async I/O. Preview 1 has a
poll_oneoffprimitive but noasync/awaitstory. Preview 2 adds a full async model. - Threads. Wasm threads exist as a separate proposal, not always enabled.
- Native libraries. You can't
dlopen. Everything is statically compiled into the module.
For network-facing work, you either use Preview 2 (if your runtime supports it), use custom imports for sockets, or run Wasm behind an HTTP server that handles networking natively.
WASI-Compatible Runtimes
Every major server-side Wasm runtime supports WASI Preview 1. The differences are in extras (networking, Preview 2 support, embedding APIs).
- Wasmtime (Bytecode Alliance): reference implementation, Rust-based.
- Wasmer: similar scope, broader language embedding support.
- WasmEdge: edge/cloud focused, includes TLS and networking extensions.
- wazero (Go): pure-Go runtime, no CGO.
Chapter 9 covers picking and embedding one.
A Useful Pattern: WASI for Plugins
Many server-side Wasm use cases are "run untrusted user code safely". WASI's capability model makes this easy:
- User writes Rust, compiles to WASI.
- Your server embeds a Wasm runtime (say, Wasmtime).
- For each user request, you instantiate the module with only the capabilities that request needs.
- You invoke their code, capture stdout/return value, and respond.
Shopify Functions, Fastly Compute@Edge, and many Envoy filters work this way.
Common Pitfalls
Running without --dir. File operations fail with "not capable" errors. Pre-open what you need.
Wrong path mapping. The module sees guest paths, not host paths. --dir=/tmp::/app means the module sees /app, backed by the host's /tmp. Confusing until it clicks.
Using std::net. Won't link for wasm32-wasip1. Preview 2 or runtime extensions are required for networking.
Expecting POSIX semantics exactly. Close; not identical. open flags differ slightly. Symlink resolution is stricter. Check the WASI spec when something behaves oddly.
Conflating WASI and wasm32-unknown-unknown. Different targets. A module built for one won't run as the other. For a server-side CLI, you want WASI.
Next Steps
Continue to 09-server-runtimes.md to pick a runtime for production.