Skip to main content
Build WebAssembly plugins using Rust. Rust provides excellent WebAssembly support with cargo-component and native WASI Preview 2 tooling.

Setup

Install Rust and WASM Target

# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env

# Add WASM target
rustup target add wasm32-wasip2

# Install cargo-component
cargo install cargo-component

# Create a new Rust plugin
noorle plugin new my-rust-plugin --lang rust
cd my-rust-plugin

Project Structure

my-rust-plugin/
├── src/
│   ├── lib.rs               # Entry point
│   ├── tools.rs             # Tool implementations
│   └── utils.rs             # Utilities
├── Cargo.toml               # Dependencies
├── noorle.yaml              # Plugin config
├── world.wit                # Component interface
├── .env
└── README.md

Define Tools

Export functions as plugin tools.

Simple Functions

// src/lib.rs
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

pub fn add(a: f64, b: f64) -> f64 {
    a + b
}

pub fn multiply(a: f64, b: f64) -> f64 {
    a * b
}

Complex Types

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Item {
    pub id: String,
    pub name: String,
    pub count: i32,
}

pub fn process_items(
    items: Vec<Item>,
    config: std::collections::HashMap<String, String>,
) -> Vec<Item> {
    let prefix = config.get("prefix").cloned().unwrap_or_default();
    items
        .into_iter()
        .map(|mut item| {
            item.name = format!("{}{}", prefix, item.name);
            item
        })
        .collect()
}

pub fn search_items(query: &str, limit: i32) -> Vec<Item> {
    let limit = std::cmp::min(limit.max(0), 100) as usize;
    (0..limit)
        .map(|i| Item {
            id: i.to_string(),
            name: format!("{}_{}", query, i),
            count: (i + 1) as i32,
        })
        .collect()
}

Structured Data

Use serde for JSON serialization:
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct User {
    pub id: String,
    pub name: String,
    pub email: String,
    pub active: bool,
}

#[derive(Serialize, Deserialize)]
pub struct Result<T> {
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

pub fn validate_user(user: User) -> Result<User> {
    if !user.email.contains("@") {
        return Result {
            success: false,
            data: None,
            error: Some("Invalid email".to_string()),
        };
    }

    Result {
        success: true,
        data: Some(user),
        error: None,
    }
}

File I/O

Access filesystem via standard library.
use std::fs;
use std::path::Path;

pub fn read_config() -> Result<String, String> {
    fs::read_to_string("/allowed/path/config.json")
        .map_err(|e| e.to_string())
}

pub fn write_cache(key: &str, value: &str) -> Result<bool, String> {
    let cache_dir = std::env::var("CACHE_DIR").unwrap_or_else(|_| "/cache".to_string());
    let path = Path::new(&cache_dir).join(format!("{}.txt", key));

    fs::write(&path, value)
        .map_err(|e| e.to_string())?;

    Ok(true)
}

pub fn list_files(directory: &str) -> Result<Vec<String>, String> {
    let entries = fs::read_dir(directory)
        .map_err(|e| e.to_string())?;

    let files: Result<Vec<String>, String> = entries
        .filter_map(|entry| {
            entry.ok().and_then(|e| {
                e.file_type().ok().and_then(|ft| {
                    if ft.is_file() {
                        e.file_name().into_string().ok()
                    } else {
                        None
                    }
                })
            })
        })
        .map(Ok)
        .collect();

    files
}

Configure Permissions

In noorle.yaml:
permissions:
  filesystem:
    allow:
      - uri: "fs://cache/**"
        access: [read, write]
      - uri: "fs://config/**"
        access: [read]

  environment:
    allow:
      - key: "CACHE_DIR"

Environment Variables

Access configuration via std::env:
pub fn get_api_credentials() -> std::collections::HashMap<String, String> {
    let mut creds = std::collections::HashMap::new();
    creds.insert(
        "api_key".to_string(),
        std::env::var("API_KEY").unwrap_or_default(),
    );
    creds.insert(
        "api_secret".to_string(),
        std::env::var("API_SECRET").unwrap_or_default(),
    );
    creds
}

pub fn get_database_url() -> String {
    std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite:///:memory:".to_string())
}
Configure in .env:
API_KEY=sk-1234567890
API_SECRET=secret-value
DATABASE_URL=postgres://localhost/mydb

Network Access

Make HTTP requests via standard library or libraries.

Using reqwest (Rust standard)

pub async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let body = client.get(url)
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await?
        .text()
        .await?;
    Ok(body)
}

pub async fn post_json(
    url: &str,
    data: serde_json::Value,
) -> Result<String, Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let response = client.post(url)
        .header("Content-Type", "application/json")
        .json(&data)
        .send()
        .await?
        .text()
        .await?;
    Ok(response)
}
Add to Cargo.toml:
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde_json = "1.0"
Configure network permissions:
permissions:
  network:
    allow:
      - host: "api.example.com"
      - host: "*.github.com"

Dependencies

Cargo.toml

[package]
name = "my-rust-plugin"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["sync"] }
uuid = { version = "1.0", features = ["v4"] }

[lib]
crate-type = ["cdylib"]

[profile.release]
opt-level = "z"
lto = true
strip = true

Add Dependencies

cargo add serde --features derive
cargo add serde_json
cargo add reqwest --features json
noorle plugin build

Size Optimization

[profile.release]
opt-level = "z"        # Optimize for size
lto = true             # Link-time optimization
codegen-units = 1      # Better optimization
strip = true           # Strip symbols
Typical Rust plugin size: 500KB - 2MB

Error Handling

Return structured errors:
use thiserror::Error;

#[derive(Error, Debug, Serialize)]
pub enum PluginError {
    #[error("invalid input: {0}")]
    InvalidInput(String),

    #[error("network error: {0}")]
    NetworkError(String),

    #[error("io error: {0}")]
    IoError(String),
}

pub fn safe_operation(input: &str) -> Result<String, PluginError> {
    if input.is_empty() {
        return Err(PluginError::InvalidInput("Empty input".to_string()));
    }

    Ok(process(input)?)
}

fn process(input: &str) -> Result<String, PluginError> {
    if input.is_empty() {
        return Err(PluginError::InvalidInput("Expected input".to_string()));
    }
    Ok(input.to_uppercase())
}

Testing

Test locally before uploading.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_greet() {
        assert_eq!(greet("Alice"), "Hello, Alice!");
    }

    #[test]
    fn test_add() {
        assert_eq!(add(2.0, 3.0), 5.0);
    }

    #[test]
    fn test_process_items() {
        let items = vec![Item {
            id: "1".to_string(),
            name: "test".to_string(),
            count: 5,
        }];

        let mut config = std::collections::HashMap::new();
        config.insert("prefix".to_string(), "new_".to_string());

        let result = process_items(items, config);
        assert_eq!(result[0].name, "new_test");
    }
}
Run tests:
cargo test

Building and Deploying

Build

noorle plugin build

# Output:
# ✓ Building plugin...
# ✓ Compiling Rust to WebAssembly
# ✓ Creating my-rust-plugin.npack (780KB)

Upload

noorle plugin push --api-key YOUR_KEY

# Output:
# ✓ Uploading plugin...
# ✓ Plugin ID: 550e8400-e29b-41d4-a716-446655440000
# ✓ Active version: v1
# ✓ Tools: greet, add, processItems

Best Practices

Input Validation

pub fn validate_email(email: &str) -> Result<(), Vec<String>> {
    let mut errors = Vec::new();

    if email.is_empty() {
        errors.push("Email cannot be empty".to_string());
    }

    if !email.contains('@') {
        errors.push("Email must contain @".to_string());
    }

    if email.len() > 255 {
        errors.push("Email too long".to_string());
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors)
    }
}

Logging

pub fn log(message: &str, level: &str) {
    eprintln!("[{}] {}", level, message);
}

pub fn my_tool(input: &str) -> String {
    log(&format!("Processing: {}", input), "INFO");
    let result = process(input);
    log("Completed", "DEBUG");
    result
}

Documentation

/// Transform input text.
///
/// # Arguments
///
/// * `text` - The text to transform
/// * `uppercase` - Convert to uppercase if true
/// * `trim` - Remove whitespace if true
///
/// # Example
///
/// ```
/// assert_eq!(transform_text("hello", true, true), "HELLO");
/// ```
pub fn transform_text(text: &str, uppercase: bool, trim: bool) -> String {
    let mut result = text.to_string();
    if trim {
        result = result.trim().to_string();
    }
    if uppercase {
        result = result.to_uppercase();
    }
    result
}

Example: Complete Plugin

// src/lib.rs
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone)]
pub struct WeatherData {
    pub city: String,
    pub temp: f64,
    pub description: String,
}

pub async fn get_weather(city: &str, units: &str) -> Result<WeatherData, String> {
    let api_key = std::env::var("OPENWEATHER_API_KEY")
        .map_err(|_| "API key not configured".to_string())?;

    let url = format!(
        "https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units={}",
        city, api_key, units
    );

    let client = reqwest::Client::new();
    let response: serde_json::Value = client
        .get(&url)
        .send()
        .await
        .map_err(|e| e.to_string())?
        .json()
        .await
        .map_err(|e| e.to_string())?;

    Ok(WeatherData {
        city: response["name"].as_str().unwrap_or("").to_string(),
        temp: response["main"]["temp"].as_f64().unwrap_or(0.0),
        description: response["weather"][0]["description"]
            .as_str()
            .unwrap_or("")
            .to_string(),
    })
}

Troubleshooting

Build fails - “Target not found”:
rustup target add wasm32-wasip2
Large WASM binary (>5MB):
  • Review dependencies
  • Use cargo-bloat to analyze
  • Enable release optimizations
Async runtime issues:
  • TinyGo/WASI have limited async support
  • Use tokio 1.0+ with WASI target
  • Consider blocking alternatives for simple cases

Next Steps