Elixir bindings to Wasmer runtime

README.md

Wasmer #

WebAssembly execution from Elixir, powered by Wasmer. This binding provides a small public API for compiling WAT or Wasm binary modules, instantiating modules without imports, looking up exported functions, and calling them with tagged WebAssembly numeric values.

Installation #

Add wasmer to the dependencies in your Mix project:

def deps do
  [
    {:wasmer, "~> 0.1.0"}
  ]
end

This package builds its Rustler NIF from source during Mix compilation. The build host must have:

  • Cargo, supplied with Rust
  • a stable Rust toolchain
  • a C or C++ toolchain supported by Wasmer on the target platform

After installing the prerequisites, fetch and compile the dependency with mix deps.get and mix compile.

Quick start #

The complete execution sequence is:

wat = ~S"""
(module
  (func (export "add_one") (param i32) (result i32)
    local.get 0
    i32.const 1
    i32.add))
"""

{:ok, store} = Wasmer.Store.new()
{:ok, module} = Wasmer.Module.new(store, wat)
{:ok, instance} = Wasmer.Instance.new(module)
{:ok, add_one} = Wasmer.Instance.function(instance, "add_one")
Wasmer.Function.call(add_one, [{:i32, 41}])
# {:ok, [{:i32, 42}]}

Wasmer.Store.new/0 creates an isolated store. Compile source with Wasmer.Module.new/2, instantiate it with Wasmer.Instance.new/1, retrieve a named function with Wasmer.Instance.function/2, and invoke it with Wasmer.Function.call/2.

Module input and imports #

Wasmer.Module.new/2 accepts either WAT text or a Wasm binary as its second argument. It performs checked compilation; it does not interpret the argument as a file path. An empty module can be compiled from the Wasm binary header:

empty_wasm = <<0, 97, 115, 109, 1, 0, 0, 0>>
{:ok, store} = Wasmer.Store.new()
{:ok, module} = Wasmer.Module.new(store, empty_wasm)
{:ok, _instance} = Wasmer.Instance.new(module)

Instances are created with an empty import collection. There is no import map or Elixir host-callback API in this release. A module that requires an import therefore returns {:error, {:instantiate, message}}.

Values #

Function arguments and results use tagged tuples:

  • {:i32, integer} — a signed 32-bit integer from -2_147_483_648 through 2_147_483_647
  • {:i64, integer} — a signed 64-bit integer from -9_223_372_036_854_775_808 through 9_223_372_036_854_775_807
  • {:f32, float} — a WebAssembly 32-bit floating-point value
  • {:f64, float} — a WebAssembly 64-bit floating-point value
  • {:v128, binary} — exactly sixteen bytes in WebAssembly little-endian lane order

The tags must be the atoms shown above. Integers outside the signed range, non-float floating-point values, malformed tuples, :v128 binaries that are not sixteen bytes, and unsupported reference values are rejected with {:error, :invalid_value}.

Calls return results in WebAssembly result order:

{:ok, [{:i32, 3}, {:i64, 4}, {:f32, 5.0}, {:f64, 6.0}]}

Binary memory #

For binary data, a module must define and export its own linear memory. Instance creation uses Imports::new(). The binding does not import memory.

Use Wasmer.Instance.memory/2 to get a named memory export. The offset and length are byte values. Write the binary at an explicit offset. Then pass the offset and byte size through existing {:i32, value} function arguments. The guest owns allocation and memory growth. The binding does not select a default memory, allocate guest space, or expand binary function arguments.

wat = ~S"""
(module
  (memory (export "memory") 1)
  (data (i32.const 32) "guest")
  (func (export "load_byte") (param i32) (result i32)
    local.get 0
    i32.load8_u))
"""

{:ok, store} = Wasmer.Store.new()
{:ok, module} = Wasmer.Module.new(store, wat)
{:ok, instance} = Wasmer.Instance.new(module)
{:ok, memory} = Wasmer.Instance.memory(instance, "memory")
:ok = Wasmer.Memory.write(memory, 0, <<0, 255, 65>>)
{:ok, load_byte} = Wasmer.Instance.function(instance, "load_byte")
{:ok, [{:i32, 255}]} = Wasmer.Function.call(load_byte, [{:i32, 1}])
{:ok, "guest"} = Wasmer.Memory.read(memory, 32, 5)

Wasmer.Memory.read/3 and Wasmer.Memory.write/3 check every range. A read or write outside the exported memory returns {:error, :out_of_bounds}.

Results and errors #

Successful creators return {:ok, handle} and successful calls return {:ok, values}. Public operations may return exactly one of these error tuples:

{:error, {:compile, diagnostic}}
{:error, {:instantiate, diagnostic}}
{:error, :not_found}
{:error, :not_a_function}
{:error, {:runtime, diagnostic}}
{:error, :invalid_value}
{:error, :worker_unavailable}
{:error, :not_a_memory}
{:error, :out_of_bounds}
{:error, :allocation_failed}

:not_found means that a named export does not exist. :not_a_function means that the named export exists but is not a function. :not_a_memory means that the named export exists but is not a linear memory. :allocation_failed means that the NIF cannot allocate a required cross-thread byte buffer or output binary. Compilation, instantiation, and guest runtime failures carry diagnostic binaries. Those diagnostics are informational text, not stable Wasmer message strings.

Execution and concurrency #

Each store owns one dedicated Wasmer worker. Calls for modules, instances, and functions belonging to the same store are serialized on that worker, while separate stores are isolated from one another. This preserves Wasmer's store thread-affinity requirements.

Native calls use Rustler's dirty-CPU scheduler. Guest execution that does not return can occupy the dedicated worker indefinitely, and this API provides no timeout or interruption mechanism. Do not call guest functions that may run forever.