Getting Started with WASM Plugins
This tutorial builds a sandboxed WASM plugin that logs when players join and registers a /hello command. By the end you have a .wasm component that Infrarust loads at startup. The plugin runs in a wasmtime sandbox against the infrarust:plugin@0.3.0 contract, separate from the proxy binary.
Native plugins are different
The native plugin API (infrarust-api, BoxFuture, statically compiled into the proxy) is a separate system. The WASM guest Plugin trait is synchronous and the build, packaging, and capability model differ. For the native path see Getting Started with Plugin Development.
Prerequisites
- Rust toolchain, edition 2024
- The
wasm32-wasip2compilation target cargo generatefor scaffolding
rustup target add wasm32-wasip2
cargo install cargo-generate2
Scaffold from the template
Generate a crate from the official template under templates/plugin-template:
cargo generate --git https://github.com/Shadowner/Infrarust.git templates/plugin-templateThe template asks how the crate should depend on infrarust-plugin-sdk. The choices come from cargo-generate.toml:
| Variable | Values | Meaning |
|---|---|---|
crate_name | any | Your plugin crate name. The macro derives the plugin id from it (CARGO_PKG_NAME). |
sdk_source | git, path, crates-io | Where the SDK dependency comes from. Default git. |
sdk_git | URL | Git URL for the SDK when sdk_source = "git". Default the Infrarust repo. |
sdk_path | path | Local checkout path to crates/infrarust-plugin-sdk. Asked only when sdk_source = "path". |
Local development
When iterating against a local Infrarust checkout, pick sdk_source = "path" so the SDK resolves to your working tree:
cargo generate --git https://github.com/Shadowner/Infrarust.git templates/plugin-template \
--define sdk_source=path2
The generated plugin
The template's src/lib.rs is the plugin in full:
#![forbid(unsafe_code)]
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct MyPlugin;
#[plugin]
impl Plugin for MyPlugin {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.on::<PostLoginEvent>(EventPriority::NORMAL, |event| {
info!("{} joined", event.player.username);
})?;
ctx.command("hello")
.description("Say hello")
.handler(|invocation| {
let who = invocation.args.first().map_or("world", String::as_str);
let _ = invocation.reply(Component::text(format!("hello, {who}!")));
})
.register()?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#![forbid(unsafe_code)] holds for the whole crate: the SDK needs no unsafe from you, the generated export glue included.
Four pieces do the work.
The plugin type
The struct holds plugin state and must implement Default; the #[plugin] macro instantiates it. The guest is single-threaded with no async runtime, so keep any mutable state in Cell/RefCell fields rather than locks.
The #[plugin] attribute
#[plugin] turns the impl Plugin block into a loadable component: it generates the WIT Guest glue and the component export!. It also derives metadata() from Cargo.toml (CARGO_PKG_NAME, CARGO_PKG_VERSION, CARGO_PKG_AUTHORS, CARGO_PKG_DESCRIPTION). Override individual fields inline when the crate name is not the id you want, and declare the plugins yours depends on:
#[plugin(
id = "hello",
name = "Hello",
description = "Logs joins, adds /hello",
depends = ["auth"],
soft_depends = ["stats"],
)]
impl Plugin for MyPlugin { /* ... */ }2
3
4
5
6
7
8
The id is what you reference in config. It defaults to CARGO_PKG_NAME, not the .wasm file name. The macro checks every id at compile time: lowercase letters, digits, - and _, starting with a letter or a digit, at most 64 characters. depends names plugins that must be enabled first; soft_depends names plugins to enable first when they are present.
on_enable
The guest Plugin trait is synchronous and reports errors as PluginError:
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError>;PluginError converts from String, &str, std::io::Error and the SDK's Error, so ? works on every host call. Returning an error aborts enabling the plugin. Register every event handler, command, and scheduled task here. The optional on_disable(&self, ctx: &Context) -> Result<(), PluginError> runs at shutdown and defaults to Ok(()). ctx.enable_reason() and ctx.disable_reason() tell why each one runs.
Subscribing to an event
ctx.on::<E> subscribes a handler for a typed event. PostLoginEvent fires after a player authenticates; its player field is a PlayerRef with the player's id, uuid and username. The handler is FnMut(&mut E). EventPriority orders handlers when several listen to the same kind (FIRST, EARLY, NORMAL, LATE, LAST, or EventPriority::custom(u8)). on returns an error when the host refuses the subscription, and the ? turns that into a failed enable.
ctx.on::<PostLoginEvent>(EventPriority::NORMAL, |event| {
info!("{} joined", event.player.username);
})?;2
3
See Events for every event the SDK exposes.
Registering a command
ctx.command returns a builder. Chain .description(...) and .handler(...), and finish with .register(), which answers what the host registered or an error. The handler receives a CommandInvocation with the label, the args, the raw line and the sender, a player or the console. invocation.reply(...) answers the sender.
ctx.command("hello")
.description("Say hello")
.handler(|invocation| {
let who = invocation.args.first().map_or("world", String::as_str);
let _ = invocation.reply(Component::text(format!("hello, {who}!")));
})
.register()?;2
3
4
5
6
7
See Commands for aliases and tab completion.
Getting a Context inside a callback
Context is a cheap handle to the guest runtime. Callbacks that do not receive a &Context (for example a limbo handler that wants to schedule a task) can construct one with Context::new().
The info!, warn!, error!, debug!, and trace! macros forward formatted messages to the host log.
Cargo.toml essentials
The template's manifest produces a component and optimizes for size:
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
infrarust-plugin-sdk = { git = "https://github.com/Shadowner/Infrarust.git" }
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
[profile.dev]
panic = "abort"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
crate-type = ["cdylib"] is required to emit a WASM component. The release profile keeps the .wasm small: opt-level = "s" optimizes for size, lto and strip shrink the artifact, and panic = "abort" removes unwinding code. wit-bindgen is embedded in the SDK, so no cargo-component is needed.
Build
Compile for wasm32-wasip2 in release mode:
cargo build --release --target wasm32-wasip2The template ships a .cargo/config.toml with target = "wasm32-wasip2", so plain cargo build --release produces the same component.
The component lands at:
target/wasm32-wasip2/release/my_plugin.wasmCargo replaces - with _ in the artifact name, so crate my-plugin produces my_plugin.wasm. More build options are in Building plugins.
Deploy
Infrarust discovers WASM plugins by scanning its plugins/ directory, not its subdirectories, for .wasm files and keying each by its metadata id. Copy the artifact there:
cp target/wasm32-wasip2/release/my_plugin.wasm /path/to/infrarust/plugins/Enable it with a [plugins.<id>] block in the proxy config, keyed by the metadata id (here my-plugin), not the file name:
[plugins.my-plugin]
enabled = true2
Events and commands are covered by the baseline capabilities granted to every WASM plugin, so this plugin needs no permissions. Opt-in capabilities (such as ban, server-manage, codec-filter, limbo) are listed there when a plugin uses them:
[plugins.my-plugin]
enabled = true
permissions = ["ban", "server-manage"]2
3
| Field | Type | Notes |
|---|---|---|
enabled | bool | Whether the plugin loads. Defaults to true. |
permissions | array of strings | Opt-in capabilities, kebab-case, added to the baseline grants. |
deny | array of strings | Capabilities to remove, applied after the baseline and permissions, so it can revoke a baseline grant. |
strict_capabilities | bool | Refuse to load the plugin when it imports a host function whose capability it lacks. Defaults to false. |
wasm | table | Per-plugin sandbox limits, network rules and mounts, overriding [wasm]. |
path | string | Optional. WASM plugins are auto-discovered, so this is unset for them. |
See Capabilities for the full baseline and opt-in tables, and the configuration reference for the surrounding config.
Run and verify
Start the proxy. On startup the manager loads each enabled plugin and logs it:
INFO Plugin enabled plugin=my-pluginJoin the server with a Minecraft client. The PostLoginEvent handler fires and the host log shows the join:
INFO Steve joinedRunning /hello (or /hello Steve) sends hello, world! (or hello, Steve!) to your chat. reply answers the sender, so the text reaches the log only when the console runs the command.
The log line and the chat reply mean the plugin loaded, the capabilities resolved, and dispatch reached your code.
flowchart LR
A[cargo build<br/>--target wasm32-wasip2] --> B[my_plugin.wasm]
B --> C[copy to plugins/]
C --> D["[plugins.my-plugin]<br/>enabled = true"]
D --> E[start proxy]
E --> F[Plugin enabled]
F --> G[player joins / /hello]
G --> H[handler logs fire]2
3
4
5
6
7
8
Next steps
- Architecture: how the host runs a guest component.
- Events: every event kind the SDK exposes.
- Commands: aliases, descriptions, and tab completion.
- Capabilities: baseline grants and opt-in permissions.
- Building and Deploying: the full build and packaging workflow.
- Examples: complete plugins to read.