← Back to Lessons Lesson 38 of 48
Advanced getting-started

Building a Wasm Plugin System

Why Wasm for Plugins?

WebAssembly is becoming the standard runtime for plugin systems because it offers a unique combination of safety, performance, and portability:

  Traditional Plugin         Wasm Plugin
  (Native Code)              (Sandboxed)

  ┌─────────────────┐       ┌─────────────────────────────┐
  │  Host Process    │       │  Host Process               │
  │  ┌─────────────┐│       │  ┌────────────────────────┐ │
  │  │ Plugin (DLL) ││       │  │ Wasm Runtime           │ │
  │  │             ││       │  │ ┌──────────┐           │ │
  │  │ Full access ││       │  │ │ Plugin   │ sandboxed │ │
  │  │ to memory,  ││       │  │ │ .wasm    │ linear    │ │
  │  │ filesystem, ││       │  │ │          │ memory    │ │
  │  │ network ... ││       │  │ └──────────┘           │ │
  │  └─────────────┘│       │  │  No fs, no net, no     │ │
  │                  │       │  │  raw memory access     │ │
  │ ⚠ Crash = host  │       │  └────────────────────────┘ │
  │   crash          │       │  ✓ Crash = plugin only     │
  └─────────────────┘       └─────────────────────────────┘
Property Native Plugin (DLL/SO) Wasm Plugin
Sandboxed No Yes
Language-agnostic No (ABI-dependent) Yes (any lang -> Wasm)
Portable No (per-OS build) Yes (one binary)
Near-native speed Yes Yes (~0.8-0.95x native)
Memory safe Depends on language Yes (linear memory)
Hot-reloadable Difficult Easy (re-instantiate)
Resource limits Hard Built-in (fuel, memory)

Real-World Examples

Product How They Use Wasm Plugins
Figma Runs community plugins in a Wasm sandbox inside the app
VS Code Extension host can run Wasm extensions (web & desktop)
Envoy Proxy filters as Wasm modules (network policy, auth, etc.)
Shopify Functions (discounts, shipping) run as Wasm
Zed Editor extensions (themes, languages) as Wasm
Fermyon Spin framework: entire microservices as Wasm components

Architecture of a Wasm Plugin System

  ┌───────────────────────────────────────────────────────┐
  │                     Host Application                   │
  │                                                       │
  │  ┌─────────────┐  ┌────────────┐  ┌───────────────┐  │
  │  │ Plugin      │  │ Plugin     │  │ Plugin        │  │
  │  │ Registry    │  │ Loader     │  │ Lifecycle Mgr │  │
  │  │             │  │            │  │               │  │
  │  │ - manifest  │  │ - compile  │  │ - init()      │  │
  │  │ - versions  │  │ - validate │  │ - on_event()  │  │
  │  │ - deps      │  │ - instantiate│ │ - shutdown()  │  │
  │  └─────────────┘  └────────────┘  └───────────────┘  │
  │                         │                             │
  │                         ▼                             │
  │  ┌────────────────────────────────────────────────┐   │
  │  │              Wasm Runtime (wasmtime / wasmer)   │   │
  │  │                                                │   │
  │  │  ┌──────────┐  ┌──────────┐  ┌──────────┐     │   │
  │  │  │Plugin A  │  │Plugin B  │  │Plugin C  │     │   │
  │  │  │ .wasm    │  │ .wasm    │  │ .wasm    │     │   │
  │  │  │          │  │          │  │          │     │   │
  │  │  │ 1MB mem  │  │ 2MB mem  │  │ 1MB mem  │     │   │
  │  │  │ limit    │  │ limit    │  │ limit    │     │   │
  │  │  └──────────┘  └──────────┘  └──────────┘     │   │
  │  └────────────────────────────────────────────────┘   │
  └───────────────────────────────────────────────────────┘

Host-Guest Communication

The host and plugin communicate through imported and exported functions:

  Host                              Plugin (.wasm)
  ┌──────────────────┐             ┌──────────────────┐
  │                  │  exports    │                  │
  │  call plugin ────┼────────────>│ on_event()       │
  │  functions       │             │ on_init()        │
  │                  │             │ alloc() / free() │
  │                  │  imports    │                  │
  │  host_log()  <───┼────────────│ call host        │
  │  host_fetch()<───┼────────────│ functions         │
  │  host_kv_get()<──┼────────────│                  │
  └──────────────────┘             └──────────────────┘

With wasmtime (Rust host)

use wasmtime::*;

fn load_plugin(engine: &Engine, path: &str) -> Result<Instance> {
    let module = Module::from_file(engine, path)?;
    let mut store = Store::new(engine, ());
    let mut linker = Linker::new(engine);

    // Provide host functions to the plugin
    linker.func_wrap("env", "host_log", |caller: Caller<'_, ()>, ptr: i32, len: i32| {
        // Read string from plugin's linear memory
        let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
        let data = &memory.data(&caller)[ptr as usize..(ptr + len) as usize];
        let msg = std::str::from_utf8(data).unwrap();
        println!("[plugin log] {}", msg);
    })?;

    let instance = linker.instantiate(&mut store, &module)?;
    Ok(instance)
}

Calling Plugin Functions

fn call_plugin_event(
    store: &mut Store<()>,
    instance: &Instance,
    event: &str,
) -> Result<i32> {
    let memory = instance.get_memory(&mut *store, "memory").unwrap();
    let alloc = instance.get_typed_func::<i32, i32>(&mut *store, "alloc")?;

    // Allocate space in plugin memory and copy the event string
    let ptr = alloc.call(&mut *store, event.len() as i32)?;
    memory.data_mut(&mut *store)[ptr as usize..ptr as usize + event.len()]
        .copy_from_slice(event.as_bytes());

    // Call the plugin's on_event function
    let on_event = instance
        .get_typed_func::<(i32, i32), i32>(&mut *store, "on_event")?;
    let result = on_event.call(&mut *store, (ptr, event.len() as i32))?;

    Ok(result)
}

WIT: WebAssembly Interface Types

WIT (Wasm Interface Type) defines the contract between host and guest in a language-agnostic way:

// plugin.wit — the interface definition

package my-app:plugin@1.0.0;

interface host {
    /// Log a message to the host's console
    log: func(message: string);

    /// Read a key-value pair from host storage
    kv-get: func(key: string) -> option<string>;

    /// Write a key-value pair to host storage
    kv-set: func(key: string, value: string);
}

interface plugin {
    /// Called when the plugin is loaded
    init: func(config: list<tuple<string, string>>);

    /// Called for each event
    on-event: func(name: string, payload: string) -> option<string>;

    /// Called before the plugin is unloaded
    shutdown: func();
}

world my-plugin {
    import host;
    export plugin;
}

wit-bindgen generates glue code:

// In the plugin crate (guest side)
wit_bindgen::generate!({
    world: "my-plugin",
    exports: {
        "my-app:plugin/plugin": MyPlugin,
    },
});

struct MyPlugin;

impl exports::my_app::plugin::plugin::Guest for MyPlugin {
    fn init(config: Vec<(String, String)>) {
        host::log(&format!("Initialized with {} config entries", config.len()));
    }

    fn on_event(name: String, payload: String) -> Option<String> {
        host::log(&format!("Got event: {}", name));
        Some(format!("processed: {}", payload))
    }

    fn shutdown() {
        host::log("Goodbye!");
    }
}

Resource Limits and Sandboxing

Wasm runtimes provide fine-grained control over plugin resources:

Memory Limits

let mut config = Config::new();
let engine = Engine::new(&config)?;

let memory_type = MemoryType::new(1, Some(16)); // 1 page min, 16 pages max (1 MB)
let memory = Memory::new(&mut store, memory_type)?;

Fuel (CPU Limits)

let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;

let mut store = Store::new(&engine, ());
store.set_fuel(1_000_000)?;  // 1M instructions

// Plugin runs until fuel is exhausted
let result = on_event.call(&mut store, (ptr, len));
match result {
    Err(e) if e.to_string().contains("fuel") => {
        println!("Plugin exceeded CPU budget!");
    }
    _ => {}
}

Capability Table

  ┌──────────────────┬─────────┬─────────┬─────────┐
  │ Capability       │ Default │ Granted │ Denied  │
  ├──────────────────┼─────────┼─────────┼─────────┤
  │ Linear memory    │ 1 page  │ ≤ 16 pg │ > 16 pg │
  │ CPU (fuel)       │ 1M      │ Custom  │ 0       │
  │ Host functions   │ None    │ Linked  │ Omitted │
  │ File system      │ None    │ WASI    │ Default │
  │ Network          │ None    │ WASI    │ Default │
  │ System clock     │ None    │ WASI    │ Default │
  └──────────────────┴─────────┴─────────┴─────────┘

Plugin Discovery and Loading

A practical system needs a way to discover, validate, and load plugins:

struct PluginManifest {
    name: String,
    version: String,
    wasm_path: String,
    permissions: Vec<String>,
    min_host_version: String,
}

struct PluginRegistry {
    manifests: HashMap<String, PluginManifest>,
    loaded: HashMap<String, Instance>,
}

impl PluginRegistry {
    fn discover(&mut self, plugin_dir: &str) -> Vec<String> {
        // Scan directory for plugin.toml files
        // Parse manifests, validate signatures
        // Return list of discovered plugin names
        vec![]
    }

    fn load(&mut self, name: &str, engine: &Engine) -> Result<(), String> {
        let manifest = self.manifests.get(name).ok_or("Not found")?;

        // Validate permissions
        for perm in &manifest.permissions {
            if !self.is_permission_allowed(perm) {
                return Err(format!("Permission denied: {}", perm));
            }
        }

        // Compile and instantiate
        // ...
        Ok(())
    }

    fn is_permission_allowed(&self, _perm: &str) -> bool { true }
}

Hot Reloading

One of Wasm's strengths is easy hot-reload — drop the old instance, load the new one:

fn hot_reload(registry: &mut PluginRegistry, name: &str, engine: &Engine) {
    // 1. Call shutdown on old instance
    // 2. Drop old Instance (reclaims all memory)
    registry.loaded.remove(name);

    // 3. Load new .wasm file
    registry.load(name, engine).unwrap();

    // 4. Call init on new instance
    // Plugin state is fresh — no stale pointers or leaked memory
}

Security Considerations

  Attack Surface Comparison:

  Native Plugin              Wasm Plugin
  ───────────────            ────────────────
  ✗ Buffer overflow          ✓ Linear memory bounds-checked
  ✗ Use-after-free           ✓ No raw pointers exposed
  ✗ Arbitrary syscalls       ✓ Only imported functions
  ✗ File system access       ✓ Explicit WASI capabilities
  ✗ Network access           ✓ Must be granted
  ✗ Shared memory corruption ✓ Isolated address space
Threat Mitigation
Infinite loop Fuel / instruction limits
Memory exhaustion Memory page limits
Malicious host calls Validate all arguments from plugin
Data exfiltration No network unless explicitly granted
Side-channel timing Disable clock access or add jitter

Summary

Wasm plugin systems combine the performance of native code with the safety of sandboxed execution. The host defines a trait-like interface (or WIT contract), loads .wasm modules at runtime, and enforces resource limits. Real products like Figma, Envoy, and VS Code prove the model works at scale. Use wasmtime or wasmer as your runtime, wit-bindgen for ergonomic interfaces, and fuel/memory limits for safety.

Try It