Universal Binaries: Cross-Platform Wasm CLI [2026]
Bottom Line
A WASI component lets you ship one CLI artifact instead of separate macOS, Linux, and Windows builds, as long as the host has a WASI runtime such as Wasmtime. The tradeoff is explicit sandboxing: filesystem and other host capabilities must be granted on purpose.
Key Takeaways
- ›wasm32-wasip2 produces a WASI Preview 2 component that runs on Wasmtime 17+.
- ›One
.wasmfile can replace separate macOS, Linux, and Windows CLI builds. - ›WASI is sandboxed by default, so file access needs explicit mounts such as --dir.
- ›If your CLI mostly uses Rust
std, the port to WASI is usually small and mechanical.
Universal binaries usually mean maintaining a build matrix, signing pipeline, and release artifacts for every operating system you support. With WebAssembly and WASI, you can invert that model: compile your CLI once into a portable .wasm component, then run the same artifact on macOS, Linux, and Windows through a compliant runtime. This tutorial uses Rust plus WASI Preview 2 to build a small CLI, verify it, and package it in a way that stays honest about the runtime tradeoffs.
- wasm32-wasip2 targets the newer WASI component model instead of legacy Preview 1.
- Wasmtime can execute the same CLI artifact across all major desktop OSes.
- Sandboxing is the feature, not a bug: your CLI gets only the capabilities you grant.
- The clean mental model is simple: one portable artifact, optional thin wrappers per platform.
Prerequisites
Before you start
- A recent stable Rust toolchain with
cargoandrustup. - Wasmtime installed to run the resulting component.
- wasi-sdk 22+ available, with
WASI_SDK_PATHset. - A CLI codebase that mostly uses portable Rust
stdAPIs rather than OS-specific syscalls.
Bottom Line
If your command-line app is mostly text I/O, argument parsing, filesystem work, and straightforward networking, WASI is now practical for shipping one portable artifact. The big shift is operational: you are distributing a runtime-based CLI, not pretending a .wasm file is a native executable.
Two official details matter here. First, Rust documents wasm32-wasip2 as a component-producing target for WASI Preview 2. Second, Rust notes that Wasmtime 17+ can run that target natively. That gives us a stable enough baseline for a production-minded tutorial in 2026.
rustup target add wasm32-wasip2
curl https://wasmtime.dev/install.sh -sSf | bash
export WASI_SDK_PATH=/opt/wasi-sdk-22.0If you are documenting or sharing the commands internally, run them through TechBytes' Code Formatter before pasting them into scripts, READMEs, or release notes.
Step 1: Create the CLI
Start with a tiny Rust program that exercises the two things most CLIs need: arguments and file access. That gives you a meaningful portability test without dragging in platform-specific dependencies.
- Create a new project.
- Replace the default
src/main.rswith a WASI-friendly version. - Keep the implementation boring on purpose so you can isolate runtime behavior from app logic.
cargo new universal-greet
cd universal-greetuse std::{env, fs};
fn main() {
let mut args = env::args().skip(1);
let name = args.next().unwrap_or_else(|| "world".to_string());
let file = args.next();
println!("hello, {name} from WASI");
if let Some(path) = file {
match fs::read_to_string(&path) {
Ok(contents) => println!("read {} bytes from {}", contents.len(), path),
Err(err) => eprintln!("could not read {}: {}", path, err),
}
}
}Why this example works
env::args()maps cleanly to the WASI CLI interface.println!andeprintln!map to standard output and error streams.std::fsworks, but only for directories the runtime explicitly preopens.
That last point is the architectural difference from native binaries. In a normal host executable, your process inherits wide filesystem access by default. In a WASI component, access is capability-based and opt-in.
Step 2: Build the WASI Component
Now compile the same Rust code to wasm32-wasip2. The target produces a WebAssembly component rather than a plain native binary, which is exactly what lets a compliant runtime provide the same behavior across operating systems.
- Confirm the target is installed with rustup target add.
- Build in release mode with --target.
- Locate the generated
.wasmartifact under the target directory.
cargo build --release --target wasm32-wasip2You should now have an artifact at:
target/wasm32-wasip2/release/universal-greet.wasmWhat actually changed
- Your application logic stayed in Rust.
- Your distribution artifact became a portable component.
- The host-specific work moved into the runtime layer, which is Wasmtime here.
This is the core universal-binary pattern. Instead of producing three executables and hoping behavior stays aligned, you produce one CLI artifact and rely on the runtime to adapt it to each host OS.
.wasm file as your canonical release asset, then add thin platform wrappers only for developer ergonomics.Step 3: Run It Anywhere
Execution is where the payoff becomes obvious. The same component file can be copied to a different OS and run there with Wasmtime. Start with argument handling, then test filesystem access.
- Run the component with a simple argument.
- Create a local text file.
- Grant directory access with --dir and run again.
wasmtime target/wasm32-wasip2/release/universal-greet.wasm Adaprintf "portable cli\n" > note.txt
wasmtime --dir . target/wasm32-wasip2/release/universal-greet.wasm Ada note.txtImportant flag rule
Put Wasmtime flags before the WebAssembly file. For example, --dir must appear before universal-greet.wasm; otherwise it is passed to your program as an argument instead of being interpreted by the runtime.
Optional wrapper scripts
If you want the command to feel more native, add tiny launcher scripts per platform while still shipping the same .wasm artifact underneath.
#!/usr/bin/env sh
DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec wasmtime "$DIR/universal-greet.wasm" "$@"@echo off
set DIR=%~dp0
wasmtime "%DIR%universal-greet.wasm" %*This is the honest compromise. The universal part is the component; the wrapper is just convenience glue for local shells and package managers.
Verify and Troubleshoot
Verification and expected output
A healthy run should look like this:
$ wasmtime target/wasm32-wasip2/release/universal-greet.wasm Ada
hello, Ada from WASI
$ wasmtime --dir . target/wasm32-wasip2/release/universal-greet.wasm Ada note.txt
hello, Ada from WASI
read 13 bytes from note.txtThe exact path formatting may differ by OS, but the behavior should not. That consistency is the value proposition you are buying.
Top 3 troubleshooting fixes
- Unknown target: If Cargo does not recognize wasm32-wasip2, install it first with
rustup target add wasm32-wasip2. - File access fails: If your program cannot open a file, mount the directory explicitly with wasmtime --dir . before the wasm path.
- Linker or SDK errors: Ensure wasi-sdk 22+ is installed and
WASI_SDK_PATHpoints to it correctly.
One more pragmatic note: not every native crate will behave under WASI. Anything that assumes raw OS handles, daemonization, fork/exec patterns, or arbitrary host paths deserves a compatibility audit before you promise universal distribution.
What's Next
Once the basic CLI works, you can harden the workflow instead of stopping at a demo.
- Add CI that builds wasm32-wasip2 once and runs smoke tests with Wasmtime.
- Create platform-specific installers that bundle the same
.wasmplus a wrapper or a managed runtime dependency. - Audit capabilities deliberately: decide which directories, environment variables, and network permissions your tool actually needs.
- Benchmark startup time against your native build so you know whether portability or cold-start latency matters more for your use case.
The bigger design lesson is that universal binaries in 2026 are no longer only about static linking or fat executables. With WASI, the universal unit can be a capability-scoped component. For internal tooling, build pipelines, and developer-facing CLIs, that is often the cleaner abstraction.
Frequently Asked Questions
Can a WASI CLI really replace separate macOS, Linux, and Windows binaries? +
.wasm component and require a compatible runtime such as Wasmtime on each host. If you need a zero-runtime user experience, you will still package wrappers, installers, or bundled runtimes per platform.
Why use wasm32-wasip2 instead of wasm32-unknown-unknown for a CLI?
+
wasm32-unknown-unknown is the minimal WebAssembly target and does not provide the host interfaces a CLI expects. wasm32-wasip2 gives you standardized WASI APIs for arguments, stdio, and other host capabilities through the component model.How do I let a WASI program read local files? +
wasmtime --dir . app.wasm, and make sure the runtime flags appear before the wasm file path.Do Rust crates work unchanged under WASI? +
std behavior. Crates that depend on OS-specific syscalls, process control, or native platform bindings often need conditional compilation or a different implementation path.Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.
Related Deep-Dives
Rust CLI Release Engineering for Small Teams
A practical guide to versioning, packaging, and distributing command-line tools without a brittle release matrix.
System ArchitectureWebAssembly Component Model Explained
Understand the component model ideas that make modern WASI workflows possible.
Security Deep-DiveSecure CLI Defaults for Internal Tools
Design safer developer tools by making capabilities, paths, and secrets explicit from day one.