WASM Plugins
WASM plugins are compiled binaries that run native WebAssembly instructions inside the Alloy process. They execute 5-10x faster than QuickJS plugins, making them ideal for filters and transforms called on every page.
plugins/
custom-slugify.wasm # Compiled from Rust, TinyGo, or AssemblyScript
Drop a .wasm file in plugins/ and Alloy loads it automatically via wazero (pure Go, zero CGo).
When to Use WASM
WASM plugins are worth the compilation step when:
- A filter runs on every page in a large site (thousands of calls per build)
- You need maximum throughput for data transforms
- You prefer Rust, Go, or AssemblyScript over JavaScript
For one-off or low-frequency operations, QuickJS plugins are simpler (no build step).
ABI Contract
WASM modules communicate with Alloy through linear memory using a pointer/length convention. Your module must export specific functions that Alloy calls during the build.
Required Export
alloc(size i32) -> ptr i32
Alloc returns a pointer to a block of size bytes in the module’s linear memory. Alloy uses this to write input data before calling filter, shortcode, or hook exports. This avoids writing at hardcoded offsets that could collide with the module’s data section, stack, or heap.
Filter Export
filter(ptr i32, len i32) -> (ptr i32, len i32)
Receives a UTF-8 string at the given pointer/length. Returns a pointer/length pair for the result string. Input and output are raw UTF-8 – the filter transforms the value and returns the transformed value.
Optional Exports
shortcode(ptr i32, len i32) -> (ptr i32, len i32)
hooks() -> (ptr i32, len i32)
hook(ptr i32, len i32) -> (ptr i32, len i32)
last_error() -> (ptr i32, len i32)
shortcode: Input is a JSON object{ "name": "youtube", "args": ["abc123"], "content": "" }. Output is a UTF-8 HTML string.hooks: Called once at module load, no input. Returns a JSON array of hook name strings (e.g.,["onContentTransformed"]).hook: Input is a JSON payload with an"event"key. Output is the modified JSON payload.last_error: Called when any export returns(0, 0). Returns an error message string.
Calling Sequence
For every call from Alloy to a WASM export:
- Alloy calls
alloc(inputLen)to get a write offset in WASM memory - Alloy writes input bytes at the returned pointer
- Alloy calls the target export (e.g.,
filter(ptr, len)) - The module reads input, processes it, writes the result to its own memory
- The module returns
(resultPtr, resultLen) - Alloy reads result bytes from WASM memory
Error Handling
If any export returns (0, 0), Alloy treats it as an error. If the module exports last_error(), Alloy reads and surfaces the error message. No silent fallback to the original input.
If hooks() returns invalid JSON (not an array of strings), module loading fails. If hook() returns non-JSON bytes, the hook call returns an error.
Rust Example
use std::alloc::{alloc, Layout};
use std::slice;
use std::str;
// Required: memory allocator for host writes
#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 {
let layout = Layout::from_size_align(size as usize, 1).unwrap();
unsafe { alloc(layout) as i32 }
}
// Filter: convert text to uppercase
#[no_mangle]
pub extern "C" fn filter(ptr: i32, len: i32) -> u64 {
let input = unsafe {
let slice = slice::from_raw_parts(ptr as *const u8, len as usize);
str::from_utf8(slice).unwrap()
};
let result = input.to_uppercase();
let result_bytes = result.as_bytes();
let result_ptr = alloc(result_bytes.len() as i32);
unsafe {
std::ptr::copy_nonoverlapping(
result_bytes.as_ptr(),
result_ptr as *mut u8,
result_bytes.len(),
);
}
// Pack (ptr, len) into a single i64 return value
((result_ptr as u64) << 32) | (result_bytes.len() as u64)
}
Build with:
cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/my_filter.wasm plugins/
TinyGo Example
package main
import "unsafe"
// Required: memory allocator
//export alloc
func alloc(size int32) int32 {
buf := make([]byte, size)
return int32(uintptr(unsafe.Pointer(&buf[0])))
}
// Filter: count words in text
//export filter
func filter(ptr, length int32) (int32, int32) {
input := ptrToString(ptr, length)
words := 0
inWord := false
for _, c := range input {
if c == ' ' || c == '\n' || c == '\t' {
inWord = false
} else if !inWord {
inWord = true
words++
}
}
result := itoa(words)
return stringToPtr(result)
}
func ptrToString(ptr, length int32) string {
return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), length)
}
func stringToPtr(s string) (int32, int32) {
buf := []byte(s)
ptr := &buf[0]
return int32(uintptr(unsafe.Pointer(ptr))), int32(len(buf))
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
func main() {}
Build with:
tinygo build -o plugins/word-count.wasm -target wasi .
AssemblyScript Example
// src/word-count.ts
// Required: memory allocator
export function alloc(size: i32): i32 {
return heap.alloc(size) as i32;
}
// Filter: count words
export function filter(ptr: i32, len: i32): u64 {
const input = String.UTF8.decodeUnsafe(ptr, len);
const words = input.trim().split(" ").filter(w => w.length > 0);
const result = words.length.toString();
const resultBuf = String.UTF8.encode(result);
const resultPtr = alloc(resultBuf.byteLength);
memory.copy(resultPtr, changetype<usize>(resultBuf), resultBuf.byteLength);
return (u64(resultPtr) << 32) | u64(resultBuf.byteLength);
}
Build with:
asc src/word-count.ts -o plugins/word-count.wasm
Hook Support
WASM modules can register lifecycle hooks by exporting hooks() and hook():
use serde_json::{json, Value};
#[no_mangle]
pub extern "C" fn hooks() -> u64 {
let names = json!(["onContentTransformed"]);
let bytes = names.to_string().into_bytes();
let ptr = alloc(bytes.len() as i32);
unsafe {
std::ptr::copy_nonoverlapping(
bytes.as_ptr(), ptr as *mut u8, bytes.len()
);
}
((ptr as u64) << 32) | (bytes.len() as u64)
}
#[no_mangle]
pub extern "C" fn hook(ptr: i32, len: i32) -> u64 {
let input = unsafe {
let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
std::str::from_utf8(slice).unwrap()
};
let mut payload: Value = serde_json::from_str(input).unwrap();
if payload["event"] == "onContentTransformed" {
if let Some(html) = payload["html"].as_str() {
let modified = html.replace("<img ", "<img loading=\"lazy\" ");
payload["html"] = json!(modified);
}
}
let result = payload.to_string().into_bytes();
let result_ptr = alloc(result.len() as i32);
unsafe {
std::ptr::copy_nonoverlapping(
result.as_ptr(), result_ptr as *mut u8, result.len()
);
}
((result_ptr as u64) << 32) | (result.len() as u64)
}
WASM hooks always run at default priority 50. There is no mechanism for per-hook priority in the WASM ABI.
Compilation Cache
Alloy caches compiled WASM modules in .alloy/wasm-cache/ so subsequent builds skip the compilation step. The cache persists across builds.
Sandboxing
WASM plugins run in isolated memory via wazero. They cannot access the filesystem, network, or system resources. Safe to run untrusted community plugins.
Performance Comparison
| Runtime | Per-call | Best For |
|---|---|---|
| QuickJS (JS) | ~10-50 microseconds | Prototyping, low-frequency filters |
| WASM (compiled) | ~1-10 microseconds | Hot-path filters on every page |
| Node (Tier 3) | ~1-5 milliseconds | npm packages, system access |
Related
- Plugin System – overview and tier comparison
- QuickJS Plugins – JS plugins with no build step
- Node Plugins – full Node.js access
- Lifecycle Events – all hook events and payloads