Reference
ABI
A plugin is a .wasm module a script imports through an edge.json alias, like any other module. The JS host and the CLI both load it. This page is the contract it follows.
Plugin ABI v1 is sealed. Every signature, op code, tag and error kind on this page is the public contract for plugin modules. A future wire-level break would ship as env_v2.* without removing v1.
System calls arrive as the Sys op. edge_call_id is the one import added since v1 sealed. A plugin that never waits on a system call still loads on any v1 host.
The API is handle-based.
- The host owns all values. The guest sees only opaque
u32handles. - One dispatch primitive,
edge_op, covers every operation. - New types, methods and language features reach existing modules with no ABI change.
This contract is distinct from the interface between compiler.wasm and its host. Rust covers that one. That interface is not bound by the 7-import limit here.
Guest export shape
Every function the script can call is an export with this signature.
extern "C" fn <name>(argv: *const u32, argc: u32, out: *mut u32) -> i32;| Field | Meaning |
|---|---|
argv | Pointer in guest linear memory to an array of argc host-managed handles. One per positional argument, plus a trailing kwargs slot |
argc | Positional argument count plus one, the trailing kwargs slot. That slot is handle 0 when no name=value arguments were passed |
out | Pointer in guest linear memory where the guest writes ONE handle for the return value |
| return | 0 for success, 1 for an error the host pulls at once with edge_take_error, 2 while a system call waits. See System calls |
argv handles are host-owned and live for the call. Handles the guest creates with edge_encode or edge_op are guest-owned until released. The guest must edge_release each before returning, except the one written into *out.
The #[plugin_fn] macro exports free functions as __fn_<name>, and the host strips the prefix. The host also reads the __const_<name> and __class_<Name>_<method> conventions described below. Plain-named exports work too.
Required guest exports
Every guest module MUST export these two, beside its user functions.
#[unsafe(no_mangle)]
pub extern "C" fn __edge_alloc(size: u32) -> *mut u8;
#[unsafe(no_mangle)]
pub extern "C" fn __edge_abi_version() -> u32;| Export | Required | Meaning |
|---|---|---|
__edge_alloc(size) | Yes | Lets the host stage argv arrays in guest linear memory before each call |
__edge_abi_version() | Yes | The wire-format version, currently 1. Both hosts read it and refuse a mismatch. A v2 module never decodes garbage on a v1 host |
__edge_free(ptr, size) | Should | Lets the host release that staging after each call. Without it staging accumulates for the lifetime of the instance |
__edge_resume(call, answer, out) | When it waits | Finishes a call that waited on a system call. See System calls |
The wasm-pdk crate emits __edge_alloc, __edge_free and __edge_abi_version itself. #[plugin_resume] emits __edge_resume. EDGE_ABI_VERSION lives in the shared wasm-abi crate, no_std with zero deps, and the host and every PDK read the same value.
Host imports
The guest declares these seven from env.
fn edge_op(op: u32, recv: u32, name_ptr: *const u8, name_len: u32, argv_ptr: *const u32, argc: u32, out: *mut u32) -> i32;
fn edge_encode(tag: u32, ptr: *const u8, len: u32) -> u32;
fn edge_decode(h: u32, out_tag: *mut u32, dst: *mut u8, dst_max: u32) -> i32;
fn edge_release(h: u32);
fn edge_take_error(out_kind: *mut u32, dst: *mut u8, dst_max: u32) -> i32;
fn edge_throw(kind: u32, msg_ptr: *const u8, msg_len: u32);
fn edge_call_id() -> u32;edge_op
Universal dispatch. It returns 0 with a fresh handle in *out on success, 1 on error, or 2 while a system call waits.
edge_encode
Wraps a primitive in a fresh handle with a refcount of one. Release it when done.
ptr and len describe bytes in guest memory, and the host copies them. It returns 0 on an invalid tag or when called outside a run.
edge_decode
Writes the tag of the value at *out_tag and copies bytes into dst[..dst_max].
It returns the bytes copied, >= 0, or -bytes_needed when the buffer was too small. The tag is still written in that case. The guest can grow the buffer and retry.
An invalid handle or a non-transit value returns 0 with *out_tag = 0xFFFFFFFF. Sets, instances and cyclic composites are non-transit. Walk those with edge_op.
edge_release
Decrements a refcount. A no-op for handle 0 or an already-released handle.
edge_take_error
Drains the most recent error from a call that returned 1. It writes the kind at *out_kind and copies the UTF-8 message into dst[..dst_max].
- It returns the bytes copied,
>= 0. - It returns
-bytes_neededwhen the buffer was too small. The error stays pending. - It returns
-1when no error is pending.
edge_throw
Stashes an error the host sees after the guest returns 1. Use it when the error did not come from an edge_op that returned 1, for example a typed Result::Err from user code. It overwrites any pending error, and the guest must return 1 at once.
edge_call_id
The id of the call the host is running in the plugin right now. It stays the same inside __edge_resume. A Sys op names it, and a plugin keeps whatever it needs between a call and its resume under it.
Op codes
| Op | Value | Meaning |
|---|---|---|
Call | 0 | recv.<name>(args...) -> handle |
GetAttr | 1 | recv.<name> -> handle |
SetAttr | 2 | recv.<name> = args[0] -> handle (None) |
GetItem | 3 | recv[args[0]] -> handle |
SetItem | 4 | recv[args[0]] = args[1] -> handle (None) |
Len | 5 | len(recv) -> handle (Int) |
Iter | 6 | iter(recv) -> handle (iterator List) |
IterNext | 7 | next(iter) -> handle, or 1 + StopIteration on end |
NewDict | 8 | Empty dict, recv and name ignored, argc=0 -> handle |
NewList | 9 | Empty list, recv and name ignored, argc=0 -> handle |
TypeOf | 10 | Runtime type of recv -> handle (Str with the type name) |
NewTuple | 11 | Tuple from argv items, recv and name ignored -> handle |
NewSet | 12 | Set from argv items, an unhashable item -> error |
NewFrozenSet | 13 | Frozenset from argv items, an unhashable item -> error |
Sys | 14 | The system call name, such as net.request, with recv the id from edge_call_id -> handle, or 2 while it waits |
Op::Iter materialises the receiver into a List handle, and Op::IterNext advances it.
- A set yields its items in hash-table order.
- A dict yields its keys.
- A str splits into single-character strings.
- A bytes yields its bytes as ints.
TypeOf returns the builtin type names, such as "int", "float", "str", "bytes", "list", "dict", "set", "tuple", "NoneType" and "bool". A user instance returns its class name.
Values 15..u32::MAX are reserved. Old hosts return 1 with kind Runtime.
Tags
The tags edge_encode and edge_decode use.
| Tag | Value | Layout |
|---|---|---|
| None | 0 | Payload ignored |
| Bool | 1 | 1 byte (0/1) |
| Int | 2 | 16 bytes little-endian i128 |
| Float | 3 | 8 bytes IEEE 754 little-endian |
| Bytes | 4 | UTF-8 bytes -> str, non-UTF-8 is rejected |
| Raw | 5 | Bytes -> bytes, no UTF-8 validation |
| List | 6 | TLV, count:u32le then count nodes -> list |
| Dict | 7 | TLV, count:u32le then count key,value node pairs -> dict |
Composite transit
List and Dict payloads nest values as TLV nodes. Each is framed tag:u32le len:u32le payload[len] with any transit tag inside. A dict crosses whole in one call.
- Dict keys must be
str. Any other key makes the whole value non-transit. - Nesting caps at depth 32. Malformed or deeper input is rejected, and
edge_encodereturns handle0. - Cyclic values cannot serialize.
edge_decodereports them as non-transit,0xFFFFFFFF. tupleflattens toListon the wire.
The canonical codec is wasm_abi::WireValue, with encode_body and decode_body. The compiler and wasm-pdk share it.
Sets and frozensets construct with NewSet and NewFrozenSet. The remaining composites, instance, callable and iterator, construct with edge_op(Call, type_handle, ...) and work through the indexing ops.
Error kinds
The kinds edge_take_error and edge_throw carry.
| Kind | Value | Maps to |
|---|---|---|
| Type | 0 | TypeError |
| Value | 1 | ValueError |
| Runtime | 2 | RuntimeError |
| Attribute | 3 | AttributeError |
| Index | 4 | IndexError |
| Key | 5 | KeyError |
| Custom | 6 | Any other class. The message carries its name, such as PermissionError: ... |
System calls
A plugin reaches fs, net, time and secret through the Sys op. They are served exactly as a .py beside the .wasm would import them. The calls and their scopes are on System.
The plugin holds the grants of the package it sits in. A module that package is not granted fails with PermissionError.
A call that answers at once returns 0 with the value. One that waits plays out in four steps.
edge_opreturns2, and the plugin returns2from its own export.- The host parks the coroutine, and the program waits.
- Once the system call settles, the host calls
__edge_resumewith the same call id. It passes a handle to the answer, or0with the error left foredge_take_error. - What
__edge_resumereturns is what the program sees.0is a value,1an error, and2another wait on the same call.
The program sees what the plugin makes of the answer.
#[plugin_fn]
fn status_of(url: String) -> Result<i64> {
let (method, target) = (encode(Value::Bytes(b"GET".to_vec()))?, encode(Value::Bytes(url.into_bytes()))?);
let id = Handle::sys("net.request", &[method.raw(), target.raw()])?;
// net.response waits, `?` returns Error::Waiting and the plugin goes on in resume.
status(Handle::sys("net.response", &[id.raw()])?)
}
#[plugin_resume]
fn resume(_call: u32, head: Result<Handle>) -> Result<i64> {
status(head?)
}Handle::sys returns Error::Waiting while the call waits. call_id() names the call to keep state under, and #[plugin_resume] exports __edge_resume.
Writing a plugin with wasm-pdk
The wasm-pdk crate is the Plugin Development Kit. It is bundled in this repo and publishable apart from compiler.wasm. Its #[plugin_fn] macro turns a typed Rust function into a wire-conformant export, and authors write normal Rust.
// slugify-mod/src/lib.rs
#![no_std]
#![no_main]
extern crate alloc;
use alloc::string::String;
use wasm_pdk::*;
wasm_pdk::module!(); // expands to #[global_allocator] + #[panic_handler]
#[plugin_fn]
fn slugify(s: String) -> String {
s.to_lowercase().replace(' ', "-")
}
#[plugin_fn]
fn repeat_n(s: String, n: i64) -> Result<String> {
if n < 0 { return Err(Error::Value("repeat count must be non-negative".into())); }
Ok(s.repeat(n as usize))
}
#[plugin_fn]
fn sum_ints(items: Handle) -> Result<i64> {
let it = items.iter()?;
let mut total: i64 = 0;
while let Some(item) = it.iter_next()? {
total += i64::from_handle(item.raw())?;
}
Ok(total)
}Two macros set up the module.
module!()expands to#[global_allocator]and#[panic_handler].module_fixed_pool!(), ormodule_fixed_pool!(bytes), does the same on a fixed static pool, 4 MiB by default, that never callsmemory.grow. The bundledstdpackages use it.
The Cargo.toml of the module follows.
[package]
name = "slugify-mod"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-pdk = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.9.5" }Pin a tag rather than branch = "main". That gives reproducible builds and a known wire-ABI version.
A module compiled against wasm-pdk from release vX.Y.Z is binary-compatible with the compiler.wasm of the same release. To upgrade, bump tag and run cargo update -p wasm-pdk.
Build it for wasm.
cargo build --release --target wasm32-unknown-unknown
# -> target/wasm32-unknown-unknown/release/slugify_mod.wasmA script uses it through an edge.json alias.
{ "imports": { "slugify_mod": "./slugify_mod.wasm" } }from slugify_mod import slugify, repeat_n, sum_ints
print(slugify("Hello World")) # hello-world
print(repeat_n("ha", 3)) # hahaha
print(sum_ints([1, 2, 3, 4])) # 10
try:
print(repeat_n("nope", -1))
except ValueError as e:
print("caught:", e) # caught: repeat count must be non-negativeTypes that cross
FromValue and IntoValue convert arguments and returns.
| Type | Crosses as |
|---|---|
i64 | int. An out-of-range value raises ValueError |
i128 | int, the full range |
f64 | float |
bool | bool |
String, &str | str |
Bytes | bytes, over the Raw tag |
Option<T> | T or None |
Vec<Value>, Vec<f64> | A whole sequence in one TLV transit instead of one op per item |
Handle | Any value, held by the host |
Value | Any transit value, decoded into the guest |
A Handle releases itself on Drop. It carries the ops as methods.
call,get_attr,set_attr,get_itemandset_item.len,iteranditer_next.new_dict,new_list,new_tuple,new_setandnew_frozenset.type_of.
PluginCell<T> is a single-threaded interior mutability cell for static plugin state.
Exposing Rust structs as classes
#[plugin_class], #[plugin_methods] and #[plugin_ctor] expand to __class_<Name>_<method> exports. The host detects them by that naming convention and builds a class.
State lives in a guest-side BTreeMap<id, T>. Each instance carries an __rust_id attribute the methods use to look themselves up.
#[plugin_class]
pub struct Slugger { parts: Vec<String> }
#[plugin_methods]
impl Slugger {
#[plugin_ctor]
pub fn new() -> Self { Self { parts: Vec::new() } }
pub fn add(&mut self, s: String) { self.parts.push(s.to_lowercase()); }
pub fn build(&self) -> String { self.parts.join("-") }
pub fn pop(&mut self) -> Option<String> { self.parts.pop() }
pub fn repeat(&self, n: i64) -> Result<String> {
if n < 0 { return Err(Error::Value("n must be non-negative".into())); }
Ok(self.parts.join("-").repeat(n as usize))
}
}A script uses it through the same alias.
from slugify_mod import Slugger
s = Slugger()
s.add("Hello")
print(s.build()) # helloMethods may return T, Option<T> or Result<T>. Instances live until the run ends. There is no __del__ dispatch.
Exposing module constants
A .wasm exports only functions. A value attribute like math.pi ships as a zero-arg #[plugin_const] export, under the __const_<name> convention. The host calls it once at import and binds the result as a module attribute, a value rather than a callable.
#[plugin_const]
fn pi() -> f64 { core::f64::consts::PI }3.141592653589793
Variadic functions
A trailing Args parameter captures every positional argument past the fixed ones, for *args-style signatures. Its handles are borrowed. Declare it as the last parameter before any Kwargs.
#[plugin_fn]
fn hypot(coords: Args) -> Result<f64> {
let mut sum = 0.0;
for h in &coords.0 { let x = f64::from_handle(h.raw())?; sum += x * x; }
Ok(libm::sqrt(sum))
}Keyword arguments
A Kwargs parameter wraps the trailing kwargs handle, as in fn foo(a: Handle, kw: Kwargs).
get::<T>(name) reads a primitive kwarg. get_handle(name) reads a callable, a tuple or a dict. Without a Kwargs parameter the slot is absorbed silently.
Writing a plugin without an SDK
The same module without the macro, for Zig, C or hand-written Rust.
#![no_std]
#![no_main]
extern crate alloc;
use alloc::{boxed::Box, vec};
#[global_allocator]
static A: lol_alloc::LeakingPageAllocator = lol_alloc::LeakingPageAllocator;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! { core::arch::wasm32::unreachable() }
#[link(wasm_import_module = "env")]
unsafe extern "C" {
fn edge_op(op: u32, recv: u32, name_ptr: *const u8, name_len: u32, argv_ptr: *const u32, argc: u32, out: *mut u32) -> i32;
fn edge_encode(tag: u32, ptr: *const u8, len: u32) -> u32;
fn edge_release(h: u32);
}
const OP_CALL: u32 = 0;
const TAG_BYTES: u32 = 4;
/// Required by the host for staging argv arrays.
#[unsafe(no_mangle)]
pub extern "C" fn __edge_alloc(size: u32) -> *mut u8 {
Box::into_raw(vec![0u8; size as usize].into_boxed_slice()) as *mut u8
}
/// Releases an `__edge_alloc` buffer after the call; sizes must match.
#[unsafe(no_mangle)]
pub extern "C" fn __edge_free(ptr: *mut u8, size: u32) {
if ptr.is_null() || size == 0 { return; }
drop(unsafe { Box::from_raw(core::slice::from_raw_parts_mut(ptr, size) as *mut [u8]) });
}
#[unsafe(no_mangle)]
pub extern "C" fn slugify(argv: *const u32, argc: u32, out: *mut u32) -> i32 {
if argc != 2 { return 1; } // 1 positional + trailing kwargs slot
let input = unsafe { *argv };
// 1) input.lower()
let mut lower: u32 = 0;
if unsafe { edge_op(OP_CALL, input, b"lower".as_ptr(), 5, core::ptr::null(), 0, &mut lower) } != 0 {
return 1;
}
// 2) lower.replace(" ", "-")
let space = unsafe { edge_encode(TAG_BYTES, b" ".as_ptr(), 1) };
let dash = unsafe { edge_encode(TAG_BYTES, b"-".as_ptr(), 1) };
let argv2 = [space, dash];
let r = unsafe { edge_op(OP_CALL, lower, b"replace".as_ptr(), 7, argv2.as_ptr(), 2, out) };
// 3) Release intermediate handles. The result handle in *out transfers to the host.
unsafe { edge_release(space); edge_release(dash); edge_release(lower); }
r
}It takes the same Cargo.toml as the wasm-pdk example, without wasm-pdk and with lol_alloc = "0.4". Plain-named exports like this slugify are picked up as free functions. Scripts import the module the same way.
The macro writes this boilerplate. By hand it costs about 25 lines for the first function and about 5 for each one after.
Community PDKs track this sealed spec with releases of their own. There is one for Zig (wasm-pdk-zig), one for AssemblyScript (wasm-pdk-as) and one for C (wasm-pdk.h).
How the host loads it
For from <name> import <names> where the manifest maps <name> to a .wasm URL, the host runs five steps.
- Fetches the bytes. The engine holds them to any
#sha256-...fragment. - Instantiates the module with the 7 host imports.
- Walks the export table.
- Marshals arguments as handles.
- Propagates results.
The reference bridge is js/src/native.ts in the repo.
The CLI implements the same seven imports in Rust over wasmtime, bridging the plugin instance and the compiler instance the same way. It compiles a plugin with Cranelift on first use and caches the machine code. A Rust host mirrors the shape.
Constraints and caveats
- Refcounted handles. The guest releases every handle it creates with
edge_encodeoredge_op, except the one returned through*out. The host releasesargvhandles. edge_decodecovers primitives pluslist,tupleanddict. They cross TLV-encoded. Sets, instances and cyclic values returnTAG_INVALID. Walk those withedge_op.- Trailing kwargs slot. Every plugin call carries one extra
u32after the positional argv. It is handle0when the caller passed noname=valuearguments, and otherwise adicthandle holding the pairs. - Calling a callable the caller passed.
edge_op(Call, recv, "__call__", ...)invokesrecvdirectly. Lambdas, builtins, classes and bound methods all route through the dispatch the language uses. Hooks likedefault,object_hookorparse_intwork this way. Positional arguments cap at 255. - Reentrance. An
edge_opfrom the guest runs while the VM is paused on theCallExternof the script. Method dispatch goes through the method table the language uses inside. A method added there reaches existing modules with no recompile. - Errors are a status. Returning
1does not abort the host. The host pulls the error and raises it as a typed exception. - Memory ownership. The host reads guest linear memory only at well-defined copy points. Allocations inside the guest stay private.
- ABI v1 leaks about 8 bytes per host call in guest linear memory. A single worker session caps at roughly 500 k plugin calls. Recycle the worker from time to time for unbounded streaming.