Building a minimal WASI polyfill for browsers
Tags: webassembly, wasi, webdev
Published: 2023-02-28 00:16:50

While researching some WASM/WASI stuff I was trying to get my app to run in a variety of environments and with various programming language bindings.  While most popular languages have bindings through Wasmtime and support WASI out of the box I was completely shocked that there doesn't seem to be a standard working polyfill for browsers.

If you search "WASI polyfill" on Google the primary result is this page: https://wasi.dev/polyfill/ with nothing on it.  Apparently the polyfill was removed and there's no useful information here.  [If you peek back into the commit history](https://github.com/bytecodealliance/wasi.dev/blob/3f72f73d4e5002518b4bb5ec54edfdafb28ceadd/polyfill/polyfill.js) on Github there was a polyfill that seems to forward requests to another WASM app, weird.

The page also suggests https://github.com/bjorn3/browser_wasi_shim which I tried and couldn't get to work and it comes with no real documentation.  Lastly was [wasmer-js](https://github.com/wasmerio/wasmer-js) which I had a number of issues with the module output when used from unpkg and skypack, and it also didn't work when I ran it.

So, I guess it's time to roll up the sleeves and understand what's actually going on under the hood so we can make a polyfill.  This actually turns out to be somewhat hard as well.  Despite being a spec, there is not much authoritative documentation on the actual signatures WASI functions use.  You can find that a function takes 4 parameters as i32s, but what those mean is anyone's guess.  Hopefully this can serve as an extra resource to help you along.

Note that I'm only implementing a few basic calls, there's a lot of them and many are for specific things I don't need.  Specifically we'll look at:

- fd_write (file and stdout)
- fd_read (file and stdin)
- environ_get (get environment variables)
- environ_sizes_get (gets the size of environment variables)

## WASI basics

This topic is a little more intermediate and getting into the details of WASM is out of scope for this post.  WASM modules in their vanilla state cannot do any sort of IO.  This helps keep them secure and helps keep the WASM spec small.  In order to do IO things like write to a standard output the host environment has to pass in functions for the WASM module to use.  The problem here is that you need to create some "glue code."  Each language has a different convention, how are strings encoded?  What order are the params etc.  Implementers who want to use your module need to know how to make it work, meaning they get to write that glue code and WASM's pointers and memory paradigm is quite complicated making this brittle and unfun.  Now imagine doing that for every module in every platform you wanted to run it on.

WASI standardizes some functions so that they take the same signature and behave the same way.  This means that as long as you provide WASI interfaces, a WASI compatible WASM module can do normal IO.  This was largely modeled after POSIX and many of those paradigms persist, after all WASM was designed to port applications that have already been written.  WASI is not the same as POSIX though, certain things just didn't make sense in the contexts where WASM runs.

## A basic app

Writing a WASM app is a little involved.  You can use a number of languages but you do need to know a bit about their compiler toolchains.  You can also hand-craft `.wat`, a LISP-looking text format that's pretty much 1:1 with the binary. I found this even harder not just because of the low level language but because [WBAT](https://github.com/WebAssembly/wabt) which is where the canonical WAT to WASM compiler is apparently doesn't run on windows. There is a WASM port via [WAPM](https://wapm.io/wasmer/wabt) but since WAT is a big topic to itself let's set it aside.

Instead I chose Rust which is perhaps the most popular and most battle tested way to write WASM today.  [Install rust](https://rustup.rs/) and then run `cargo install wasm` to add the wasm toolchain.  Create a new binary with `cargo new --bin {name}`.  This will generate a new folder with `src/main.rs`.  All you need to get started is:

```rust
pub fn main() {
    println!("Hello World");
}
```
This does exactly what you think, but it specifically writes to standard out which means to compile to WASM we need WASI.  You can compile it with `cargo wasi build --release`.  The `--release` flag makes things slightly smaller but due to exception handling, strings and whatnot the WASM file will likely be a lot larger than you might expect for such a small program.  Much more than a minimal `.wat` example you might find.

The `.wasm` file gets output into the `target/wasm32-wasi/release` folder.

## Running the WASM code in the browser

So we have a `.wasm` file, we run this in the browser.

```html
<!-- index.html -->
<html>
	<body>
		<script src="app.js" type="module"></script>
	</body>
</html>
```
```js
//app.js
const { instance } = await WebAssembly.instantiateStreaming(fetch("../target/wasm32-wasi/release/main.wasm"), imports);
instance.exports._start();
```
This is how you can run it ... almost.  First off, there are about half a dozen ways to instantiate a WASM module you'll come across, the above `instantiateStreaming` is the best way (at least until we can directly import `.wasm` modules) so if it looks different than some other tutorial that's why.  `instantiateStreaming` takes two things a fetch promise and an object with our "imports".  The imports are the functions we pass to the WASM module so it can call out to our code.  This is where the WASI goes.

Also, WASI programs use the paradigm of the entrypoint being `_start` so we call that on the modules exports.

If you run this you'll get some errors like `Uncaught LinkError: WebAssembly.instantiate(): Import #2 module="wasi_snapshot_preview1" function="environ_get" error: function import requires a callable`.  This means that it couldn't find that function in the imports.  We can tell it's WASI because at the time of writing `wasi_snapshot_preview1` is the module name used by WASI and at least in this case it's missing function `environ_get`.

Let's fix these errors.  We can start by stubbing it all out.

```js
const imports = { 
	"wasi_snapshot_preview1": {
		environ_sizes_get(){ return 0; },
		environ_get() { return 0; },
		proc_exit() { return 0; },
		fd_write() { return 0; },
	}
}
```
This should stub out all the used functions.  You can fix the errors one-by-one by adding stubs like these.  While I found it's not strictly required here, returning `0` on success is proper.

This won't run though, you'll get an `unreachable` error.  This is an exception that comes from the code rust generated because we didn't actually do what was expected of us when it made those calls.  Specifically, the error happens on `fd_write` (if you are brave enough to debug through the WASM file in the debugger you'll find this call).

## Printing to stdout

The signature of `fd_write` is `function fd_write(fd: int32, iovs: int32, iovsLength: int32, bytesWrittenPtr: int32)` using the general argument naming conventions I found.  How this works is not obvious at all.  

Firstly we have `fd` which is the "File Descriptor", basically a numeric id to a file on a file system.  In the POSIX world standard in, standard out and standard error are considered files that can be written to and they have ids 0, 1 and 2 respectively.  So any WASI app wanting to print something to standard IO will use this convention.  Therefore for value `1` we can simply `console.log` the data (or in a UI dump it out to a DOM element).

The second argument is a cryptic `iovs`.  This I think stands for "in out vectors".  This value is a pointer to a list of 32-bit pointer/length values, eg each in out vector is 8 bytes in total.  The 3rd partmeter `iovsLength` tell you how many of these structures to read.  I'm not actually sure why they do it like this. Perhaps because it may not fit in one chunk of memory?

In our "hello world" case there seems to be exactly 1 in out vector.  The first 4 bytes are the starting address of "hello world" and we read 12 bytes total.

The last parameter is a pointer to "bytes written".  The function expects you to write how many bytes you wrote to this location.  I think this is for streaming data, like in the case you can't write as many as it asks for it can try again with the remaining data.

The final function I came up with:

```js
fd_write(fd, iovsPtr, iovsLength, bytesWrittenPtr){
	const iovs = new Uint32Array(instance.exports.memory.buffer, iovsPtr, iovsLength * 2);
	if(fd === 1) { //stdout
		let text = "";
		let totalBytesWritten = 0;
		const decoder = new TextDecoder();
		for(let i =0; i < iovsLength * 2; i += 2){
			const offset = iovs[i];
			const length = iovs[i+1];
			const textChunk = decoder.decode(new Int8Array(instance.exports.memory.buffer, offset, length));
			text += textChunk;
			totalBytesWritten += length;
		}
		const dataView = new DataView(instance.exports.memory.buffer);
		dataView.setInt32(bytesWrittenPtr, totalBytesWritten, true);
		console.log(text);
	}
	return 0;
},
```
This will cause standard out to be forwarded to `console.log` but will ignore all file writes including standard error.  Still with the signature decoded it's much easier to see how to implement those.

If all goes well you should see "Hello World" in the console and no other errors.

## Reading from stdin

Let's update `main.rs` and recompile:

```rust
use std::io;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;
    println!("Hello {}", buffer);
    Ok(())
}
```
We need to import `std::io` to read from standard in and we place it in a string buffer.  The extra syntax for mutable variables and why the signature changed to `io::Result<()>` is a little beyond the scope of this post but this is the most minimal way to read from standard in.

Reading from stdin works the opposite direction, we need to implement `fd_read`.  The signature looks like `function fd_read(fd: int32, iovs: int32, iovsLength: int32, bytesReadPtr: int32)`.  It's the same as `fd_write` except the operations will be flipped.  We write to `iovs`.

```js
fd_read(fd, iovsPtr, iovsLength, bytesReadPtr){
	const memory = new Uint8Array(instance.exports.memory.buffer);
	const iovs = new Uint32Array(instance.exports.memory.buffer, iovsPtr, iovsLength * 2);
	let totalBytesRead = 0;
	if(fd === 0) {//stdin
		for(let i = 0; i < iovsLength * 2; i += 2){
			const offset = iovs[i];
			const length = iovs[i+1];
			const chunk = stdin.slice(0, length);
			stdin = stdin.slice(length);
			memory.set(chunk, offset);
			totalBytesRead += chunk.byteLength;
			if(stdin.length === 0) break;
		}
		const dataView = new DataView(instance.exports.memory.buffer);
		dataView.setInt32(bytesReadPtr, totalBytesRead, true);
	}
	return 0;
}
```
If `fd` is 0 then we're reading from standard in.  We're going to write what we have at those pointers.  This is actually a bit annoying since we need to keep track of how much we wrote.  Basically we fill up the buffer and when it's full we move onto the next one.  We loop over iovs pointers (remember they are 2 32-bit values hence why we iterate up to length * 2).  Then we slice from 0 to the total length of the buffer from the `stdin` value (`stdin` is a `UInt8Array`). If the buffer was larger than the remaining `stdin` it will just slice to end.  Then we can set that value to the offset given in the iov.  `memory` looks at the buffer as an array of `UInt8`s so that we can write a `UInt8Array` to the pointer offset.  We update the bytes read and continue only if there's more in the `stdin` buffer.  Finally we write the `totalBytesRead`.

I had hoped to implement this as a stream since that essentially what standard in is but it doesn't seem like we can do that.  When you read from a stream the read is async and `fd_read` is sync so I had to leave it as a `UInt8Array`.  It looks like this:

```js
const textEncoder = new TextEncoder();
let stdin = textEncoder.encode("stdin");
```
This unfortunately makes things very awkward, at least from a javascript app perspective since no there's no standard in on a web browser.  Maybe we can clean this up if we built a terminal component.

If everything goes well you should see `Hello stdin` in the console.

## Reading environment variables

We can update the rust code again to use environment variables:

```rust
use std::io;
use std::env;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;

    let foo_var = env::var("FOO").unwrap_or("[foo]".to_string());

    println!("Hello {} {}", buffer, foo_var);
    Ok(())
}
```
The `unwrap_or` stuff is how rust handles errors if you don't speak it.  We default the value to `[foo]` if it's not found which is helpful for debugging.

Reading the environment variables is two parts.  I don't really understand why it's two but that's how it is.  `environ_get` is the main one, the one that actually gets the environment data.  `environ_sizes_get` gets the length of the buffer that the environment data was written to.  We'll start with that one because it's easier.

`environ_sizes_get` has signature `function environ_sizes_get(environCountPtr, environBufferSizePtr)`.  All we need to due to write the number of environment variables and the total length of their data to those pointers.  The first part is easy, we just count the environment variables and write that 32-bit value to the assigned place in memory.  

The actual data is cryptic.  It turns out the way to encode the environment data is a set of strings with the format `KEY=VALUE` followed by a null byte `\0`  So, we can map the strings, add `\0` and then run them through the TextEncoder (at least I think so, this worked but I saw another implementation that joined the strings with `\0` meaning there's one less byte as the final value has no trailing `\0`).  The number of bytes produced is the size.

So we can expand on the above and create an intermediate format that's a list of strings encoded as `UInt8Array`s

```js
const env = {
	FOO: "FOO",
	BAR: "BAR"
};

const envStrings = Object.entries(env).map(([k, v]) => `${k}=${v}`);
const envEncodedStrings = envStrings.map(s => textEncoder.encode(s + "\0"))
```

So to calculate the sizes, I got this:

```js
environ_sizes_get(environCountPtr, environBufferSizePtr){
	const envByteLength = envEncodedStrings.map(s => s.byteLength).reduce((sum, val) => sum + val);
	const countPointerBuffer = new Uint32Array(instance.exports.memory.buffer, environCountPtr, 1);
	const sizePointerBuffer = new Uint32Array(instance.exports.memory.buffer, environBufferSizePtr, 1);
	countPointerBuffer[0] = envEncodedStrings.length;
	sizePointerBuffer[0] = envByteLength;
	return 0;
},
```
I chose to write into buffers of length 1 here, but you could also use a `DataView` instead.

The count and size must match the implementation in `environ_get` or bad things could happen.  `environ_get` has signature `function environ_get(environPtr, environBufferPtr)`.  The first pointer points to a location with a list of offsets.  That is, if you had 3 environment variables you'd write 3 32-bit values.  The `environBufferPtr` is where the actual string data we computed above is stored and the pointers we calculated point to the beginning of each string.

```js
environ_get(environPtr, environBufferPtr){
	const envByteLength = envEncodedStrings.map(s => s.byteLength).reduce((sum, val) => sum + val);
	const environsPointerBuffer = new Uint32Array(instance.exports.memory.buffer, environPtr, envEncodedStrings.length);
	const environsBuffer = new Uint8Array(instance.exports.memory.buffer, environBufferPtr, envByteLength)
	
	let pointerOffset = 0;
	for(let i = 0; i < envEncodedStrings.length; i++){
		const currentPointer = environBufferPtr + pointerOffset;
		environsPointerBuffer[i] = currentPointer;
		environsBuffer.set(envEncodedStrings[i], pointerOffset)  
		pointerOffset += envEncodedStrings[i].byteLength;
	}
	return 0;
},
```
I use the same technique of creating a buffer out of TypedArray views but this time it makes a little more sense because there is more than one element.  A word of caution as I got tripped up here.  The signature `UInt32Array(buffer, offset, length)` can be confusing even if you read the MDN page.  The `offset` is in **bytes**, that is, it doesn't have to be a multiple of 4.  The length is the **number of elements** not the length in bytes.

Any way in the console hopefully you get "Hello stdin FOO".

## proc_exit

As far as I can tell this is just a callback that the program exited.  It was not actually called when I ran the module so I'm not sure what distinguishes it but it's still required to have because the WASM code references it.  You can just make the function `console.log` a message. 

With this you should have enough to start implementing patterns like [WAGI](https://github.com/deislabs/wagi) which can use the existing WASI primitives to write language agnostic request handlers.

## Bonus args_get and args_sizes_get

Since these are pretty much identical to environs I thought I'd throw them in there since it's very common to need these for CLI apps.

```js
args_sizes_get(argCountPtr, argBufferSizePtr) {
	const argByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
	const countPointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argCountPtr, 1);
	const sizePointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argBufferSizePtr, 1);
	countPointerBuffer[0] = this.#argEncodedStrings.length;
	sizePointerBuffer[0] = argByteLength;
	return 0;
}
args_get(argsPtr, argBufferPtr) {
	const argsByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
	const argsPointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argsPtr, this.#argEncodedStrings.length);
	const argsBuffer = new Uint8Array(this.#instance.exports.memory.buffer, argBufferPtr, argsByteLength)
	let pointerOffset = 0;
	for (let i = 0; i < this.#argEncodedStrings.length; i++) {
		const currentPointer = argBufferPtr + pointerOffset;
		argsPointerBuffer[i] = currentPointer;
		argsBuffer.set(this.#argEncodedStrings[i], pointerOffset)
		pointerOffset += this.#argEncodedStrings[i].byteLength;
	}
	return 0;
}
```
You can modify `main.rs` to try them out: 

```rust
use std::io;
use std::env;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;

    let foo_var = env::var("FOO").unwrap_or("[foo]".to_string());

    let args: Vec<String> = env::args().collect();

    println!("Hello World! stdin: {}, env: {}, args: {}", buffer, foo_var, &args[0]);
    Ok(())
}
```

## Incorporating into a library

Unfortunately the flow for WASM instantiation kinda sucks.  WASI implementations will need access to the instance's memory which creates a nasty circular loop where the instance depends on the WASI implementation because it's required to create one but also that the WASI implementation needs the instance to operate on it.  In order to make a nice class I had to add an additional required setter to supply the instance reference after you have it.

```js
const wasi = new Wasi({
	stdin: "stdio",
	env: {
		FOO: "FOO",
		BAR: "BAR"
	},
	args: ["--my-arg"]
});

const { instance } = await WebAssembly.instantiateStreaming(fetch("../target/wasm32-wasi/release/main.wasm"), {
	"wasi_snapshot_preview1": wasi
});
wasi.instance = instance;
```

I also wanted to have the class use an `exports` object rather than be the import object directly.  This means you can't use private fields though because the exported object cannot use references to them.  Furthermore, when the WASM module calls out to the functions the `this` argument is lost so you need to bind them all.  Really just annoying all around.  I think this is why the Deno implementation has a method to start the instance. It then has a proper instance it can hold a reference to before invoking the `_start` method.  It's just unfortunate it has to be this way.

## Code

You can find the WASI polyfill code here: https://github.com/ndesmic/wasm-cross/blob/v0/browser/wasi.js

## Reference

- https://github.com/caspervonb/deno-wasi
- https://www.wasmtutor.com/webassembly-barebones-wasi
- https://github.com/bytecodealliance/wasmtime/blob/d900a5f6efd78055017e089af4df0d89c18298de/docs/WASI-api.md
- https://github.com/bjorn3/browser_wasi_shim