Sealed contract, plugin ABI v1. Every signature, op code, tag, and error kind here is the public contract for CDN-distributed
.wasmplugin modules (Path A). New host packages arrive as newOpvalues, never new imports; a future wire-level break would ship asenv_v2.*without removing v1. Distinct from thecompiler<->hostinterface embedders declare (see host packages), embedders aren’t bound by the 6-import limit here.
A .wasm module imported via from "<url>" import <names> follows the contract below.
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.
Guest export shape
Every function the script can call is exposed as an extern "C" symbol (the #[plugin_fn] macro names free functions __fn_<name> and the host strips the prefix):
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 (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 = success, 1 = error (host pulls the error via edge_take_error immediately). |
argv handles are host-owned and live for the call. Handles the guest creates via edge_encode or edge_op are guest-owned until released. The guest must edge_release each before returning, except the one written into *out.
Required guest exports
Every guest module MUST export, in addition to 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;__edge_alloc lets the host stage argv arrays in guest linear memory before invoking each export.
Guests SHOULD also export __edge_free(ptr: *mut u8, size: u32) so the host can release that staging after each call. The host treats it as optional; without it, staging accumulates for the instance’s lifetime.
__edge_abi_version returns the wire-format version (currently 1). The host MUST read this once at instantiation and refuse unknown versions. Otherwise a v2 host would silently decode garbage from a v1 module.
At v1 every loader targets 1, so the bundled compiler.wasm shim does not yet read the symbol. The check becomes load-bearing when v2 ships.
The reference wasm-pdk crate emits both symbols automatically. EDGE_ABI_VERSION lives in the shared wasm-abi crate (no_std, zero deps) so host and every PDK read the same value.
Host imports (6 functions)
Guest declares 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);edge_op
Universal dispatch. Returns 0 with a fresh handle in *out on success, 1 on error.
edge_encode
Wraps a primitive in a fresh handle (rc=1; release when done). ptr/len describe bytes in guest memory; the host copies.
edge_decode
Writes the value’s tag at *out_tag and copies bytes into dst[..dst_max]. Returns bytes copied (>= 0), or -bytes_needed if the buffer was too small (re-allocate and retry). On invalid handle or non-transit value (set, instance, cyclic composite), returns 0 with *out_tag = 0xFFFFFFFF; those go through edge_op.
edge_release
Decrement refcount. No-op for handle 0 or already-released.
edge_take_error
Drain the most recent error from a 1-returning edge_op. Writes kind at *out_kind, copies the UTF-8 message into dst[..dst_max]. Returns bytes copied (>= 0), -bytes_needed if the buffer was too small (error stays pending), or -1 if no error pending.
edge_throw
Stash an error visible after the guest returns 1. Use it when an error did not originate from a 1-returning edge_op (e.g., a typed Result::Err from user code). Overwrites any pending error. The guest must immediately return 1.
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 | construct empty dict; recv and name ignored, argc=0 -> handle |
NewList | 9 | construct empty list; recv and name ignored, argc=0 -> handle |
TypeOf | 10 | runtime type of recv -> handle (Str with the type name) |
NewTuple | 11 | construct tuple from argv items; recv and name ignored -> handle |
NewSet | 12 | construct set from argv items; unhashable item -> error |
NewFrozenSet | 13 | construct frozenset from argv items; unhashable item -> error |
Op::Iter materialises the receiver into a List handle:
- set: items in hash-table iteration order
- dict: yields keys
- str: splits to single-char strings
Op::IterNext advances it.
NewDict / NewList construct empty composites. NewTuple / NewSet / NewFrozenSet construct from the argv items in one call.
TypeOf returns names matching the Python builtin: "int", "float", "str", "bytes", "list", "dict", "set", "tuple", "NoneType", "bool", "object" (user instance), etc.
Values 14..u32::MAX are reserved. Old hosts return 1 with kind=Runtime.
Tags (for edge_encode / edge_decode)
| 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 (TLV)
List / Dict payloads nest values as TLV nodes, each framed tag:u32le len:u32le payload[len] with any transit tag inside:
inp = tick({"positions": [[92.5, 115.75], [50.0, 20.25]], "step": 2}) # dict crosses whole- Dict keys must be
str; any other key makes the whole value non-transit. - Nesting caps at depth 32; malformed or deeper input rejects (
edge_encodereturns handle0). - Cyclic values cannot serialize;
edge_decodereports them as non-transit (0xFFFFFFFF). - Python
tupleflattens toListon the wire.
The canonical codec is wasm_abi::WireValue (encode_body / decode_body), shared by the compiler and wasm-pdk.
Sets and frozensets construct via NewSet / NewFrozenSet. Remaining composites (instance, callable, iterator) construct via edge_op(Call, type_handle, ...) and operate via indexing ops.
Error kinds (for edge_take_error)
| Kind | Value | Maps to |
|---|---|---|
| Type | 0 | TypeError |
| Value | 1 | ValueError |
| Runtime | 2 | RuntimeError |
| Attribute | 3 | AttributeError |
| Index | 4 | IndexError |
| Key | 5 | KeyError |
| Custom | 6 | the message carries the user-defined kind name |
Worked example, recommended Rust path with wasm-pdk
The wasm-pdk crate provides the #[plugin_fn] proc macro that expands to wire-conformant exports. 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)
}Cargo.toml:
[package]
name = "slugify-mod"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-pdk = { path = "../../pdk" } # in-repo example; external authors use the git+tag form belowBuild:
cargo build --release --target wasm32-unknown-unknown -p slugify-mod # -> target/wasm32-unknown-unknown/release/slugify_mod.wasm (around 80 KB stripped)Exposing Rust structs as Python classes
#[plugin_class] + #[plugin_methods] expand to __class_<Name>_<method> exports. The host detects them by naming convention and synthesises a HeapObj::Class. State lives in a guest-side BTreeMap<id, T>. Each instance carries an __rust_id attribute the methods use to look itself 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))
}
}From Edge Python:
from "./slugify_mod.wasm" import Slugger
s = Slugger()
s.add("Hello")
print(s.build()) # -> helloMethod returns of T, Option<T>, and Result<T> are all supported. Instances live until the worker 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. The host calls it once at import and binds the result as a module attribute (a value, not a callable):
#[plugin_const]
fn pi() -> f64 { core::f64::consts::PI }import math
print(math.pi) # -> 3.141592653589793 (a float, not a call)
from math import tau # also works under `from ... import *`Variadic functions
A trailing Args param captures every positional past the fixed ones, for *args-style signatures:
#[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))
}Consuming wasm-pdk from your own crate
Not on crates.io. Depend from GitHub, pinned to a release tag:
[dependencies]
wasm-pdk = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0" }Cargo resolves wasm-abi and wasm-pdk-macros transitively. Pinning to a tag (vs branch = "main") gives reproducible builds and a known wire-ABI version. Your module compiled against wasm-pdk vX.Y.Z is binary-compatible with the compiler.wasm of the same release. Bump tag + cargo update -p wasm-pdk to upgrade. Use branch = "main" only for unreleased iteration.
Use it from a script:
from "./slugify_mod.wasm" 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-negativeWorked example, raw, no SDK
Same module without the macro (for Zig / C / hand-written Rust):
Rust source (src/lib.rs)
#![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 shim 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(std::slice::from_raw_parts_mut(ptr, size as usize) 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) Cleanup intermediate handles. The result handle in *out transfers to the host.
unsafe { edge_release(space); edge_release(dash); edge_release(lower); }
r
}Same Cargo.toml as the wasm-pdk example (drop wasm-pdk, add lol_alloc = "0.4"). Imported from scripts the same way.
How the host loads it
For from "<url>" import <names> with a .wasm URL, the host:
- Fetches bytes, verifying any
#sha256-...fragment. - Instantiates with the 6 host imports.
- Walks the export table.
- Marshals args as handles.
- Propagates results.
Reference browser shim: js/src/native.js (the edge_* guest imports and the built-in Path A loader). WASI hosts and Rust embedders mirror the shape.
Constraints and caveats
- Refcounted handles. Guest releases every handle it creates via
edge_encode/edge_opexcept the one returned through*out. Host releases argv. edge_decodecovers primitives pluslist/tuple/dict(TLV-encoded).set, instances, and cyclic values returnTAG_INVALID; walk those withedge_op(e.g.Call recv "items",GetItem recv idx).- Trailing kwargs slot. Every plugin call carries one extra
u32after the user’s positional argv: handle0when the caller passed noname=valuearguments, otherwise adicthandle holding the pairs. The#[plugin_fn]macro folds it into aKwargsparameter if declared (fn foo(a: Handle, kw: Kwargs)). Otherwise it is silently absorbed and the function sees only its positional args. - Invoking a caller-supplied callable. From the guest,
edge_op Call recv "__call__" argvinvokesrecvdirectly. Lambdas, builtins, classes, and bound methods all route through the same dispatch the language uses. Use this to wire Python hooks likedefault,object_hook,parse_int. - Reentrance supported. A guest’s
edge_opruns while the VM is paused on the script’sCallExtern. Method dispatch routes through the samevm/handlers/builtin_methods/descriptor table the language uses internally. Adding a method there makes it visible to existing modules with no recompile. - Error-as-status, not panic. Returning
1does NOT abort the host. The host pulls the error and raises it as a typed Python exception. - Memory ownership. Host only reads guest linear memory at well-defined copy points. Guest-internal allocations stay private.
Author conveniences
The wasm-pdk crate (Plugin Development Kit), bundled in this repo, publishable independently of compiler.wasm, provides:
#[plugin_fn], typed Rust function -> wire-conformant export.#[plugin_const], zero-arg fn -> module constant via the__const_<name>export convention; the host calls it once at import and binds the value as a module attribute.#[plugin_class]/#[plugin_methods]/#[plugin_ctor], expose a Rust struct as a Python class via the__class_<Name>_<method>export convention.module!(), expands to#[global_allocator]+#[panic_handler].module_fixed_pool!()(ormodule_fixed_pool!(bytes)), same but allocating from a fixed-size static pool that never callsmemory.grow; used by the bundledstdpackages.FromValue/IntoValuewith primitive impls (i64,i128,f64,bool,String,&str,Bytes,Option<T>,Handle,Value).i64rejects out-of-range values withValueError; usei128for the full range.Bytesmaps to Pythonbytesovertag::RAW.Vec<Value>/Vec<f64>cross a whole sequence in one TLV transit instead of per-item ops.HandlewithDrop-driven release pluscall,get_attr/set_attr,get_item/set_item,len,iter/iter_next,new_dict/new_list,new_tuple/new_set/new_frozenset,type_of.Args, trailing variadic positional params as borrowed handles; declare it as the last param before anyKwargs.Kwargs, thin wrapper around the trailing kwargs handle withget::<T>(name)for primitive kwargs andget_handle(name)for callables, tuples, dicts.PluginCell<T>, single-threaded interior mutability cell for static plugin state.__edge_alloc/__edge_free+__edge_abi_versionemitted automatically.
The macro emits the worked-example boilerplate. Manual is around 25 lines for the first function, around 5 per additional.
Community PDKs (uncoordinated releases, each tracking the sealed wire spec): Zig (wasm-pdk-zig), AssemblyScript (wasm-pdk-as), C (wasm-pdk.h).
Snapshot exports
Distinct from the sealed plugin imports above, these are exports on compiler.wasm itself, part of the host-driver surface an embedder calls to freeze and revive a paused run (the host-facing feature is Snapshots). They reuse the linear-memory buffers and the packed status word of the run lifecycle (run_start / run_resume / run_push_event).
| Export | Signature | Meaning |
|---|---|---|
save_state | () -> i64 | Serialise the parked run into an internal buffer. Returns the blob length, or -1 when nothing is parked. |
snapshot_ptr | () -> *const u8 | Pointer to the blob left by the last save_state. |
restore_state | (len: usize) -> u32 | Boot a VM from a blob staged in the source buffer and overlay its state. Returns the same packed status word as run_start. |
state_globals | () -> usize | Write the parked run’s module-level bindings as JSON into the out buffer. Returns its byte length. |
state_stack | () -> usize | Write the parked run’s coroutines as JSON into the out buffer. Returns its byte length. |
set_preempt_interval | (n: u32) | Yield PREEMPTED every n loop back-edges so a program with no suspension point stays snapshottable. Defaults to 0, which disables it. Applies to the next run_start / restore_state. |
Buffers are the run lifecycle’s: src_ptr() (1 MiB input), out_ptr() (1 MiB output), and snapshot_ptr() for the blob.
- Save. Drive to a pause (
run_start, thenrun_resumeuntil aPENDING_*status), callsave_state(), and if the result is non-negative read that many bytes atsnapshot_ptr(). - Preempt. With a non-zero
set_preempt_interval,run_start/run_resumealso return kind7(PREEMPTED). The run is parked and snapshottable, and needs no host action: callrun_resumeto continue, orsave_state()first to freeze a program that never suspends on its own. - Restore. Boot a fresh instance and register the same host modules (the embedded source is re-parsed, so its imports must resolve), write the blob into
src_ptr(), then callrestore_state(len)and drive it withrun_resumelike any other run. - Inspect. Call
state_globals()orstate_stack()and read that many UTF-8 bytes atout_ptr(), one JSON value each.
Blob layout
Little-endian, self-contained, versioned.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic, 0x4E535045 |
| 4 | 4 | format version, currently 1 |
| 8 | 8 | fingerprint, structural hash of the bytecode |
| 16 | 8 | source length in bytes |
| 24 | N | source, UTF-8 |
| 24+N | rest | serialised VM state (heap, stacks, scheduler, pending) |
restore_state re-parses the embedded source, recomputes the fingerprint, and rejects any blob whose fingerprint does not match the freshly compiled chunk, which pins each blob to one program and one compiler build. The whole blob must fit the 1 MiB source buffer. Serializer: vm/snapshot.rs; internals in Design.
Consuming the release from a Rust crate
The edge-python crate is a cdylib: a Rust host can instantiate compiler.wasm and call the exports above directly, the same .wasm that ships to browsers; the host owns I/O. The crate declares links = "compiler" and its build.rs downloads the matching compiler.wasm from the GitHub Release for CARGO_PKG_VERSION into OUT_DIR. Downstream crates read the absolute path through DEP_COMPILER_WASM.
# Downstream Cargo.toml
[dependencies]
edge-python = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0" }// Downstream build.rs
fn main() {
println!("cargo::rerun-if-changed=build.rs");
let wasm = std::env::var("DEP_COMPILER_WASM").expect("`DEP_COMPILER_WASM` unset, upstream `edge-python` must declare `links = \"compiler\"`");
std::fs::copy(&wasm, "runtime/compiler.wasm").expect("copy failed");
}The download URL is derived from CARGO_PKG_VERSION, so a tag bump is the only retarget. Use branch = "main" for unreleased work. Requires curl on PATH; gated by the default-on prebuilt feature. To add native modules from a Rust host, implement the Resolver trait; see Writing modules.
See also
- Imports: how
from "..." importresolves on the script side, including walk-up packages.json and integrity verification. - Writing modules: overview of the three module delivery paths.
- Snapshots: freezing and resuming a paused run from the host.