Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Docs and refactoring #714

Merged
merged 3 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 171 additions & 15 deletions lib/wasmex/components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,169 @@
@moduledoc """
This is the entry point to support for the [WebAssembly Component Model](https://component-model.bytecodealliance.org/).

Support should be considered experimental at this point, with not all types yet supported.
The Component Model is a higher-level way to interact with WebAssembly modules that provides:
- Better type safety through interface types
- Standardized way to define imports and exports using WIT (WebAssembly Interface Types)
- WASI support for system interface capabilities

## Basic Usage

To use a WebAssembly component:

1. Start a component instance:
```elixir
# Using raw bytes
bytes = File.read!("path/to/component.wasm")
{:ok, pid} = Wasmex.Components.start_link(%{bytes: bytes})

# Using a file path
{:ok, pid} = Wasmex.Components.start_link(%{path: "path/to/component.wasm"})

# With WASI support
{:ok, pid} = Wasmex.Components.start_link(%{
path: "path/to/component.wasm",
wasi: %Wasmex.Wasi.WasiP2Options{}
})

# With imports (host functions the component can call)
{:ok, pid} = Wasmex.Components.start_link(%{
bytes: bytes,
imports: %{
"host_function" => {:fn, &MyModule.host_function/1}
}
})
```

2. Call exported functions:
```elixir
{:ok, result} = Wasmex.Components.call_function(pid, "exported_function", ["param1"])
```

## Component Interface Types

The component model supports the following WIT (WebAssembly Interface Type) types:

### Currently Supported Types

- **Primitive Types**
- Integers: `s8`, `s16`, `s32`, `s64`, `u8`, `u16`, `u32`, `u64`
- Floats: `f32`, `f64`
- `bool`
- `string`

- **Compound Types**
- `record` (maps to Elixir maps with atom keys)
```wit
record point { x: u32, y: u32 }
```
```elixir
%{x: 1, y: 2}
```

- `list<T>` (maps to Elixir lists)
```wit
list<u32>
```
```elixir
[1, 2, 3]
```

- `tuple<T1, T2>` (maps to Elixir tuples)
```wit
tuple<u32, string>
```
```elixir
{1, "two"}
```

- `option<T>` (maps to `nil` or the value)
```wit
option<u32>
```
```elixir
nil # or
42
```

### Currently Unsupported Types

The following WIT types are not yet supported:
- `char`
- `variant` (tagged unions)
- `enum`
- `flags`
- `result` types
- Resources

Support should be considered experimental at this point.

## Options

The `start_link/1` function accepts the following options:

* `:bytes` - Raw WebAssembly component bytes (mutually exclusive with `:path`)
* `:path` - Path to a WebAssembly component file (mutually exclusive with `:bytes`)
* `:wasi` - Optional WASI configuration as `Wasmex.Wasi.WasiP2Options` struct for system interface capabilities
* `:imports` - Optional map of host functions that can be called by the WebAssembly component
* Keys are function names as strings
* Values are tuples of `{:fn, function}` where function is the host function to call

Additionally, any standard GenServer options (like `:name`) are supported.

### Examples

```elixir
# With raw bytes
{:ok, pid} = Wasmex.Components.start_link(%{
bytes: File.read!("component.wasm"),
name: MyComponent
})

# With WASI configuration
{:ok, pid} = Wasmex.Components.start_link(%{
path: "component.wasm",
wasi: %Wasmex.Wasi.WasiP2Options{
args: ["arg1", "arg2"],
env: %{"KEY" => "value"},
preopened_dirs: ["/tmp"]
}
})

# With host functions
{:ok, pid} = Wasmex.Components.start_link(%{
path: "component.wasm",
imports: %{
"log" => {:fn, &IO.puts/1},
"add" => {:fn, fn(a, b) -> a + b end}
}
})
```
"""

use GenServer
alias Wasmex.Wasi.WasiP2Options

Check warning on line 145 in lib/wasmex/components.ex

View workflow job for this annotation

GitHub Actions / OTP 25.2 / Elixir 1.15.8

unused alias WasiP2Options

Check warning on line 145 in lib/wasmex/components.ex

View workflow job for this annotation

GitHub Actions / OTP 26.2 / Elixir 1.15.8

unused alias WasiP2Options

def start_link(%{bytes: component_bytes, wasi: %WasiP2Options{} = wasi_options}) do
with {:ok, store} <- Wasmex.Components.Store.new_wasi(wasi_options),
{:ok, component} <- Wasmex.Components.Component.new(store, component_bytes) do
GenServer.start_link(__MODULE__, %{store: store, component: component})
end
end
@doc """
Starts a new WebAssembly component instance.

def start_link(%{bytes: component_bytes}) do
with {:ok, store} <- Wasmex.Components.Store.new(),
{:ok, component} <- Wasmex.Components.Component.new(store, component_bytes) do
GenServer.start_link(__MODULE__, %{store: store, component: component})
end
end
## Options

* `:bytes` - Raw WebAssembly component bytes (mutually exclusive with `:path`)
* `:path` - Path to a WebAssembly component file (mutually exclusive with `:bytes`)
* `:wasi` - Optional WASI configuration as `Wasmex.Wasi.WasiP2Options` struct
* `:imports` - Optional map of host functions that can be called by the component
* Any standard GenServer options (like `:name`)

## Returns

* `{:ok, pid}` on success
* `{:error, reason}` on failure
"""
def start_link(opts) when is_list(opts) or is_map(opts) do
opts = normalize_opts(opts)

def start_link(opts) when is_list(opts) do
with {:ok, store} <- build_store(opts),
component_bytes <- Keyword.get(opts, :bytes),
component_bytes <- get_component_bytes(opts),
imports <- Keyword.get(opts, :imports, %{}),
{:ok, component} <- Wasmex.Components.Component.new(store, component_bytes) do
GenServer.start_link(
Expand All @@ -35,6 +175,22 @@
end
end

defp normalize_opts(opts) when is_map(opts) do
opts
|> Map.to_list()
|> Keyword.new()
end

defp normalize_opts(opts) when is_list(opts), do: opts

defp get_component_bytes(opts) do
cond do
bytes = Keyword.get(opts, :bytes) -> bytes
path = Keyword.get(opts, :path) -> File.read!(path)
true -> raise ArgumentError, "Either :bytes or :path must be provided"
end
end

defp build_store(opts) do
if wasi_options = Keyword.get(opts, :wasi) do
Wasmex.Components.Store.new_wasi(wasi_options)
Expand Down
38 changes: 0 additions & 38 deletions lib/wasmex/components/component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,42 +31,4 @@ defmodule Wasmex.Components.Component do
resource -> {:ok, __wrap_resource__(resource)}
end
end

defmacro __using__(opts) do
macro_imports = Keyword.get(opts, :imports, %{})

genserver_setup =
quote do
use GenServer

def start_link(opts) do
Wasmex.Components.start_link(opts |> Keyword.put(:imports, unquote(macro_imports)))
end

def handle_call(request, from, state) do
Wasmex.Components.handle_call(request, from, state)
end
end

functions =
if wit_path = Keyword.get(opts, :wit) do
wit_contents = File.read!(wit_path)
exported_functions = Wasmex.Native.wit_exported_functions(wit_path, wit_contents)

for {function, arity} <- exported_functions do
arglist = Macro.generate_arguments(arity, __MODULE__)
function_atom = function |> String.replace("-", "_") |> String.to_atom()

quote do
def unquote(function_atom)(pid, unquote_splicing(arglist)) do
Wasmex.Components.call_function(pid, unquote(function), [unquote_splicing(arglist)])
end
end
end
else
[]
end

[genserver_setup, functions]
end
end
133 changes: 133 additions & 0 deletions lib/wasmex/components/component_server.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
defmodule Wasmex.Components.ComponentServer do
@moduledoc """
A GenServer wrapper for WebAssembly components. This module provides a macro to easily
create GenServer-based components with wrapper functions for the exports in the WIT definition.

## Usage

To use this module, you need to:
1. Create a WIT file defining your component's interface
2. Create a module that uses ComponentServer with the path to your WIT file
3. Use the generated functions to interact with your WebAssembly component

## Basic Example

Given a WIT file `greeter.wit` with the following content:

```wit
package example:greeter

world greeter {
export greet: func(who: string) -> string;
export multi-greet: func(who: string, times: u16) -> list<string>;
}
```

You can create a GenServer wrapper like this:

```elixir
defmodule MyApp.Greeter do
use Wasmex.Components.ComponentServer,
wit: "path/to/greeter.wit"
end
```

This will automatically generate the following functions:

```elixir
# Start the component server
iex> {:ok, pid} = MyApp.Greeter.start_link(path: "path/to/greeter.wasm")

# Generated function wrappers:
iex> MyApp.Greeter.greet(pid, "World") # Returns: "Hello, World!"
iex> MyApp.Greeter.multi_greet(pid, "World", 2) # Returns: ["Hello, World!", "Hello, World!"]
```

## Imports Example

When your WebAssembly component imports functions, you can provide them using the `:imports` option.
For example, given a WIT file `logger.wit`:

```wit
package example:logger

world logger {
import log: func(message: string)
import get-timestamp: func() -> u64

export log-with-timestamp: func(message: string)
}
```

You can implement the imported functions like this:

```elixir
defmodule MyApp.Logger do
use Wasmex.Components.ComponentServer,
wit: "path/to/logger.wit",
imports: %{
"log" => fn message ->
IO.puts(message)
:ok
end,
"get-timestamp" => fn ->
System.system_time(:second)
end
}
end
```

# Usage:
```elixir
iex> {:ok, pid} = MyApp.Logger.start_link(wasm: "path/to/logger.wasm")
iex> MyApp.Logger.log_with_timestamp(pid, "Hello from Wasm!")
```

The import functions should return the correct types as defined in the WIT file. Incorrect types will likely
cause a crash, or possibly a NIF panic.

## Options

* `:wit` - Path to the WIT file defining the component's interface
* `:imports` - A map of import function implementations that the component requires, where each key
is the function name as defined in the WIT file and the value is the implementing function
"""

defmacro __using__(opts) do
macro_imports = Keyword.get(opts, :imports, %{})

genserver_setup =
quote do
use GenServer

def start_link(opts) do
Wasmex.Components.start_link(opts |> Keyword.put(:imports, unquote(macro_imports)))
end

def handle_call(request, from, state) do
Wasmex.Components.handle_call(request, from, state)
end
end

functions =
if wit_path = Keyword.get(opts, :wit) do
wit_contents = File.read!(wit_path)
exported_functions = Wasmex.Native.wit_exported_functions(wit_path, wit_contents)

for {function, arity} <- exported_functions do
arglist = Macro.generate_arguments(arity, __MODULE__)
function_atom = function |> String.replace("-", "_") |> String.to_atom()

quote do
def unquote(function_atom)(pid, unquote_splicing(arglist)) do
Wasmex.Components.call_function(pid, unquote(function), [unquote_splicing(arglist)])
end
end
end
else
[]
end

[genserver_setup, functions]
end
end
Loading
Loading