WASM Plugin Development
This guide walks you through creating a WASM plugin for Conductor from scratch.
Prerequisites
Install Rust WASM Target
rustup target add wasm32-wasip1
Verify Installation
rustup target list | grep wasm32-wasip1
# Should show: wasm32-wasip1 (installed)
Creating Your First Plugin
1. Project Setup
# Create new library project
cargo new --lib my-midi-plugin
cd my-midi-plugin
2. Configure Cargo.toml
[package]
name = "my-midi-plugin"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # Required for WASM
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Link-time optimization
codegen-units = 1 # Better optimization
strip = true # Remove debug symbols
3. Implement Plugin Logic
// src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct PluginParams {
message: String,
}
#[derive(Serialize)]
struct PluginResult {
success: bool,
output: String,
}
/// Initialize plugin (called once on load)
#[no_mangle]
pub extern "C" fn init() {
eprintln!("Plugin initialized");
}
/// Execute plugin action
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
// Parse input parameters
let params_bytes = unsafe {
std::slice::from_raw_parts(params_ptr, params_len)
};
let params: PluginParams = match serde_json::from_slice(params_bytes) {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to parse params: {}", e);
return 1; // Error code
}
};
// Plugin logic
eprintln!("Received message: {}", params.message);
// Return success
0
}
/// Cleanup (called before unload)
#[no_mangle]
pub extern "C" fn shutdown() {
eprintln!("Plugin shutting down");
}
4. Build the Plugin
cargo build --target wasm32-wasip1 --release
Output file: target/wasm32-wasip1/release/my_midi_plugin.wasm
5. Test the Plugin
Create test_config.toml:
[[modes]]
name = "Default"
[[modes.mappings]]
trigger = { Note = { note = 60 } } # Middle C
action = { WasmPlugin = {
path = "target/wasm32-wasip1/release/my_midi_plugin.wasm",
params = {
"message": "Hello from MIDI!"
}
}}
Advanced Examples
Example 1: HTTP Request Plugin
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct HttpParams {
url: String,
method: String,
}
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
let params_bytes = unsafe {
std::slice::from_raw_parts(params_ptr, params_len)
};
let params: HttpParams = serde_json::from_slice(params_bytes)
.expect("Invalid params");
// Note: Requires Network capability
// This is a simplified example - real implementation would use reqwest
eprintln!("Making {} request to {}", params.method, params.url);
0
}
Required Capability: Network
Example 2: File Logger Plugin
use std::fs::OpenOptions;
use std::io::Write;
#[derive(Deserialize)]
struct LogParams {
message: String,
level: String, // "info", "warn", "error"
}
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
let params_bytes = unsafe {
std::slice::from_raw_parts(params_ptr, params_len)
};
let params: LogParams = serde_json::from_slice(params_bytes)
.expect("Invalid params");
// Write to sandboxed directory
// Path: ~/Library/Application Support/conductor/plugin-data/
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open("/plugin.log") // Sandboxed path
.expect("Failed to open log file");
let timestamp = chrono::Utc::now().to_rfc3339();
writeln!(file, "[{}] {}: {}",
timestamp, params.level, params.message)
.expect("Failed to write log");
0
}
Required Capability: Filesystem
Example 3: Velocity-Responsive Plugin
#[derive(Deserialize)]
struct VelocityParams {
action: String,
}
#[derive(Deserialize)]
struct TriggerContext {
velocity: Option<u8>,
mode: Option<u8>,
}
#[no_mangle]
pub extern "C" fn execute_with_context(
params_ptr: *const u8,
params_len: usize,
context_ptr: *const u8,
context_len: usize,
) -> i32 {
let params_bytes = unsafe {
std::slice::from_raw_parts(params_ptr, params_len)
};
let context_bytes = unsafe {
std::slice::from_raw_parts(context_ptr, context_len)
};
let params: VelocityParams = serde_json::from_slice(params_bytes)
.expect("Invalid params");
let context: TriggerContext = serde_json::from_slice(context_bytes)
.expect("Invalid context");
let velocity = context.velocity.unwrap_or(0);
// Adjust action based on velocity
match velocity {
0..=40 => eprintln!("Soft press: {}", params.action),
41..=80 => eprintln!("Medium press: {}", params.action),
81..=127 => eprintln!("Hard press: {}", params.action),
}
0
}
Capability Declaration
Declaring Capabilities
Capabilities are declared via a metadata function:
#[no_mangle]
pub extern "C" fn capabilities() -> *const u8 {
let caps = vec!["Network", "Filesystem"];
let json = serde_json::to_string(&caps).unwrap();
let boxed = Box::new(json.into_bytes());
Box::into_raw(boxed) as *const u8
}
Available Capabilities
// Low risk - auto-granted
"Network" // HTTP requests, WebSocket
"Audio" // Audio device access
"Midi" // MIDI device access
// Medium risk - user approval
"Filesystem" // File read/write (sandboxed)
// High risk - explicit approval
"Subprocess" // Shell command execution
"SystemControl" // System-level operations
Resource Limits
Fuel (CPU) Limits
Plugins are limited by “fuel” (instruction count):
// Conductor automatically limits plugins to 100M instructions
// This prevents infinite loops and excessive CPU usage
// Check remaining fuel (from plugin side):
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
// Your plugin code...
// Conductor will automatically terminate if fuel runs out
0
}
Default: 100,000,000 instructions (~100ms execution time)
Memory Limits
Default: 128 MB per plugin
// Conductor enforces memory limits automatically
// Allocations beyond the limit will fail
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
// Be mindful of memory allocations
let large_buffer = vec![0u8; 1024 * 1024]; // 1 MB - OK
// let huge_buffer = vec![0u8; 200 * 1024 * 1024]; // 200 MB - Would fail
0
}
Filesystem Sandbox
Plugins with Filesystem capability can only access:
macOS: ~/Library/Application Support/conductor/plugin-data/
Linux: ~/.local/share/conductor/plugin-data/
Windows: %APPDATA%\conductor\plugin-data\
// These paths are relative to the sandbox root:
let ok_path = "/my-data.json"; // OK - sandboxed
let ok_path2 = "/subdir/file.txt"; // OK - sandboxed
// let bad_path = "/etc/passwd"; // BLOCKED - outside sandbox
// let bad_path2 = "../../../etc/passwd"; // BLOCKED - path traversal
Optimization
Size Optimization
# Cargo.toml
[profile.release]
opt-level = "z" # Optimize for size
lto = true
codegen-units = 1
strip = true
panic = "abort" # Smaller panic handling
Post-Build Optimization
# Install wasm-opt
cargo install wasm-opt
# Optimize WASM binary
wasm-opt -Oz \
target/wasm32-wasip1/release/my_plugin.wasm \
-o target/wasm32-wasip1/release/my_plugin_opt.wasm
# Check size reduction
ls -lh target/wasm32-wasip1/release/*.wasm
Performance Tips
-
Minimize allocations in hot paths
// Bad: allocates on every call fn process(data: &str) -> String { format!("Processed: {}", data) } // Good: reuse buffer fn process(data: &str, buffer: &mut String) { buffer.clear(); buffer.push_str("Processed: "); buffer.push_str(data); } -
Use static data when possible
// Bad: allocates vec on every call fn get_options() -> Vec<String> { vec!["option1".to_string(), "option2".to_string()] } // Good: static slice const OPTIONS: &[&str] = &["option1", "option2"]; -
Lazy initialization
use std::sync::OnceLock; static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new(); fn get_client() -> &'static reqwest::Client { HTTP_CLIENT.get_or_init(|| reqwest::Client::new()) }
Testing
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_params() {
let params = r#"{"message": "test"}"#;
let parsed: PluginParams = serde_json::from_str(params).unwrap();
assert_eq!(parsed.message, "test");
}
}
Integration Testing
# Build plugin
cargo build --target wasm32-wasip1 --release
# Test with wasmtime
wasmtime target/wasm32-wasip1/release/my_plugin.wasm
# Or use Conductor directly
conductor --config test_config.toml 0
Debugging
Enable Debug Logging
#[no_mangle]
pub extern "C" fn execute(params_ptr: *const u8, params_len: usize) -> i32 {
// Use eprintln! for debug output
eprintln!("DEBUG: Received {} bytes", params_len);
eprintln!("DEBUG: Params: {:?}", params);
// This appears in Conductor's stderr
0
}
Run with Debug Output
# See plugin debug output
DEBUG=1 conductor --config test_config.toml 0 2>&1 | grep "DEBUG:"
Common Issues
Plugin not loading:
# Verify WASM format
file target/wasm32-wasip1/release/my_plugin.wasm
# Should show: WebAssembly (wasm) binary module
# Check for WASI imports
wasm-objdump -x target/wasm32-wasip1/release/my_plugin.wasm | grep wasi
Out of fuel error:
- Reduce computation in execute()
- Move heavy work to init()
- Use lazy initialization
Memory limit exceeded:
- Reduce buffer sizes
- Use streaming instead of loading entire data
- Profile with
cargo-bloat
Signing Your Plugin
See: Plugin Security Guide for complete signing instructions.
Quick reference:
# Generate keypair (one-time)
conductor-sign generate-key ~/.conductor/my-key
# Sign plugin
conductor-sign sign \
target/wasm32-wasip1/release/my_plugin.wasm \
~/.conductor/my-key \
--name "Your Name" \
--email "you@example.com"
# Creates: my_plugin.wasm.sig
Distribution
Package Structure
my-midi-plugin/
├── my_plugin.wasm # Binary
├── my_plugin.wasm.sig # Signature
├── README.md # Documentation
├── LICENSE # License
└── examples/
└── config.toml # Example configuration
README Template
# My MIDI Plugin
Brief description of what your plugin does.
## Installation
1. Download `my_plugin.wasm` and `my_plugin.wasm.sig`
2. Copy to `~/.conductor/wasm-plugins/`
3. Add to configuration (see example)
## Configuration
\```toml
[[modes.mappings]]
trigger = { Note = { note = 60 } }
action = { WasmPlugin = {
path = "~/.conductor/wasm-plugins/my_plugin.wasm",
params = {
"param1": "value1"
}
}}
\```
## Parameters
- `param1`: Description
- `param2`: Description
## Capabilities
- Network: For HTTP requests
- Filesystem: For data persistence
## License
MIT
Next Steps
- Plugin Security - Signing and verification
- Plugin Examples - Real-world examples
- WASM Plugins Overview - Architecture and concepts