Examples & Cookbook
The first examples on this page are short, self-contained plugins. The rest are the real source of SDK test fixtures, copied without changes. The fixtures live under crates/infrarust-loader-wasm/tests/fixtures/ and compile against the same SDK you depend on, so each one is a working reference for one capability. For a from-scratch starter project, use the template in Getting Started.
Fixtures, not templates
The fixture crates are part of the loader's test suite. They are not published as standalone, copy-and-go templates. Read them for the patterns; build new plugins from the Getting Started layout.
Each example imports infrarust_plugin_sdk::prelude::* and tags one impl block with #[plugin(...)]. The guest Plugin trait is synchronous: on_enable(&self, ctx: &Context) -> Result<(), PluginError>, and ? works on every host call.
hello
What it shows: the smallest valid plugin. on_enable runs once at load and writes a marker into the plugin's WASI-scoped data directory.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct Hello;
#[plugin(id = "hello", name = "Hello", description = "The smallest plugin")]
impl Plugin for Hello {
fn on_enable(&self, _ctx: &Context) -> Result<(), PluginError> {
std::fs::write("enabled.marker", "on_enable ran")?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
The path enabled.marker is relative to the preopened data directory, which the host mounts as the guest's root. See Getting Started for the full project layout and build steps.
event-subscriber
What it shows: observing an event. The handler reads PostLoginEvent fields and never changes an outcome.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct EventSubscriber;
#[plugin(id = "event-subscriber", name = "Event Subscriber")]
impl Plugin for EventSubscriber {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.on::<PostLoginEvent>(EventPriority::NORMAL, |event| {
info!(
"{} ({}) joined with protocol {}",
event.player.username, event.player.uuid, event.protocol
);
})?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
event-modifier
What it shows: reading and changing an event result. The first handler sends every connection to a chosen backend; the second, later one lets staff through to the server they asked for, undoing the redirect.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct EventModifier;
#[plugin(id = "event-modifier", name = "Event Modifier")]
impl Plugin for EventModifier {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.on::<ServerPreConnectEvent>(EventPriority::NORMAL, |event| {
event.redirect_to("backend-1");
})?;
ctx.on::<ServerPreConnectEvent>(EventPriority::LATE, |event| {
let staff = event.player.handle().has_permission("example.staff").unwrap_or(false);
if staff && matches!(event.result(), ServerPreConnectResult::ConnectTo(_)) {
event.allow();
}
})?;
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
multi-handler
What it shows: priority ordering and per-handler cancellation. Five handlers register for the same event at different priorities; one is cancelled before any event fires.
use std::cell::RefCell;
use infrarust_plugin_sdk::prelude::*;
thread_local! {
static ORDER: RefCell<String> = const { RefCell::new(String::new()) };
}
fn append(tag: &str) {
ORDER.with(|order| order.borrow_mut().push_str(tag));
}
#[derive(Default)]
struct MultiHandler;
#[plugin(id = "multi-handler", name = "Multi Handler")]
impl Plugin for MultiHandler {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.on::<PostLoginEvent>(EventPriority::FIRST, |_| append("A"))?;
ctx.on::<PostLoginEvent>(EventPriority::custom(32), |_| append("B"))?;
ctx.on::<PostLoginEvent>(EventPriority::NORMAL, |_| append("C"))?;
let cancelled = ctx.on::<PostLoginEvent>(EventPriority::NORMAL, |_| append("L"))?;
ctx.on::<PostLoginEvent>(EventPriority::LAST, |_| append("D"))?;
cancelled.cancel();
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
26
27
Each login appends ABCD: the cancelled L handler never runs.
command-plugin
What it shows: registered commands with a description and a tab-completer, a command registered from inside a completer, and unregistering through the SDK. This is the command-plugin fixture.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct CommandPlugin;
#[plugin(id = "command-plugin", name = "Command Plugin Fixture")]
impl Plugin for CommandPlugin {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
ctx.command("greet")
.description("Greets the caller")
.handler(|invocation| {
let _ = std::fs::write("command.marker", invocation.args.join(","));
})
.completer(|completion| {
let partial = completion.partial();
["world", "everyone", "friend"]
.into_iter()
.filter(|candidate| candidate.starts_with(partial))
.collect::<Vec<_>>()
})
.register()?;
ctx.command("nest")
.description("Registers `nested` from inside its own completer")
.completer(|_| {
let _ = Context::new()
.command("nested")
.handler(|_| {
let _ = std::fs::write("nested.marker", "ran");
})
.completer(|_| vec!["inner"])
.register();
vec!["registered"]
})
.register()?;
ctx.command("unnest")
.description("Unregisters `nested` through the SDK")
.handler(|_| {
let removed = Context::new().unregister_command("nested").unwrap_or(false);
let _ = std::fs::write("unnest.marker", removed.to_string());
})
.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
When another plugin already owns nested, the registration from the completer is refused and the guest keeps no handler for it, so unnest records false.
codec-modify
What it shows: the three packet verdicts in one filter. Drop a packet, mutate its payload, or inject extra packets around it. This is the codec-modify fixture; it needs the codec-filter capability.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct CodecModify;
struct OpFilter;
impl CodecFilter for OpFilter {
fn filter(
&mut self,
_ctx: &CodecContext,
packet: &mut Packet,
out: &mut Injections,
) -> Verdict {
match packet.id() {
0x01 => Verdict::Drop,
0x02 => {
packet.set_data(b"MODIFIED".to_vec());
Verdict::Pass
}
0x03 => {
out.before(Packet::new(0xfe, b"before".to_vec()));
out.after(Packet::new(0xff, b"after".to_vec()));
Verdict::Pass
}
_ => Verdict::Pass,
}
}
}
#[plugin(id = "codec-modify", name = "Codec Modify Fixture")]
impl Plugin for CodecModify {
fn on_enable(&self, _ctx: &Context) -> Result<(), PluginError> {
Ok(())
}
fn register_codec_filters(reg: &mut CodecRegistrar) {
reg.add("ops", FilterPriority::Normal, |_init| Box::new(OpFilter));
}
}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
codec-stateful
What it shows: per-connection filter state. Each filter instance owns a counter; the host drives the client-side and server-side instances separately to prove their state is independent. This is the codec-stateful fixture.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct CodecStateful;
struct Counter {
count: u32,
}
impl CodecFilter for Counter {
fn filter(
&mut self,
_ctx: &CodecContext,
packet: &mut Packet,
_out: &mut Injections,
) -> Verdict {
self.count += 1;
packet.set_data(self.count.to_le_bytes().to_vec());
Verdict::Pass
}
}
#[plugin(id = "codec-stateful", name = "Codec Stateful Fixture")]
impl Plugin for CodecStateful {
fn on_enable(&self, _ctx: &Context) -> Result<(), PluginError> {
Ok(())
}
fn register_codec_filters(reg: &mut CodecRegistrar) {
reg.add("counter", FilterPriority::Normal, |_init| {
Box::new(Counter { count: 0 })
});
}
}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
limbo-handler
What it shows: holding a player in limbo, completing on a command, timing out, completing from a scheduled closure after the dispatch returns, and a handler name that only the first instance registers. This is the limbo-handler fixture and needs the limbo capability. It registers five handlers: gate completes on a command, boom traps, timed-gate times out, delayed-gate completes from a scheduled closure, and first-boot-gate holds every player and never completes, which only the loader tests need.
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct LimboPlugin;
static RECOVERED: AtomicBool = AtomicBool::new(false);
struct FirstBootGate;
impl LimboHandler for FirstBootGate {
fn on_player_enter(&self, _session: &LimboSession) -> HandlerOutcome {
HandlerOutcome::Hold
}
}
struct Gate {
waiting: RefCell<HashSet<PlayerId>>,
}
impl LimboHandler for Gate {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
self.waiting.borrow_mut().insert(session.player_id());
session.send_message("Type /continue to proceed").ok();
HandlerOutcome::Hold
}
fn on_command(&self, session: &LimboSession, command: &str, _args: &[String]) {
match command {
"continue" => {
self.waiting.borrow_mut().remove(&session.player_id());
session.complete(HandlerOutcome::Accept).ok();
}
"redirect" => {
self.waiting.borrow_mut().remove(&session.player_id());
session
.complete(HandlerOutcome::Redirect("hub".into()))
.ok();
}
_ => {
session.send_message("Unknown command").ok();
}
}
}
fn on_chat(&self, session: &LimboSession, _message: &str) {
session.send_message("Please use /continue").ok();
}
fn on_disconnect(&self, player: PlayerId) {
self.waiting.borrow_mut().remove(&player);
}
}
struct Boom;
impl LimboHandler for Boom {
fn on_player_enter(&self, _session: &LimboSession) -> HandlerOutcome {
panic!("boom: this handler always traps");
}
}
struct TimedGate;
impl LimboHandler for TimedGate {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
session.send_message("Type /continue within 5s").ok();
HandlerOutcome::HoldWithTimeout {
after: Duration::from_secs(5),
on_timeout: TimeoutOutcome::Deny(Component::text("Timed out")),
}
}
fn on_command(&self, session: &LimboSession, command: &str, _args: &[String]) {
if command == "continue" {
session.complete(HandlerOutcome::Accept).ok();
}
}
fn on_session_end(&self, _player: PlayerId, _reason: SessionEndReason) {}
}
struct DelayedGate;
impl LimboHandler for DelayedGate {
fn on_player_enter(&self, session: &LimboSession) -> HandlerOutcome {
let handle = session.handle();
let scheduled = Context::new().delay(Duration::from_millis(50), move || {
if !handle.cancelled() {
handle.complete(HandlerOutcome::Accept).ok();
}
});
match scheduled {
Ok(_) => HandlerOutcome::Hold,
Err(_) => HandlerOutcome::Accept,
}
}
}
#[plugin(id = "limbo-handler", name = "Limbo Handler Fixture")]
impl Plugin for LimboPlugin {
fn on_enable(&self, ctx: &Context) -> Result<(), PluginError> {
let recovered = matches!(ctx.enable_reason(), Some(EnableReason::Recovered(_)));
RECOVERED.store(recovered, Ordering::SeqCst);
Ok(())
}
fn register_limbo_handlers(reg: &mut LimboRegistrar) {
if !RECOVERED.load(Ordering::SeqCst) {
reg.add("first-boot-gate", FirstBootGate);
}
reg.add(
"gate",
Gate {
waiting: RefCell::new(HashSet::new()),
},
);
reg.add("boom", Boom);
reg.add("timed-gate", TimedGate);
reg.add("delayed-gate", DelayedGate);
}
}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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
DelayedGate accepts the player at once when the task cannot be scheduled, so nobody is held with nothing to release them.
on_enable runs before register_limbo_handlers, which gets no Context, so the fixture keeps whether this instance is a recovery in a static. A recovered instance does not register first-boot-gate again. The players that handler still held are denied when the first instance is discarded, and the players who reach the name afterwards are denied too (see Fail-closed behavior). The loader tests use it to check that a name the fresh instance leaves out stops routing to the plugin.
host-caller
What it shows: calling read-only host services from on_enable. This is the host-caller fixture; it queries the player count and a config value.
use infrarust_plugin_sdk::prelude::*;
#[derive(Default)]
struct HostCaller;
#[plugin(id = "host-caller", name = "Host Caller Fixture")]
impl Plugin for HostCaller {
fn on_enable(&self, _ctx: &Context) -> Result<(), PluginError> {
std::fs::write("count.txt", Players::count().to_string())?;
if let Ok(Some(greeting)) = Config::get("greeting") {
std::fs::write("greeting.txt", greeting)?;
}
Ok(())
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
Players::count() answers 0 when the plugin lacks player-read; Config::get returns an error of kind PermissionDenied when it lacks config-read, which the fixture treats like a missing value. greeting is a key of the mock config service the fixture runs against; on a real proxy, Config::get takes a dotted path of the running proxy config, such as bind or keepalive.retries. The full set of host services is in Services.
net-probe
What it shows: outbound TCP and UDP with std::net, HTTP through wasi:http, and files in a mounted folder with std::fs, all limited by the network and filesystem-extended config. A short, commented plugin doing the same is on Network & Extra Folders. The net-probe fixture runs each call on command so the loader tests can check what gets through and what is refused; read crates/infrarust-loader-wasm/tests/fixtures/net-probe/src/lib.rs for the HTTP body loop and the error reporting.
See also
- Getting Started: the project layout and build steps.
- Events, Commands, Codec Filters, Limbo, Services: the features these examples exercise.
- Migrating to 0.3: how the 0.2.3 versions of these examples changed.