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_get returns 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 see KEY.
  • wasmtime -- my_app.wasm arg1 arg2: can see args.
  • wasmtime --dir=/tmp::/home/user my_app.wasm: preopens /home/user as /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_oneoff primitive but no async/await story. 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:

  1. User writes Rust, compiles to WASI.
  2. Your server embeds a Wasm runtime (say, Wasmtime).
  3. For each user request, you instantiate the module with only the capabilities that request needs.
  4. 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.