Building a Plugin Host with Extism
This guide answers one task: stand up a working WebAssembly plugin system using Extism rather than hand-writing the allocator handshake, the limits and the language bindings — and understand what that choice costs.
Prerequisites
- [ ] Node 18+ or a browser build, plus
@extism/extism. - [ ] A plugin development kit for whichever language plugin authors will use.
- [ ] A clear idea of your host functions; the framework does not choose them for you.
- [ ] A plugin to test with, even a trivial one.
What the framework actually provides
Extism sits between a host and a .wasm module and supplies the parts every plugin system needs and
nobody enjoys writing: a memory protocol for passing arbitrary-length inputs and outputs, a consistent
way to declare and call host functions, resource limits, and development kits that make the plugin side
a few lines in each supported language.
What it does not provide is your interface. The functions you expose, the payload format, the version policy and the validation of results are still design decisions — the framework removes the plumbing, not the thinking. That is the right division, and it is worth being clear about it before adopting one, because a framework that appeared to answer the interface question would be answering it badly.
A host in a dozen lines
The host side loads a module, sets limits, and calls a named export with a byte payload.
import createPlugin from '@extism/extism';
const plugin = await createPlugin('./plugins/formatter.wasm', {
useWasi: false, // no filesystem, no clock, no environment
allowedHosts: [], // no outbound network
config: { locale: 'en-GB' }, // string config the plugin can read
runInWorker: true, // browser: keeps the main thread free
timeoutMs: 2000,
memory: { maxPages: 256 },
});
const out = await plugin.call('format', JSON.stringify({ text: 'hello' }));
const result = JSON.parse(out.text());
await plugin.close();
useWasi: false is the important default to set deliberately. With WASI enabled the plugin gets a
filesystem abstraction, environment variables and a clock, which is convenient and is exactly the ambient
authority a plugin sandbox exists to withhold. Turn it on only when a specific plugin needs it and you
have decided what it may see.
Host functions, which are still capabilities
Extism lets the host expose functions the plugin can import. The framework handles the marshalling; the decision about what to expose is unchanged from a hand-rolled design, and the same discipline applies.
const plugin = await createPlugin(wasmUrl, {
functions: {
'extism:host/user': {
lookup_price(currentPlugin, offset) {
const sku = currentPlugin.read(offset).text();
const price = catalogue.get(sku); // host owns the data
if (price === undefined) return currentPlugin.store('');
return currentPlugin.store(String(price));
},
},
},
});
Notice that the function is narrow: it looks up a price by identifier from a catalogue the host holds. A
more general query_database(sql) would be easier to write and would hand the plugin your database.
Frameworks make it easy to expose either; the judgement is yours.
The plugin side in three languages
The development kits are what make an ecosystem practical, because a plugin author does not have to understand the memory protocol at all.
// Rust
use extism_pdk::*;
#[plugin_fn]
pub fn format(input: String) -> FnResult<String> {
let cfg = config::get("locale")?.unwrap_or_else(|| "en".into());
Ok(format!("[{cfg}] {}", input.trim()))
}
// Go (TinyGo)
//export format
func format() int32 {
input := pdk.InputString()
pdk.OutputString("[go] " + strings.TrimSpace(input))
return 0
}
// JavaScript, compiled with the js-pdk
export function format() {
const input = Host.inputString();
Host.outputString('[js] ' + input.trim());
}
Three languages, the same interface, no hand-written allocator on any of them. Publishing a template per language remains worth doing — the kits remove the protocol, not the project setup.
Limits and what they actually enforce
The limits available depend on the runtime underneath. In a browser, the timeout is implemented by terminating a worker, so it is reliable but coarse: the plugin is gone and there is no partial result. On a server backed by Wasmtime, timeouts use epoch interruption and can be complemented by fuel metering for deterministic budgets.
const plugin = await createPlugin(wasmUrl, {
timeoutMs: 1500,
memory: { maxPages: 128, maxHttpResponseBytes: 0, maxVarBytes: 65536 },
allowedPaths: {}, // nothing mounted
allowedHosts: [], // nothing reachable
});
Set every one of these explicitly, including the ones whose defaults are already what you want. An explicit zero is a statement a reviewer can check; an omitted option is a question nobody answered.
Managing plugin lifetime in a server
In a browser, a plugin usually lives for one user action. On a server handling many requests, the lifetime question becomes a real design decision with a cost attached to each answer.
Creating a plugin per request is the safest and costs compilation on every one, which for a 61 kB module is 8–15 ms — acceptable for an infrequent operation, far too much for a hot path. Caching the compiled artifact and creating only a fresh instance per request brings that down to well under a millisecond while keeping the isolation, and is the arrangement to reach for by default.
Keeping a long-lived instance per plugin is fastest and reintroduces state between requests: whatever the
previous caller left in linear memory is visible to the next. That is acceptable only when all callers
belong to the same tenant and the plugin is trusted to be stateless, and it should be a deliberate,
documented exception rather than the default.
const cache = new Map(); // plugin id → compiled artifact
async function forRequest(id) {
if (!cache.has(id)) cache.set(id, await compilePlugin(id));
return instantiate(cache.get(id), perRequestOptions); // fresh memory every time
}
Whichever you choose, close instances explicitly. A server that creates plugins and never closes them leaks memory at a rate proportional to traffic, and the symptom — a slow climb in resident size over hours — is easy to attribute to everything except the plugin host.
Expected output
A working host prints the plugin’s response and the resources it used:
plugin formatter.wasm (61 kB)
config locale=en-GB
call format → 4.2 ms
result {"text":"[en-GB] hello"} (schema ok)
limits timeout 2000 ms, maxPages 256, hosts []
A timeout produces a clean rejection rather than a hang, which is the behaviour to verify first with a deliberately looping fixture:
call format → rejected after 1500 ms (timeout)
plugin disabled after 3 consecutive faults
What you give up
Three things, all worth weighing.
A dependency, with its own release cadence and its own bugs, sitting in the security-critical path of your application. That is an ordinary trade, but it is a real one for a component whose whole purpose is containment.
Some control over the memory protocol. The framework’s input and output convention is fixed, which is fine until you want something it does not express — streaming, for instance, or passing a large buffer without a copy.
And a layer between you and the runtime’s own knobs. Fuel metering, custom resource limiters and runtime-specific features are reachable only as far as the framework exposes them.
Against that: a working plugin system in an afternoon, plugin kits for languages you would otherwise have to support yourself, and a protocol that has been debugged by other people. For most applications that trade is clearly worth making, and hand-rolling makes sense mainly when the interface has requirements the framework’s protocol cannot express.
Gotchas
- WASI left enabled. The plugin gets a filesystem and clock you did not intend to grant.
allowedHostswith a wildcard. Grants outbound network to every plugin, permanently.- Not closing plugins. Instances hold memory; close them when done, especially in a long-lived server process.
- Assuming the framework validates output. It does not know your schema. Validate every result.
- Relying on host function ordering. Calls arrive as the plugin makes them; do not assume a sequence.
- Different behaviour between the browser and server runtimes. Test both if you ship both; the limits are enforced by different mechanisms.
Performance note
Loading a 61 kB plugin takes 8–15 ms including compilation, and a call with a small JSON payload costs roughly 0.2–0.5 ms of overhead on top of the plugin’s own work — the memory protocol plus the JSON parse on each side. That overhead is irrelevant for plugins doing real work and noticeable for very frequent tiny calls, where batching several items into one call recovers most of it.
Frequently Asked Questions
Should I use a framework or hand-roll the interface? Use a framework unless you have a specific requirement it cannot meet. The plumbing is not where a plugin system’s value lies, and the language kits are worth a great deal on their own.
Does it work in the browser? Yes, with the browser build, and running plugins in a worker is supported directly — which matters, because the timeout depends on being able to terminate something.
Can I migrate from a hand-rolled interface later? In both directions, with effort. The plugin-facing protocol changes, so existing plugins need recompiling — which is precisely the kind of change the ABI version export exists to make survivable.
Related
- Designing a Wasm plugin interface — what to build if you do not use a framework.
- Limiting plugin CPU and memory use — the limits underneath these options.
- Choosing between wasmtime, wasmer and WasmEdge — the runtimes a server-side host sits on.
← Back to Plugin Systems & Extensibility