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.

The allocator handshake The host calls alloc to obtain a region, writes the input into it, calls run, and receives a packed pointer and length describing the output. Every byte that crosses does so through memory the plugin itself allocated. host plugin alloc(4096) ptr = 0x10a40 write bytes, then run(ptr, len) packed (out_ptr << 32) | out_len Four calls, no shared types, no serialisation library on either side — which is what lets a plugin be written in any language at all.

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.

The path a plugin author takes A language template compiles to a valid module, a local harness runs it against the real host interface, and only then does the plugin reach the registry. Each step exists to move failures earlier. language template compiles as-is local harness real import object import + limit check automated, at submission published versioned, signed Every failure caught by the harness is a support conversation that never happens, which is the difference between a plugin system and a plugin project.

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.

The shape of one plugin call The host allocates inside the plugin's memory, writes the input, calls a single entry point, and reads a length-prefixed result from the pointer it returns. host allocates in plugin memory writes input bytes at the pointer plugin entry point one exported function result pointer length-prefixed out One entry point is easier to version than many; dispatch inside the plugin on a name in the input. The plugin's allocator owns both buffers, so the host must call back to free them when it is done. A length prefix rather than a terminator lets a result contain arbitrary bytes, including zeros.

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.

← Back to Plugin Systems & Extensibility