Getting Started with Plugin Development
This tutorial walks you through creating a plugin that logs player connections and adds a /greet command. By the end you'll have a working plugin compiled into the proxy binary.
Prerequisites
- A Rust toolchain new enough for edition 2024. Infrarust pins its MSRV at 1.94 in the workspace
Cargo.toml. - The Infrarust source code checked out locally
- Familiarity with
async/awaitin Rust
Project setup
Infrarust plugins are regular Rust crates that depend on infrarust-api. Create a new crate inside the plugins/ directory:
mkdir plugins/infrarust-plugin-greet
cd plugins/infrarust-plugin-greet
cargo init --lib2
3
Replace the generated Cargo.toml with:
[package]
name = "infrarust-plugin-greet"
version = "0.1.0"
edition = "2024"
[dependencies]
infrarust-api = { path = "../../crates/infrarust-api" }
tracing = "0.1"2
3
4
5
6
7
8
infrarust-api provides the Plugin trait, events, commands, and every type you need. tracing is the logging framework used across Infrarust. The bundled plugins (plugins/infrarust-plugin-hello and friends) pull these in through { workspace = true } instead of a literal path; either form works once the crate is part of the workspace.
The root workspace lists its members explicitly, so add the new crate to the members array in the top-level Cargo.toml or Cargo will not build it:
[workspace]
members = [
# ... existing members ...
"plugins/infrarust-plugin-greet",
]2
3
4
5
The Plugin trait
Every plugin implements Plugin from the prelude. The trait has three parts:
metadata()returns your plugin's id, name, and version.on_enable()is called at startup with aPluginContext. Register your listeners, commands, and handlers here.on_disable()is called at shutdown. It is optional; the default implementation does nothing. All listeners and commands you registered are automatically cleaned up.
Open src/lib.rs and replace the contents:
use infrarust_api::prelude::*;
pub struct GreetPlugin;
impl Plugin for GreetPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata::new("greet", "Greet Plugin", "0.1.0")
.author("YourName")
.description("Logs connections and adds a /greet command")
}
fn on_enable<'a>(
&'a self,
ctx: &'a dyn PluginContext,
) -> BoxFuture<'a, Result<(), PluginError>> {
Box::pin(async move {
// We'll fill this in next
tracing::info!("[GreetPlugin] Enabled");
Ok(())
})
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
The metadata() method uses a builder pattern. The id must be unique, snake_case, and is used internally for dependency resolution and resource tracking. PluginMetadata::new takes three required fields (id, name, version), and you chain .author(), .description(), .depends_on(), or .optional_dependency() as needed.
WARNING
on_enable and on_disable return BoxFuture because they're async but the trait isn't async_trait. Wrap your async block in Box::pin(async move { ... }).
Listening to events
The PluginContext gives you access to an event bus. Subscribe to any event type with a priority and a closure.
Add this inside your on_enable async block, before the Ok(()):
ctx.event_bus()
.subscribe(EventPriority::NORMAL, |event: &mut PostLoginEvent| {
tracing::info!("[GreetPlugin] {} joined the proxy!", event.profile.username);
});
ctx.event_bus()
.subscribe(EventPriority::NORMAL, |event: &mut DisconnectEvent| {
tracing::info!("[GreetPlugin] {} left the proxy", event.username());
});2
3
4
5
6
7
8
9
The event type in the closure signature determines which events you receive. The proxy fires PostLoginEvent once a player has logged in and DisconnectEvent, exactly once, when they leave.
Event priorities
Priorities control the order listeners run when multiple plugins listen to the same event:
| Priority | Value | Use case |
|---|---|---|
FIRST | 0 | Runs before all others. Use for access control or authentication checks. |
EARLY | 64 | Before normal processing. |
NORMAL | 128 | Default. Most plugins should use this. |
LATE | 192 | After normal processing. |
LAST | 255 | Runs after all others. Use for logging or analytics. |
You can also use EventPriority::custom(u8) for arbitrary values.
Modifying events
Some events implement ResultedEvent, which means you can change the outcome. For example, ChatMessageEvent lets you block a message:
ctx.event_bus()
.subscribe(EventPriority::NORMAL, |event: &mut ChatMessageEvent| {
if event.message.contains("spam") {
event.deny(Component::text("Message blocked"));
}
});2
3
4
5
6
Registering a command
Commands are registered through ctx.command_manager(). You describe the command with a CommandSpec (name, aliases, description) and pass a handler struct that implements CommandHandler.
Add the command registration inside on_enable, after the event subscriptions:
let greet = CommandSpec::new("greet")
.aliases(["hi", "hey"])
.description("Sends a greeting to the player");
if let Err(e) = ctx.command_manager().register(greet, Box::new(GreetCommand)) {
tracing::warn!("[GreetPlugin] /greet was not registered: {e}");
}2
3
4
5
6
Registration fails when another plugin already owns the name. Your command is always reachable as greet:greet too, with your plugin id as the prefix.
Then define the handler struct outside the impl Plugin block:
struct GreetCommand;
impl CommandHandler for GreetCommand {
fn execute<'a>(&'a self, ctx: CommandContext) -> BoxFuture<'a, ()> {
Box::pin(async move {
ctx.source.send_message(
Component::text("Hello from Infrarust! ")
.color("gold")
.bold()
.append(Component::text("Welcome to the proxy.").color("gray")),
);
})
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
CommandContext carries the source that ran the command (a player or the console) and the parsed args. ctx.source.send_message() replies to whoever ran it, and ctx.source.player() gives you the player to switch servers or disconnect.
Adding on_disable
on_disable is optional. The proxy automatically unsubscribes your event listeners, unregisters commands, and cancels scheduled tasks. Override it only if your plugin holds external resources (file handles, database connections) that need explicit cleanup:
fn on_disable(&self) -> BoxFuture<'_, Result<(), PluginError>> {
Box::pin(async {
tracing::info!("[GreetPlugin] Disabled");
Ok(())
})
}2
3
4
5
6
Wiring the plugin into the proxy
Infrarust compiles plugins statically via Cargo features. You need to make three changes.
1. Add the dependency to infrarust
In crates/infrarust/Cargo.toml, add your plugin as an optional dependency and create a feature flag:
[features]
# ... existing features, default included ...
plugin-greet = ["dep:infrarust-plugin-greet"]
[dependencies]
# ... existing dependencies ...
infrarust-plugin-greet = { path = "../../plugins/infrarust-plugin-greet", optional = true }2
3
4
5
6
7
2. Register the plugin in the static loader
In crates/infrarust/src/plugins.rs, add a registration block:
#[cfg(feature = "plugin-greet")]
{
use infrarust_api::plugin::Plugin;
let greet = infrarust_plugin_greet::GreetPlugin;
loader.register(greet.metadata(), || {
Box::new(infrarust_plugin_greet::GreetPlugin)
});
}2
3
4
5
6
7
8
3. Build with the feature enabled
cargo build --release --features "plugin-greet"Complete source
Here's the full src/lib.rs:
use infrarust_api::prelude::*;
pub struct GreetPlugin;
impl Plugin for GreetPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata::new("greet", "Greet Plugin", "0.1.0")
.author("YourName")
.description("Logs connections and adds a /greet command")
}
fn on_enable<'a>(
&'a self,
ctx: &'a dyn PluginContext,
) -> BoxFuture<'a, Result<(), PluginError>> {
Box::pin(async move {
ctx.event_bus()
.subscribe(EventPriority::NORMAL, |event: &mut PostLoginEvent| {
tracing::info!("[GreetPlugin] {} joined!", event.profile.username);
});
ctx.event_bus()
.subscribe(EventPriority::NORMAL, |event: &mut DisconnectEvent| {
tracing::info!("[GreetPlugin] {} left", event.username());
});
let greet = CommandSpec::new("greet")
.aliases(["hi", "hey"])
.description("Sends a greeting to the player");
if let Err(e) = ctx.command_manager().register(greet, Box::new(GreetCommand)) {
tracing::warn!("[GreetPlugin] /greet was not registered: {e}");
}
tracing::info!("[GreetPlugin] Enabled");
Ok(())
})
}
fn on_disable(&self) -> BoxFuture<'_, Result<(), PluginError>> {
Box::pin(async {
tracing::info!("[GreetPlugin] Disabled");
Ok(())
})
}
}
struct GreetCommand;
impl CommandHandler for GreetCommand {
fn execute<'a>(&'a self, ctx: CommandContext) -> BoxFuture<'a, ()> {
Box::pin(async move {
ctx.source.send_message(
Component::text("Hello from Infrarust! ")
.color("gold")
.bold()
.append(Component::text("Welcome to the proxy.").color("gray")),
);
})
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
Next steps
- Plugin overview for the full list of what plugins can do.
- Events for the complete event catalog and how
ResultedEventoutcomes are applied. - Commands for tab completion, permission gating, and argument parsing.
- Lifecycle for the load, enable, disable, and unload flow.
- Threading Model for where your code runs and what it must not block.
- Migrating from 2.0.0-beta.3 if your plugin was written against the beta.3 API.
- The built-in hello plugin for examples of limbo handlers and scheduled tasks.
- The
infrarust_api::preludemodule for the full list of available types, events, and services.