swiftwasm/uwasi

Micro modularized WASI runtime for JavaScript

TypeScript

60

98 commits

updated Sep 7, 2026

See the code

README

npm version .github/workflows/test.yml

μWASI

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.

Features

Installation

npm install uwasi

Example

With all system calls enabled

import { 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()

With no system calls enabled

import { WASI, useAll } from "uwasi";

const wasi = new WASI({
    features: [],
});

With environ, args, clock, proc, and random enabled

import { WASI, useArgs, useClock } from "uwasi";

const wasi = new WASI({
    args: ["./a.out", "hello", "world"],
    features: [useEnviron(), useArgs(), useClock(), useProc(), useRandom()],
});

With fd (file descriptor) enabled only for stdio

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),
    })],
});

With poll_oneoff and sched_yield enabled

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().

Genuine stdin readiness with SharedInputChannel

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 file

Atomics.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.

Implementation Status

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.

SyscallStatusNotes
args_get / args_sizes_get
clock_res_get / clock_time_getCPU-time clocks are approximated by the monotonic clock
environ_get / environ_sizes_get
fd_adviseValidates the advice; otherwise a no-op
fd_allocateGrows the file to offset + len, never shrinks
fd_closePreopens are closable
fd_datasync / fd_syncNo-op success (memory is always "synced")
fd_fdstat_getReports real per-fd flags and rights
fd_fdstat_set_flagsAPPEND honored by fd_write
fd_fdstat_set_rightsRights may only shrink (NOTCAPABLE otherwise)
fd_filestat_getStable per-node inodes, real sizes and timestamps
fd_filestat_set_sizeZero-fills growth
fd_filestat_set_timesValidates fstflags combinations
fd_pread / fd_pwritePositional; never move the cursor; pwrite ignores APPEND
fd_prestat_get / fd_prestat_dir_name
fd_read / fd_writeRights-checked; APPEND writes at end of file
fd_readdirCookie-paged with ./.. entries and real inodes
fd_renumberDestination must be open; source is closed
fd_seek / fd_tellISDIR on directories, SPIPE on character devices, INVAL on negative seek
path_create_directorySingle level; parent must exist
path_filestat_getSYMLINK_FOLLOW honored
path_filestat_set_timesSymlink-aware (lstat-level timestamps)
path_linkHard links with shared inode and nlink accounting
path_openFull oflags/fdflags/rights semantics; sandboxed path resolution
path_readlinkSilent truncation to the buffer, no NUL
path_remove_directoryNOTEMPTY on non-empty directories
path_renamePOSIX replace semantics incl. empty-directory targets
path_symlinkRelative targets only; dangling links allowed
path_unlink_fileRemoves symlinks without following
poll_oneoffClock 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_raiseExits with 128 + signal
random_get
sched_yieldNo-op success on a single-threaded host
sock_shutdownError reporting only (BADF / NOTSOCK)
sock_accept / sock_recv / sock_sendDeliberately 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).

Spec conformance notes

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 (clockid 2/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_oneoff fd subscriptions report ready immediately with nominal nbytes (1 for reads, 65536 for writes) rather than actual availability. Wire usePoll({ fdReadiness }) (e.g. via SharedInputChannel) for genuine readiness. Preview2 removed byte counts from poll results entirely; preview3 removed readiness polling.
  • poll_oneoff returns ENOTSUP for 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_raise terminates with exit code 128 + signal for every signal. There is no signal machinery to deliver to; modern wasi-libc no longer calls proc_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.

Releasing

Run Actions > Release > Run workflow from main with a new stable version, such as 1.5.0 (no v prefix). CI updates and tests both manifests, commits the version bump, pushes the commit and tag, then publishes to npm. Existing tags are rejected. If publishing fails after the tag is pushed, publish from that tag separately rather than rerunning release creation.

Contributors

kateinoigakukun

69 commits

scybot-tech

10 commits

scottmarchant

10 commits

andrewmd5

4 commits

swiftwasm/uwasi

Micro modularized WASI runtime for JavaScript

TypeScript

60

98 commits

updated Sep 7, 2026

See the code

README

npm version .github/workflows/test.yml

μWASI

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.

Features

Installation

npm install uwasi

Example

With all system calls enabled

import { 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()

With no system calls enabled

import { WASI, useAll } from "uwasi";

const wasi = new WASI({
    features: [],
});

With environ, args, clock, proc, and random enabled

import { WASI, useArgs, useClock } from "uwasi";

const wasi = new WASI({
    args: ["./a.out", "hello", "world"],
    features: [useEnviron(), useArgs(), useClock(), useProc(), useRandom()],
});

With fd (file descriptor) enabled only for stdio

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),
    })],
});

With poll_oneoff and sched_yield enabled

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().

Genuine stdin readiness with SharedInputChannel

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 file

Atomics.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.

Implementation Status

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.

SyscallStatusNotes
args_get / args_sizes_get
clock_res_get / clock_time_getCPU-time clocks are approximated by the monotonic clock
environ_get / environ_sizes_get
fd_adviseValidates the advice; otherwise a no-op
fd_allocateGrows the file to offset + len, never shrinks
fd_closePreopens are closable
fd_datasync / fd_syncNo-op success (memory is always "synced")
fd_fdstat_getReports real per-fd flags and rights
fd_fdstat_set_flagsAPPEND honored by fd_write
fd_fdstat_set_rightsRights may only shrink (NOTCAPABLE otherwise)
fd_filestat_getStable per-node inodes, real sizes and timestamps
fd_filestat_set_sizeZero-fills growth
fd_filestat_set_timesValidates fstflags combinations
fd_pread / fd_pwritePositional; never move the cursor; pwrite ignores APPEND
fd_prestat_get / fd_prestat_dir_name
fd_read / fd_writeRights-checked; APPEND writes at end of file
fd_readdirCookie-paged with ./.. entries and real inodes
fd_renumberDestination must be open; source is closed
fd_seek / fd_tellISDIR on directories, SPIPE on character devices, INVAL on negative seek
path_create_directorySingle level; parent must exist
path_filestat_getSYMLINK_FOLLOW honored
path_filestat_set_timesSymlink-aware (lstat-level timestamps)
path_linkHard links with shared inode and nlink accounting
path_openFull oflags/fdflags/rights semantics; sandboxed path resolution
path_readlinkSilent truncation to the buffer, no NUL
path_remove_directoryNOTEMPTY on non-empty directories
path_renamePOSIX replace semantics incl. empty-directory targets
path_symlinkRelative targets only; dangling links allowed
path_unlink_fileRemoves symlinks without following
poll_oneoffClock 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_raiseExits with 128 + signal
random_get
sched_yieldNo-op success on a single-threaded host
sock_shutdownError reporting only (BADF / NOTSOCK)
sock_accept / sock_recv / sock_sendDeliberately 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).

Spec conformance notes

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 (clockid 2/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_oneoff fd subscriptions report ready immediately with nominal nbytes (1 for reads, 65536 for writes) rather than actual availability. Wire usePoll({ fdReadiness }) (e.g. via SharedInputChannel) for genuine readiness. Preview2 removed byte counts from poll results entirely; preview3 removed readiness polling.
  • poll_oneoff returns ENOTSUP for 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_raise terminates with exit code 128 + signal for every signal. There is no signal machinery to deliver to; modern wasi-libc no longer calls proc_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.

Releasing

Run Actions > Release > Run workflow from main with a new stable version, such as 1.5.0 (no v prefix). CI updates and tests both manifests, commits the version bump, pushes the commit and tag, then publishes to npm. Existing tags are rejected. If publishing fails after the tag is pushed, publish from that tag separately rather than rerunning release creation.

Contributors

kateinoigakukun

69 commits

scybot-tech

10 commits

scottmarchant

10 commits

andrewmd5

4 commits

Languages

TypeScript

64.0%

JavaScript

36.0%