Skip to Content
Course content

216: Building a Wasm Plugin System

Click on the "Edit" button in the top corner of the screen to edit your slide content.

One of the most powerful patterns I've used in production systems is the Wasm plugin architecture. It gives you the holy grail of extensibility: users can write plugins in any language that targets WebAssembly, but they run in a sandboxed environment where they can't accidentally (or intentionally) wipe your server's hard drive. For this lesson, we're going to build a simple text-processing engine. The host will pass a string to a plugin, and the plugin will "transform" it—in our case, we'll build a plugin that censors specific words.

Defining the Guest Plugin

I always start with the guest—the code that will actually be compiled to wasm32-unknown-unknown. Because Wasm doesn't have a built-in way to pass complex types like String or Vec across the boundary, we have to stick to primitive types. We'll use pointers and lengths, essentially treating Wasm memory as a big byte array.

// guest/src/lib.rs
#[no_mangle]
pub extern "C" fn censor_text(ptr: *mut u8, len: usize) {
    let slice = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
    let text = std::str::from_utf8_mut(slice).unwrap();
    
    // Simple logic: replace "secret" with "XXXXXX"
    // Note: In a real app, we'd handle length changes carefully.
    // For this example, we'll assume in-place replacement of equal length.
    if let Some(pos) = text.find("secret") {
        let bytes = unsafe { text.get_unchecked_mut(pos..pos+6) };
        bytes.copy_from_slice(b"XXXXXX");
    }
}

Setting Up the Host Runtime

On the host side, I'm using wasmtime. It's the industry standard for a reason. The host's job is to load the .wasm file, instantiate it, and provide the memory that the guest will operate on. I like to keep the host logic lean; it should just be a coordinator that hands data to the plugin and gets it back.

// host/src/main.rs
use wasmtime::*;

fn main() -> Result<()> {
    let engine = Engine::default();
    let module = Module::from_file(&engine, "plugin.wasm")?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let censor_fn = instance.get_typed_func::<(&mut [u8], usize), ()>(&mut store, "censor_text")?;
    
    let mut data = "This is a secret message".as_bytes().to_vec();
    let memory = instance.get_memory(&mut store, "memory")
        .ok_or(Error::msg("No memory found"))?;

    // This is where I usually trip up...
    censor_fn.call(&mut store, (&mut data, data.len()))?;
    
    Ok(())
}

The Memory Wall (And How I Hit It)

When I first ran the code above, it crashed immediately. I realized I made a classic Wasm mistake: I tried to pass a Rust slice &mut [u8] from the host directly into the guest function. But &mut [u8] is a host-side pointer. The Wasm guest has its own isolated linear memory; it has no idea what a host-side pointer is. It can only see addresses within its own wasmtime::Memory.

To fix this, I have to actually write the data into the guest's memory first, then pass the offset of that memory to the function. It's a bit more boilerplate, but it's the only way to ensure the guest can actually touch the bytes.

Bridging the Gap with Memory Offsets

Here is the corrected host logic. Instead of passing the slice, I allocate space in the guest's memory, copy my string there, and pass the starting index.

// Corrected host logic
let mut data = "This is a secret message".as_bytes().to_vec();
let memory = instance.get_memory(&mut store, "memory")
    .ok_or(Error::msg("No memory found"))?;

// 1. Find where the guest wants the data (or allocate space)
// For simplicity, we'll write to the start of the Wasm memory
memory.write(&mut store, 0, &data)?;

// 2. Pass the offset (0) and the length
let censor_fn = instance.get_typed_func::<(u32, usize), ()>(&mut store, "censor_text")?;
censor_fn.call(&mut store, (0, data.len()))?;

// 3. Read the modified data back out of Wasm memory
let mut result = vec![0u8; data.len()];
memory.read(&store, 0, &mut result)?;
println!("Result: {}", String::from_utf8_lossy(&result));

I also had to update the guest function signature to fn censor_text(ptr: u32, len: usize) and cast that u32 back to a pointer inside the guest. It's less "Rusty," but when you're dealing with FFI and Wasm, you're essentially writing C-style interfaces. Once that's done, the host writes "secret," the guest changes it to "XXXXXX," and the host reads it back. Sandboxed, fast, and decoupled.




📋 Practical Task

Implement a "Uppercase" Wasm Plugin

Your task is to expand the existing system. Create a new guest plugin that implements a function called transform_to_uppercase. This plugin should take a pointer and a length, and modify the string in-place to be entirely uppercase.

  • Modify the guest code to iterate through the byte slice and convert each character to uppercase.
  • Update the host to load this new plugin and verify that a string like "hello rust" becomes "HELLO RUST".
  • Ensure you handle the memory transfer correctly by writing to the guest memory and reading the result back, avoiding the pointer mistake discussed in the lesson.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.