This library provides a WASI implementation for Node.js and browsers in a tree-shaking friendly way. The system calls provided by this library are configurable.
With minimal configuration, it provides WASI system calls which just return WASI_ENOSYS.
- No dependencies
- Tree-shaking friendly
- 3 KB when minimal configuration
- 6 KB when all features enabled
- Almost compatible interface with Node.js WASI implementation
- Well tested, thanks to wasi-test-suite by Casper Beyer
npm install uwasiimport { WASI, useAll } from "uwasi";
import fs from "node:fs/promises";
async function main() {
const wasi = new WASI({
args: process.argv.slice(2),
features: [useAll()],
});
const bytes = await fs.readFile(process.argv[2]);
const { instance } = await WebAssembly.instantiate(bytes, {
wasi_snapshot_preview1: wasi.wasiImport,
});
const exitCode = wasi.start(instance);
console.log("exit code:", exitCode);
/* With Reactor model
wasi.initialize(instance);
*/
}
main()import { WASI, useAll } from "uwasi";
const wasi = new WASI({
features: [],
});import { WASI, useArgs, useClock } from "uwasi";
const wasi = new WASI({
args: ["./a.out", "hello", "world"],
features: [useEnviron, useArgs, useClock, useProc, useRandom()],
});By default, stdin behaves like /dev/null, stdout and stderr print to the console.
import { WASI, useStdio } from "uwasi";
const wasi = new WASI({
features: [useStdio()],
});You can use custom backends for stdio by passing handlers to useStdio.
import { WASI, useStdio } from "uwasi";
const inputs = ["Y", "N", "Y", "Y"];
const wasi = new WASI({
features: [useStdio({
stdin: () => inputs.shift() || "",
stdout: (str) => document.body.innerHTML += str,
stderr: (str) => document.body.innerHTML += str,
})],
});By default, the stdout and stderr handlers are passed strings. You can pass outputBuffers: true to get Uint8Array buffers instead. Along with that, you can also pass Uint8Array buffers to stdin.
import { WASI, useStdio } from "uwasi";
const wasi = new WASI({
features: [useStdio({
outputBuffers: true,
stdin: () => new Uint8Array([1, 2, 3, 4, 5]),
stdout: (buf) => console.log(buf),
stderr: (buf) => console.error(buf),
})],
});usePoll supplies the blocking primitives that libc sleep functions
(nanosleep, usleep, timed waits) are built on. Clock subscriptions block
the calling thread until the earliest deadline using Atomics.wait where the
host allows it, falling back to a busy-wait (e.g. on the browser main thread).
Since all file descriptors in this runtime are synchronous, fd_read/fd_write
subscriptions report ready immediately.
import { WASI, useStdio, usePoll } from "uwasi";
const wasi = new WASI({
features: [useStdio(), usePoll()],
});The blocking strategy is replaceable, e.g. to integrate with a host scheduler:
const wasi = new WASI({
features: [usePoll({ sleep: (ms) => mySynchronousSleep(ms) })],
});usePoll is included in useAll().
For readiness-driven guests (e.g. poll(2)-based event loops or libdispatch
fd sources), SharedInputChannel connects a producing thread — a worker
pumping a pipe, or a UI thread collecting keystrokes — to the guest thread
over a SharedArrayBuffer ring. poll_oneoff then genuinely parks the guest
(Atomics.wait) until input arrives, end of file, or a clock deadline, and
reads drain the buffer without blocking. Producer close is delivered as an
fd hangup event (POLLHUP through libc poll).
// Guest thread
import { WASI, useStdio, usePoll, SharedInputChannel } from "uwasi";
const channel = new SharedInputChannel();
// hand channel.sharedBuffer to the producing thread...
const wasi = new WASI({
features: [
useStdio({ stdin: channel.stdin() }),
usePoll({ fdReadiness: channel.fdReadiness() }),
],
});
// Producing thread (worker or main thread)
const producer = new SharedInputChannel(sharedBufferFromGuestThread);
producer.push(new TextEncoder().encode("hello"));
producer.close(); // end of fileAtomics.wait is unavailable on a browser main thread, so run the guest in a
worker there; waits degrade to a busy-wait otherwise. In browsers,
SharedArrayBuffer additionally requires the page to be cross-origin
isolated (Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp response headers). Node.js and
worker threads need no special setup.
43 of the 46 WASI preview1 functions are implemented (the three
socket-transfer calls are deliberately absent — preview1 sockets are
vestigial and were replaced wholesale in preview2). The filesystem surface
is provided by useMemoryFS and validated against the full
wasi-testsuite with zero
skipped cases; useStdio provides the stdio subset only.
| Syscall | Status | Notes |
|---|---|---|
args_get / args_sizes_get |
✅ | |
clock_res_get / clock_time_get |
✅ | CPU-time clocks are approximated by the monotonic clock |
environ_get / environ_sizes_get |
✅ | |
fd_advise |
✅ | Validates the advice; otherwise a no-op |
fd_allocate |
✅ | Grows the file to offset + len, never shrinks |
fd_close |
✅ | Preopens are closable |
fd_datasync / fd_sync |
✅ | No-op success (memory is always "synced") |
fd_fdstat_get |
✅ | Reports real per-fd flags and rights |
fd_fdstat_set_flags |
✅ | APPEND honored by fd_write |
fd_fdstat_set_rights |
✅ | Rights may only shrink (NOTCAPABLE otherwise) |
fd_filestat_get |
✅ | Stable per-node inodes, real sizes and timestamps |
fd_filestat_set_size |
✅ | Zero-fills growth |
fd_filestat_set_times |
✅ | Validates fstflags combinations |
fd_pread / fd_pwrite |
✅ | Positional; never move the cursor; pwrite ignores APPEND |
fd_prestat_get / fd_prestat_dir_name |
✅ | |
fd_read / fd_write |
✅ | Rights-checked; APPEND writes at end of file |
fd_readdir |
✅ | Cookie-paged with ./.. entries and real inodes |
fd_renumber |
✅ | Destination must be open; source is closed |
fd_seek / fd_tell |
✅ | ISDIR on directories, SPIPE on character devices, INVAL on negative seek |
path_create_directory |
✅ | Single level; parent must exist |
path_filestat_get |
✅ | SYMLINK_FOLLOW honored |
path_filestat_set_times |
✅ | Symlink-aware (lstat-level timestamps) |
path_link |
✅ | Hard links with shared inode and nlink accounting |
path_open |
✅ | Full oflags/fdflags/rights semantics; sandboxed path resolution |
path_readlink |
✅ | Silent truncation to the buffer, no NUL |
path_remove_directory |
✅ | NOTEMPTY on non-empty directories |
path_rename |
✅ | POSIX replace semantics incl. empty-directory targets |
path_symlink |
✅ | Relative targets only; dangling links allowed |
path_unlink_file |
✅ | Removes symlinks without following |
poll_oneoff |
✅ | Clock subscriptions block the thread (Atomics.wait, busy-wait fallback); fd subscriptions report ready immediately by default, or genuine readiness via SharedInputChannel/fdReadiness |
proc_exit |
✅ | |
proc_raise |
✅ | Exits with 128 + signal |
random_get |
✅ | |
sched_yield |
✅ | No-op success on a single-threaded host |
sock_shutdown |
✅ | Error reporting only (BADF / NOTSOCK) |
sock_accept / sock_recv / sock_send |
❌ | Deliberately absent; superseded by preview2 wasi:sockets |
Path resolution is sandboxed per directory fd: ./../// normalize,
.. cannot escape the fd, absolute paths and absolute symlink targets are
rejected (PERM), intermediate symlinks always expand, and the final
symlink expands only with LOOKUPFLAGS_SYMLINK_FOLLOW (loop budget 32,
then LOOP).
uwasi targets WASI preview1. Four behaviors deliberately go beyond or beside
the letter of the preview1 spec; all are defaults chosen for compatibility on
single-threaded JavaScript hosts, and all guest-visible surface remains the
plain wasi_snapshot_preview1 namespace:
- CPU-time clocks (
clockid2/3) are answered with the monotonic clock. Preview2 dropped these clocks as impractical to implement, and wasi-clocks documents wasi-libc's strategy of emulating them with the monotonic clock — uwasi applies the same sanctioned emulation at the host. (wasmtime instead rejects these clock IDs.) - Without a readiness provider,
poll_oneofffd subscriptions report ready immediately with nominalnbytes(1 for reads, 65536 for writes) rather than actual availability. WireusePoll({ fdReadiness })(e.g. viaSharedInputChannel) for genuine readiness. Preview2 removed byte counts from poll results entirely; preview3 removed readiness polling. poll_oneoffreturnsENOTSUPfor waits that can never complete (not-ready fds with no way to wait and no clock deadline) instead of blocking forever on the only thread. Preview1 does not define this failure mode; preview3's completion-based async dissolves the problem.proc_raiseterminates with exit code128 + signalfor every signal. There is no signal machinery to deliver to; modern wasi-libc no longer callsproc_raise, and preview2/preview3 removed signals.
Host-side APIs beyond the preview1 surface (usePoll's sleep/
fdReadiness options, SharedInputChannel) are embedder configuration,
invisible to guests. They intentionally mirror preview2 shapes — a
WASIFdReadiness is a pollable, a SharedInputChannel is an
input-stream producer — so a future preview2 host layer can reuse them.