ABI
Sealed contract, plugin ABI v1. Every signature, op code, tag, and error kind here is the public contract for plugin modules, shipped as
.wasmover CDN and loaded by the JS host or the CLI. System calls arrive as theSysop, andedge_call_idis the one import added since, so a plugin that never waits on one still loads on any v1 host. A future wire-level break would ship asenv_v2.*without removing v1. This contract is distinct from thecompiler<->hostinterface an embedder implements, which is not bound by the 7-import limit here.
A plugin module imported via from "<url>" import <names> follows the contract below, compiled to .wasm for the CDN, which the JS host and the CLI both load. The API is handle-based: the host owns all values, the guest sees only opaque u32 handles, and 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 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 (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), 2 = waiting on a system call (the host parks the coroutine and hands the answer to __edge_resume once it settles). |
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.
The #[plugin_fn] macro exports free functions as __fn_<name> and the host strips the prefix. It also recognizes the __const_<name> and __class_<Name>_<method> conventions described below. Plain-named exports work too.
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, and without it staging accumulates for the instance’s lifetime.
A guest that waits on a system call also exports __edge_resume(call: u32, answer: u32, out: *mut u32) -> i32, see System calls.
__edge_abi_version returns the wire-format version (currently 1). Both hosts read it and refuse a mismatch, so a v2 module never decodes garbage on a v1 host.
The reference wasm-pdk crate emits all three symbols automatically. EDGE_ABI_VERSION lives in the shared wasm-abi crate (no_std, zero deps) so the host and every PDK read the same value.
Host imports (7 functions)
The guest declares these 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. 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 (rc=1, release when done). ptr/len describe bytes in guest memory, and the host copies. Returns 0 on an invalid tag or when called outside a run.
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 (the tag is still written, so re-allocate and retry). On an invalid handle or a non-transit value (set, instance, cyclic composite), returns 0 with *out_tag = 0xFFFFFFFF. Walk those with edge_op.
edge_release
Decrements a refcount. No-op for handle 0 or an already-released handle.
edge_take_error
Drains the most recent error from a 1-returning call. Writes the kind at *out_kind and copies the UTF-8 message into dst[..dst_max]. Returns bytes copied (>= 0), -bytes_needed if the buffer was too small (the error stays pending), or -1 if no error is pending.
edge_throw
Stashes an error visible after the guest returns 1. Use it when the error did not originate from a 1-returning edge_op (for example a typed Result::Err from user code). Overwrites any pending error. The guest must immediately return 1.
edge_call_id
The id of the call the host is running in the plugin right now, 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 | 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 |
Sys | 14 | the system call name, such as net.request, recv is the id from edge_call_id -> handle, or 2 while it waits |
Op::Iter materialises the receiver into a List handle: a set yields items in hash-table order, a dict yields keys, a str splits to single-char strings, a bytes yields its bytes as ints. Op::IterNext advances it.
TypeOf returns the builtin type names: "int", "float", "str", "bytes", "list", "dict", "set", "tuple", "NoneType", "bool", and so on. A user instance returns its class name.
Values 15..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. 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 (
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 (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 through the 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 | 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, served exactly as a .py beside the .wasm would import them. It holds the grants of the package it sits in, and a module that package is not granted fails with PermissionError.
A call that answers at once returns 0 with the value. One that waits returns 2, the plugin returns 2 from its own export, and the host parks the coroutine. Once the system call settles, the host calls __edge_resume with the same call id and a handle to the answer, or 0 with the error left for edge_take_error. What __edge_resume returns is what the program sees, 0 a value, 1 an error, and 2 another wait on the same call.
#[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.
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 = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0" }Pinning to a tag (rather than branch = "main") 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. Bump tag and run cargo update -p wasm-pdk to upgrade.
Build:
cargo build --release --target wasm32-unknown-unknown
# -> target/wasm32-unknown-unknown/release/slugify_mod.wasmUse it from a script, 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-negativeExposing Rust structs as classes
#[plugin_class] + #[plugin_methods] expand to __class_<Name>_<method> exports. The host detects them by naming convention and synthesises a class. State lives in a guest-side BTreeMap<id, T>, and 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))
}
}From Edge Python, same manifest alias:
from slugify_mod 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 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 rather than a callable:
#[plugin_const]
fn pi() -> f64 { core::f64::consts::PI }3.141592653589793
Variadic functions
A trailing Args param captures every positional argument 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))
}Worked example, raw, no 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
}Same Cargo.toml as the wasm-pdk example, minus wasm-pdk, plus lol_alloc = "0.4". Plain-named exports like this slugify are picked up as free functions. The module imports from scripts the same way.
How the host loads it
For from <name> import <names> where the manifest maps <name> to a .wasm URL, the host:
- Fetches the bytes, which the engine holds to any
#sha256-...fragment. - Instantiates with the 7 host imports.
- Walks the export table.
- Marshals args as handles.
- Propagates results.
The reference bridge is js/src/native.ts in the repo, and the CLI implements the same seven imports in Rust over wasmtime, bridging the plugin instance and the compiler instance the same way. The CLI compiles a plugin with Cranelift on first use and caches the machine code. Rust embedders mirror the shape.
Constraints and caveats
- Refcounted handles. The guest releases every handle it creates via
edge_encode/edge_op, except the one returned through*out. The host releasesargvhandles. edge_decodecovers primitives pluslist/tuple/dict(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: 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. - Invoking a caller-supplied callable. From the guest,
edge_op(Call, recv, "__call__", ...)invokesrecvdirectly. Lambdas, builtins, classes, and bound methods all route through the same dispatch the language uses. Use this for hooks likedefault,object_hook,parse_int. Positional arguments cap at 255. - Reentrance supported. A guest’s
edge_opruns while the VM is paused on the script’sCallExtern. Method dispatch routes through the same method table the language uses internally, so 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 exception. - Memory ownership. The host reads guest linear memory only at well-defined copy points. Guest-internal allocations 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, so recycle the worker periodically for unbounded streaming.
Author conveniences
The wasm-pdk crate (Plugin Development Kit), bundled in this repo and 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.#[plugin_class]/#[plugin_methods]/#[plugin_ctor]: expose a Rust struct as a class via the__class_<Name>_<method>export convention.module!(): expands to#[global_allocator]+#[panic_handler].module_fixed_pool!()(ormodule_fixed_pool!(bytes)): the same, but allocating from a fixed-size static pool (4 MiB by default) that never callsmemory.grow. The bundledstdpackages use it.FromValue/IntoValuewith primitive impls (i64,i128,f64,bool,String,&str,Bytes,Option<T>,Handle,Value).i64rejects out-of-range values withValueError, so usei128for the full range.Bytesmaps tobytesover theRawtag.Vec<Value>andVec<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, and 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. Writing it manually costs about 25 lines for the first function and about 5 per additional one.
Community PDKs (uncoordinated releases, each tracking this sealed spec): Zig (wasm-pdk-zig), AssemblyScript (wasm-pdk-as), C (wasm-pdk.h).
Driver 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 shape a run and to freeze and revive a paused one. The host-facing feature is Snapshots. They follow the conventions of the run lifecycle (run_start / run_resume / run_push_event) and share its packed status word.
| Export | Signature | Meaning |
|---|---|---|
set_limits | (memory: u64, ops: u64) | Caps for the next run_start or repl_eval, memory in bytes. A zero field keeps the sandbox value. restore_state keeps the limits embedded in the blob. |
set_entry | (ptr: *const u8, len: u32) | Name the script the next run starts from. Tracebacks name its frame after it, and its directory roots the walk and the relative imports of the program. An empty entry, or a directory ending in /, runs from that directory and renders its frame as <input>. |
memory_peak | () -> u64 | The most the last run held at once, in bytes, by the memory model the limit counts, garbage not yet collected included. A run that fails to compile reads 0. |
save_state | () -> i64 | Serialise the parked run into the out buffer. Returns the blob length, or -1 when nothing is parked. |
restore_state | (ptr: *const u8, len: u32) -> u32 | Boot a VM from the blob at ptr and overlay its state. Returns the same packed status word as run_start. |
state_globals | () -> u32 | Write the parked run’s module-level bindings as JSON into the out buffer. Returns its byte length. |
state_stack | () -> u32 | 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, disabled. Applies to the next run_start / restore_state. |
set_wall_clock | (on: u32) | 1 sleeps on the host’s clock, 0 keeps the virtual clock, for a run no package was granted time. Defaults to 1. Applies to the next run_start / restore_state. |
vm_create | () -> u32 | Add an interpreter slot and return its id. |
vm_select | (id: u32) -> i32 | Point every per-run export at slot id. Returns 0, or 1 when no such slot exists. |
vm_drop | (id: u32) -> i32 | Free slot id and its run. Returns 0, or 1 when there is nothing to drop. Slot 0 always stays. |
register_module_error | (spec_ptr: *const u8, spec_len: u32, msg_ptr: *const u8, msg_len: u32) | Refuse a spec or bare name. Importing it fails at compile time, at the import, with msg. |
floor_error | (ptr: *const u8, len: u32) -> u32 | Write why this engine cannot run the edge.json at ptr, a floor above its own version, into the out buffer. Returns its byte length, 0 when it can. |
manifest_check | (m_ptr: *const u8, m_len: u32, l_ptr: *const u8, l_len: u32, s_ptr: *const u8, s_len: u32) -> u32 | Write why a registry turns away the edge.json at m_ptr, held with the edge.lock at l_ptr (empty when there is none) to the rules the CLI packs under, s_ptr the newline-joined system module names. Returns its byte length, 0 when it holds. |
bundle_index | (ptr: *const u8, len: u32) -> u32 | Write where each file of the .edge at ptr sits inside it, as {"entry": path, "files": [[path, offset, length], ...]}, or {"error": why} when it is not one, so a host reads a package’s files straight out of its bytes. Returns the JSON’s byte length. |
A system module registers per package, under system:<name>@<dir> where dir is the directory of that package’s edge.json. A bare name no manifest declares resolves there, to the module when the host registered it with register_native_module, or to its refusal with register_module_error.
set_limits and set_entry are read at boot, so a host calls them before run_start, and set_entry before walk_start too. The CLI passes each group’s limits and the script path through them.
Every input, a source, a name, or a blob, is a range the host allocates with wasm_alloc(size), fills, passes as (ptr, len), and frees with wasm_free(ptr, size) after the call. Every result lands in one output buffer that grows as needed, out_len() bytes at out_ptr(), valid until the next export call. Neither direction has a size cap.
An instance starts with slot 0 selected, so a host that runs one program never calls the slot exports. A host that runs many programs in one instance, as the CLI does for an actor group, creates a slot per interpreter and selects it before each call. The run, its stdin, limits, entry, preempt interval, and value handles belong to the selected slot. The registered modules and refusals belong to the instance and serve every slot.
- Save. Drive to a pause (
run_start, thenrun_resumeuntil aPENDING_*status), callsave_state(), and read that many bytes atout_ptr()when the result is non-negative. - 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), stage the blob, then call
restore_state(ptr, 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.
The resolution walk
The engine decides what a program reaches and the host only answers what it asks, so the browser and the CLI resolve through the same code. walk_start(src_ptr, src_len, sys_ptr, sys_len) begins from the entry source, beside the script set_entry named, with the newline-joined names of the system modules the host serves. Every walk export returns the length of the next step, JSON in the out buffer.
| Step | The host |
|---|---|
{"fetch": spec} | Reads the bytes behind spec, a manifest, a lock or a module, and answers walk_fetched(ptr, len, kind) with kind 0 and the bytes, 1 when nothing is there with an optional hint, or 2 with why the read failed. The engine checks any #sha256- pin itself. |
{"plugin": spec, "name": name} | Reads the plugin’s bytes with walk_plugin_bytes(), raw in the out buffer since one inside a package never reached the host, registers it with register_native_module, and answers walk_plugin(kind, ptr, len) with kind 0 once registered, 1 with why it failed, or 2 with why this host refuses it. |
{"system": {root, needed, dirs, permissions}} | Serves each system module to every [dir, package] in dirs under the root’s permissions, and answers walk_served(ptr, len) with its failures joined by NUL. needed says whether anything can reach one at all. |
{"undeclared": [names]} | Says which of these bare names, declared by no manifest, a registry has, and answers walk_known(ptr, len) with those joined by NUL. Each one fails at its import, with a help line that runs edge add when the registry has it and declares it otherwise. |
{"done": [failures]} | Nothing more to fetch, and the run fails with these when there are any. |
Code modules, the manifests the compiler resolves names through, and refusals register inside the engine as the walk meets them.
Blob layout
Little-endian, self-contained, versioned.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic, 0x4E535045 |
| 4 | 4 | format version, currently 2 |
| 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. This pins each blob to one program and one compiler build. A blob has no size cap. The serializer is src/vm/snapshot.rs in the repo, with internals in Design.
Consuming the release from a Rust crate
cargo wasm builds the edge-python crate as a cdylib, compiler.wasm. A Rust host can instantiate compiler.wasm and call the exports above directly, the same .wasm the JS host loads, with the host owning I/O. The crate builds no wasm of its own and fetches nothing at build time, so cargo build stays offline and reproducible. Take the artifact from the tagged GitHub Release, or from https://cdn.edgepython.com/compiler.wasm for the current main. The edge CLI is that host, it embeds the .wasm precompiled by wasmtime and implements the imports in cli/src/host.
# Downstream Cargo.toml
[dependencies]
edge-python = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0" }The runtime feature is the wrapper that makes compiler.wasm, its exports, host imports, allocator and panic handler, and cargo wasm turns it on. A wasm that links the crate as a library, such as a plugin that only parses, leaves it off and brings its own allocator and panic handler.
edge-python = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0", default-features = false }Vendor the matching compiler.wasm next to your own sources and pin it by checksum, the same way the CLI pins the modules it downloads. A release asset is immutable and the CDN path is not, so a checksum is the only thing that ties a build to a known engine.
To add native modules from a Rust host, implement the Resolver trait. See Modules.