Designing a Wasm Plugin Interface
This guide answers one task: write down the contract a plugin author implements — which functions they export, which the host provides, and exactly how bytes move between the two — in a form that works from any source language and survives a few years of change.
Prerequisites
- [ ] A decision about what plugins are for; see the topic overview.
- [ ] A host that can instantiate modules with a controlled import object.
- [ ] At least two source languages in mind, so the design does not accidentally depend on one.
- [ ] A willingness to write the first three plugins yourself.
The minimal export set
Four exports are enough for a useful interface, and every one of them earns its place.
memory — the plugin's linear memory, exported so the host can read and write it
abi_version() — an integer the host checks before anything else
alloc(size) — returns a pointer to size writable bytes
run(ptr, len) — the entry point; returns a packed pointer and length
memory must be exported rather than imported if you want the plugin to control its own growth; import
it instead if you need to impose a maximum, which for untrusted plugins you do. Pick one and state it in
the specification, because a plugin built the other way will fail to instantiate with a link error that
says nothing about which side is wrong.
alloc is what makes data transfer possible at all: the host cannot safely place bytes anywhere in the
plugin’s memory without asking, because everything except what the allocator hands out may be in use.
Returning a pointer and a length from one function
WebAssembly functions return a single value in the MVP, so returning both a pointer and a length needs a convention. Two work well and both are easy in every language.
Pack them into an i64: the pointer in the high 32 bits, the length in the low 32. This is a single
return value, costs nothing, and is trivial to unpack on both sides:
#[no_mangle]
pub extern "C" fn run(ptr: *const u8, len: usize) -> u64 {
let input = unsafe { std::slice::from_raw_parts(ptr, len) };
let output = process(input); // Vec<u8>
let out_len = output.len() as u64;
let out_ptr = output.as_ptr() as u64;
std::mem::forget(output); // ownership moves to the host
(out_ptr << 32) | out_len
}
const packed = instance.exports.run(inPtr, input.length);
const outPtr = Number(packed >> 32n);
const outLen = Number(packed & 0xffffffffn);
const result = new Uint8Array(memory.buffer, outPtr, outLen).slice(); // copy before freeing
instance.exports.dealloc(outPtr, outLen);
The alternative is writing the length into a fixed location the host reads afterwards, which avoids 64-bit values in languages where they are awkward. Either is fine; specifying which is mandatory.
Define errors as data, not traps
A plugin that cannot do its job should say so, and a trap is a poor way to say anything — it loses the message and cannot be distinguished from a genuine bug. Reserve traps for faults and define a result envelope for everything else.
result := status:u8, then either payload bytes (status 0) or a UTF-8 message (status 1)
fn ok(mut data: Vec<u8>) -> u64 { let mut v = vec![0u8]; v.append(&mut data); pack(v) }
fn err(msg: &str) -> u64 { let mut v = vec![1u8]; v.extend_from_slice(msg.as_bytes()); pack(v) }
The host then distinguishes three outcomes cleanly: a successful result, a plugin-reported failure with an explanation it can show the user, and a trap, which it records as a fault against that plugin. That three-way split is what makes the system operable once there are more than a handful of plugins.
Host functions: narrow, explicit, capped
The import list is the plugin’s entire view of the outside world, and each entry should be the narrowest function that does the job.
const hostImports = {
host: {
log: (ptr, len) => {
if (logBytes > LOG_CAP) return; // the host caps accumulation
const s = readUtf8(memory, ptr, Math.min(len, 4096));
logBytes += s.length; sink.push(s);
},
config_len: () => configBytes.length,
read_config: (ptr, cap) => writeBytes(memory, ptr, Math.min(cap, configBytes.length), configBytes),
now_ms: () => Math.floor(Date.now() / 100) * 100, // deliberately coarse
},
};
Notice what is capped: the log length per call, the total accumulated, and the resolution of the clock. Each cap exists because a plugin author will eventually do the thing it prevents, usually by accident.
Publish a template, not just a specification
A specification document produces a slow trickle of plugins; a working template in each supported language produces an ecosystem. The template should compile to a valid plugin, implement the interface correctly, include a test that runs it against a local harness, and be short enough to read in one sitting.
# what a plugin author should be able to do in under five minutes
git clone https://example.com/plugin-template-rust
cd plugin-template-rust
cargo build --release --target wasm32-unknown-unknown
node ../harness/run.mjs target/wasm32-unknown-unknown/release/plugin.wasm fixtures/input.json
# → { "ok": true, "output": { ... } }
The harness matters as much as the template. Plugin authors cannot debug inside your application, so give them a command-line runner that instantiates their module with the real import object and prints what the host would have seen.
Writing the specification down
The document plugin authors read is part of the interface, and it should be short, precise and example-led. Four sections cover it.
State the required exports with exact names, signatures and semantics, including who owns each buffer and
when it may be freed. State the provided imports the same way, noting any caps — a plugin author needs to
know that log truncates at four kilobytes before they discover it by losing output. Specify the wire
format of the input and output payloads, with a worked example of each as literal bytes or JSON. And
state the limits: the time budget, the memory maximum, the maximum payload size, and what happens when
each is exceeded.
Everything else — motivation, tutorials, a gallery — is useful but secondary. What an author needs at three in the afternoon is the exact signature and the exact ownership rule, and a specification that buries those inside prose will generate the same three support questions forever.
Keep the document versioned alongside the interface itself, with a changelog that records every addition. When a plugin misbehaves against version 3 of the contract, being able to read version 3 rather than today’s version is what makes the conversation short.
Expected output
Running the harness against a correct plugin shows the whole handshake, which is also the best documentation an author can have:
module : plugin.wasm (48 kB)
imports : host.log, host.config_len, host.read_config, host.now_ms [allowed]
exports : memory, abi_version, alloc, dealloc, run
abi : 1 (host supports 1)
alloc : 4096 → ptr 0x10a40
run : 2.1 ms → status 0, 812 bytes
output : {"items":[…]} (valid against schema)
An author whose plugin fails at the imports line knows immediately that they pulled in a library
expecting WASI. That error is much less obvious from inside a host application that simply refuses to
load the module.
Gotchas
- Memory exported when the host wanted to import it, or the reverse. Specify it; the failure is a link error that names neither side helpfully.
- Allocator missing. A module compiled with a language that strips unused exports may drop
alloc. Mark exports so they survive dead-code elimination. - Ownership of the output buffer unspecified. Say who frees it and when. Leaking one buffer per call is a slow, confusing failure.
- Strings assumed to be null-terminated. Use explicit lengths; not every language produces C strings.
- Structs passed by layout. Different languages pad differently. Pass bytes with an explicit encoding.
- Interface growing by changing signatures. Add new exports and imports instead; never repurpose an existing name.
Performance note
The handshake itself is cheap: alloc plus a write plus run plus a read costs under 20 microseconds for
a few kilobytes of payload, dominated by the copies rather than the calls. What costs is calling per item
— a thousand small run calls take roughly 40 times longer than one run over a thousand items,
because each call repeats the allocation, the copy and the result decoding. Design the entry point to
accept a batch from the beginning; it is a one-line difference in the specification and a large one in
practice.
Frequently Asked Questions
Should the payload be JSON or a binary format? Start with JSON. Every language can produce it, plugin authors can debug it by eye, and the parse cost is irrelevant next to everything else unless payloads are large or calls are very frequent. Move to a binary encoding when a profile says so, and version the interface when you do.
How do I let a plugin maintain state between calls? Prefer not to, and pass state in and out instead. If it is genuinely required, keep the instance alive per tenant and document that plugins must tolerate being reinstantiated at any time — because they will be.
Can the host call several exports on one plugin?
Yes, and a richer interface often does: init, run, flush. Keep the set small and required, with
optional exports detected by their absence rather than declared separately, so older plugins keep working
when you add one.
Related
- Versioning a Wasm plugin API — evolving this contract safely.
- Loading untrusted plugins safely — the validation that runs before
run. - Encoding strings across the Wasm boundary — the byte-level mechanics.
← Back to Plugin Systems & Extensibility