2025-06-21
The only thing I really like doing on the computer is moving values around in memory. Because of this, I've never bothered learning anything about JavaScript.
But Rust + wasm-pack
break now and then, so I figured it would be worth getting
to the bottom of things. A later post will figure out how to import Rust types
into JS, which is the real nice part about wasm-pack. For now, I just want to
get a bare-bones Rust program running on the web without any tooling in
between.
It didn't end up being too hard. Passing hello, world! takes eight lines of JavaScript and four lines of rust. The resulting library is 373 bytes which (lol) grows to 680 bytes when it gets gzipped over the wire.
Here's the js:
WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/release/wasmaudio.wasm")).then(
(wasm_obj) => {
let hello_string_ptr = wasm_obj.instance.exports.hello_world();
const wasm_memory = new Uint8Array(wasm_obj.instance.exports.memory.buffer, hello_string_ptr, 13);
const decoder = new TextDecoder('utf-8');
alert(decoder.decode(wasm_memory))
},
);
And here's the rust:
#[unsafe(no_mangle)]
extern "C" fn hello_world() -> *const u8 {
"hello, world!".as_ptr()
}
I'll explain how all this works and how to expand it.
With rust, this is easy.
Add
[lib]
crate-type = ["cdylib"]
To your Cargo.toml
, throw the hello_world
function into src/lib.rs
, and
you've made a little library. Compile with cargo build --target wasm32-unknown-unknown
.
no_mangle
ensures that our wasm module exports the same function names
we see in rust. Returning a pointer means that JS can call the function
and get an offset into the running module's memory.
extern
tells the compiler to ensure compatibility with other ABIs. The "C"
ABI is the default, so it's not actually necessary to include here. The "C" ABI
is "whatever default your C compiler supports." - I'm not sure if there are any
defaults that would be incompatible with wasm, but watch out. It happened to
clang at least once. Anyways.
If the other stuff seems weird, check out MDN's WebAssembly Concepts to learn more about WebAssembly FFI.
The main workhorse is the
instantiateStreaming
function. It's pretty frictionless, and gives you an immediate handle to your
wasm module.
It can be called with 1-3 parameters (the first the wasm source, the second
importObject
providing import handles and the third compileOptions
). The
source is all we need for now.
index.js
should have something like
const wasm_obj = await WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/debug/wasmtest.wasm"));
or
WebAssembly.instantiateStreaming(fetch("target/wasm32-unknown-unknown/debug/wasmtest.wasm")).then(
(wasm_obj) => { },
);
wasm_obj
is a ResultObject
with two fields: module
and instance
.
Instance is the handier of the two, as it provides a JS handle to the exported wasm
functions.
These exports can be viewed with wasm2wat <output>.wasm
:
(module $wasmaudio.wasm
(type (;0;) (func (result i32)))
(func $hello_world (type 0) (result i32)
i32.const 1048576)
(table (;0;) 1 1 funcref)
(memory (;0;) 17)
(global $__stack_pointer (mut i32) (i32.const 1048576))
(global (;1;) i32 (i32.const 1048589))
(global (;2;) i32 (i32.const 1048592))
(export "memory" (memory 0))
(export "hello_world" (func $hello_world))
(export "__data_end" (global 1))
(export "__heap_base" (global 2))
(data $.rodata (i32.const 1048576) "hello, world!"))
So our wasm_obj.instance.exports
will give us access to our wasm's memory
buffer, the hello_world
function, and some pointers to segment boundaries.
Add
alert(wasm_obj.instance.exports.hello_world());
to index.js
to call our hello_world function.
Additionally, create and serve an index.html
with
<html>
<head>
<script type="module" src="./index.js"> </script>
</head>
</html>
I usually use npx serve
, as http-server
doesn't support MIME types or
whatever, which is required to fetch
anything.
Visiting the page should serve an alert with an integer in it. Hooray! This
integer is a pointer to a spot in wasm's memory (it should match your module's
(data $.rodata (i32.const
1048576
) "hello, world!"))
.
Parsing this into a string is fun, because we get to use pointers in JavaScript.
The two exports we care about are our hello_world
function and the memory
buffer. hello_world
returns a pointer to somewhere in the module's memory
buffer.
We can create a new view into this buffer with
TypedArray
. This is cheap
(zero copy), and inspecting it reveals that it's just an
array of bytes.
We can use our hello_string_ptr
as the start of a small view into the
module's memory or we could create a huge view with buffer.byteLength
and use
hello_string_ptr
as an offset for slicing. Here's the former:
const wasm_memory = new Uint8Array(wasm_obj.instance.exports.memory.buffer, hello_string_ptr, 13);
Now all we need to do is decode this view into a string. The simplest is with a TextDecoder
:
const decoder = new TextDecoder('utf-8');
alert(decoder.decode(wasm_memory))
This alerts "hello, world!" to the screen!
release (no_mangle
):
(module $wasmaudio.wasm
(type (;0;) (func (result i32)))
(func $hello_world (type 0) (result i32)
i32.const 1048576)
(table (;0;) 1 1 funcref)
(memory (;0;) 17)
(global $__stack_pointer (mut i32) (i32.const 1048576))
(global (;1;) i32 (i32.const 1048589))
(global (;2;) i32 (i32.const 1048592))
(export "memory" (memory 0))
(export "hello_world" (func $hello_world))
(export "__data_end" (global 1))
(export "__heap_base" (global 2))
(data $.rodata (i32.const 1048576) "hello, world!"))
release (mangled):
(module $wasmaudio.wasm
(table (;0;) 1 1 funcref)
(memory (;0;) 16)
(global $__stack_pointer (mut i32) (i32.const 1048576))
(global (;1;) i32 (i32.const 1048576))
(global (;2;) i32 (i32.const 1048576))
(export "memory" (memory 0))
(export "__data_end" (global 1))
(export "__heap_base" (global 2)))
I haven't tried this yet.