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 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 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 which is where the canonical WAT to WASM compiler is apparently doesn't run on windows. There is a WASM port via WAPM 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 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:
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.
<!-- index.html -->
<html>
<body>
<script src="app.js" type="module"></script>
</body>
</html>
//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.
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:
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:
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
.
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:
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:
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
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:
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.
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 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.
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:
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.
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
Top comments (5)
Hey, I like what you did here and great write-up, but the github is a dead link. I am working on a similar, but more complete browser WASI fs setup. I can't make heads-or-tails of how to connect to a virtual FS (I was using BrowserFS.) I'd prefer to not have to preload all the files, and just random-access grab them from a zip file. Did you ever do any more with this?
I have everything working except
fd_read
, whcih seems to get in a loop:I verified
chunk
is set correctly, andtotalBytes
increments correctly. I also tried reading the whole file. Here it is, in context. What am I doing wrong?From the current github code only thing that jumped out was line 288. The iovs basically describe multiple buffers of potentially varying lengths. So where you start reading in the file is not (i * length), you need to accumulate the lengths of all the previous iovs buffers to know where to start reading for the current io vector.
Also the github link seems to work for me.
This is good advice. I sort of stepped away from WASI in my game-engine (exposing simpler log and readFile functions) but I'd like to go back to it, so I will follow your advice. Thanks!
The github is still a dead link for me, I mean the one at the bottom of the article:
github.com/ndesmic/wasm-cross/blob...
Maybe the repo is private?
You're right it's been private this whole time 😅