Expand description
Guest SDK for Rift WebAssembly plugins.
A Rift plugin is a WebAssembly component built for wasm32-wasip2 against
the plugin world of rift:plugin@0.2.0. This crate ships that WIT,
generates the bindings and wraps them, so a plugin is an ordinary
cdylib crate:
[lib]
crate-type = ["cdylib"]
[dependencies]
rift-plugin = "0.2"use rift_plugin::{Action, CommandSpec, Context, Hook, Plugin, Registration, SuggestKind};
#[derive(Default)]
struct Greeter;
impl Plugin for Greeter {
fn init(&mut self, _config: &str) -> Result<Registration, String> {
Ok(Registration::new()
.command(
CommandSpec::new("greet")
.alias("hi")
.permission("greeter.use")
.suggest(SuggestKind::Players),
)
.hooks([Hook::Command]))
}
fn command(&mut self, ctx: Context) -> Action {
Action::message(format!("Hi, {}!", ctx.name))
}
}
rift_plugin::export!(Greeter);Build it with cargo build --release --target wasm32-wasip2. Rust’s
linker for that target emits a component directly; no cargo component
or wasm-tools component new step is needed.
§State
The component model calls exports as free functions, so the SDK keeps the
single plugin instance for you: export! stores one value of your type
in a thread-local, created with Default right before
Plugin::init, and every hook receives it as &mut self. Keep plugin
state in your struct’s fields. This is sound because guests are
single-threaded and the host never calls one instance concurrently or
re-entrantly. Plugin::shutdown runs last and the instance is dropped
after it returns.
§Hooks
Every Plugin method has a default that does nothing (continue,
default, none or an empty list). List the hooks you implement in
Registration::hooks so the host skips the rest. An empty list delivers
every hook except Hook::Chat and Hook::Kick, which are only ever
delivered when listed. Plugin::dialog and Plugin::job_done need no
entry.
The decision hooks answer with an Action; each accepts a subset:
| Hook | Accepts |
|---|---|
login, before_transfer, chat | Continue, deny |
initial_server, kick | Continue, deny, server |
command | Continue, message, server, queue, LeaveQueue |
§Host API
players, store, permissions, bus, servers, timers,
background, log and meta mirror the WIT interfaces of the same
names. Errors are the host’s message as a String. log also installs
a ::log logger before init, so log::info! and friends reach Rift’s
log. Plugin configuration arrives as JSON; decode it with config().
WASI 0.2 (std::fs on the granted directories, clocks, random) works
through the standard library. A wasi:http helper is not provided.
§Text
Everything a player reads is a Text: Text::Plain, shown exactly
as written, or Text::Json, a JSON text component with colours, line
breaks, hover text and click events. Every function and constructor that
shows text takes impl Into<Text>:
&strandStringbecome plain text;- the
textbuilders (text::minifor Rift markup,text::legacyfor&ccodes,text::Componentfield by field) and aserde_json::Valuebecome JSON text.
use rift_plugin::{Action, TitleSpec, players, text};
use serde_json::json;
players::message("Steve", "Shown <exactly> as written").ok();
let welcome = text::mini("<gold>Welcome, <player>!</gold> <gray><click:run_command:/spawn>[Spawn]")
.arg("player", "Steve");
players::message("Steve", welcome).ok();
players::title(
"Steve",
&TitleSpec::new(text::mini("<green>Lobby")).subtitle(text::legacy("&7Have fun")),
)
.ok();
let reply = Action::message(
text::Component::text("[Accept]")
.color("green")
.bold()
.run_command("/party accept Alex")
.hover_text("Join Alex's party"),
);
let banned = Action::deny(json!({
"text": "You are banned\n", "color": "red",
"extra": [{"text": "Reason: spam", "color": "gray"}],
}));Rift spells hover and click events the way each player’s client reads
them, so either the 1.21.5+ form (click_event, hover_event) or the
older one (clickEvent, hoverEvent) works for every client. Markup is
only ever read from an explicit {"mini": ...} object, which
text::mini writes; a string is always shown as written, so text from
players can be passed safely as plain text or as a markup argument.
§Chat and kicks
Plugin::chat sees every chat message, and every command that reaches
the backend, before the backend does, and may drop it. Plugin::kick
decides what happens to a player a backend kicked. Both are delivered
only to a registration that lists them:
use rift_plugin::{Action, ChatAttempt, ChatKind, Context, Hook, Plugin, Registration};
#[derive(Default)]
struct Mute {
muted: Vec<String>,
}
impl Plugin for Mute {
fn init(&mut self, _config: &str) -> Result<Registration, String> {
Ok(Registration::new().hooks([Hook::Chat, Hook::Kick]))
}
fn chat(&mut self, attempt: ChatAttempt) -> Action {
if attempt.kind == ChatKind::Message && self.muted.contains(&attempt.ctx.uuid) {
return Action::deny("You are muted.");
}
Action::Continue
}
fn kick(&mut self, ctx: Context) -> Action {
match ctx.reason.as_deref() {
Some(reason) if reason.contains("restarting") => Action::server("lobby"),
_ => Action::Continue,
}
}
}
rift_plugin::export!(Mute);Rift asks a plugin that lists Hook::Chat about every message of every
player. Dropping one signed message breaks the player’s signature chain,
so unless the proxy sets authentication.signed_chat = false, the first
deny costs that player their chat signing until they reconnect.
§Background work
A plugin runs one call at a time, so a slow lookup in login would keep
every other hook waiting. The background module moves such work to
worker instances (manifest [background] workers = N): the hook defers
its answer, queues a job and returns; a worker runs Plugin::work;
Plugin::job_done receives the result and gives the answer.
use rift_plugin::{Action, Context, Hook, Plugin, Registration, background};
use std::collections::HashMap;
#[derive(Default)]
struct Bans {
/// Job handle to the ticket of the login that waits for it.
waiting: HashMap<u64, u64>,
}
impl Plugin for Bans {
fn init(&mut self, _config: &str) -> Result<Registration, String> {
Ok(Registration::new().hooks([Hook::Login]))
}
fn login(&mut self, ctx: Context) -> Action {
let Ok(ticket) = background::defer() else {
return Action::deny("Try again in a moment.");
};
match background::run(ctx.uuid.as_bytes()) {
Ok(job) => {
self.waiting.insert(job, ticket);
}
Err(_) => {
let _ = background::resolve(ticket, Action::deny("Try again in a moment."));
}
}
// Ignored: the answer is the one given to `resolve`.
Action::Continue
}
fn job_done(&mut self, job: u64, outcome: Result<Vec<u8>, String>) {
let Some(ticket) = self.waiting.remove(&job) else { return };
let action = match outcome {
Ok(reason) if reason.is_empty() => Action::Continue,
Ok(reason) => Action::deny(String::from_utf8_lossy(&reason).into_owned()),
Err(_) => Action::deny("Try again in a moment."),
};
let _ = background::resolve(ticket, action);
}
// Runs on a worker: another instance, with its own state.
fn work(&mut self, uuid: Vec<u8>) -> Result<Vec<u8>, String> {
let uuid = String::from_utf8(uuid).map_err(|error| error.to_string())?;
// The slow part; an empty answer means "not banned".
Ok(std::fs::read(format!("/data/bans/{uuid}")).unwrap_or_default())
}
}
rift_plugin::export!(Bans);A worker is a separate instance of the same component with its own
memory: its state is its own Default value, Plugin::init never runs
there, and it receives no hooks. Plugin::work_init runs once per
worker before its first job, with the plugin’s configuration. The
deferred hook’s deadline keeps running while the job does.
§Dialogs
players::show_dialog shows a dialog (Minecraft 1.21.6+) in the
vanilla JSON format. A button whose custom or dynamic/custom action
has an id without a namespace comes back to Plugin::dialog:
use rift_plugin::{Context, DialogResponse, Plugin, players};
use serde_json::json;
#[derive(Default)]
struct Rules;
impl Plugin for Rules {
fn join(&mut self, ctx: Context) {
let _ = players::show_dialog(&ctx.uuid, &json!({
"type": "confirmation",
"title": "Server rules",
"body": "Be nice.",
"yes": {"label": "Accept", "action": {"type": "custom", "id": "accept"}},
"no": {"label": "Leave", "action": {"type": "custom", "id": "leave"}},
}));
}
fn dialog(&mut self, response: DialogResponse) {
if response.action == "leave" {
let _ = players::kick(&response.ctx.uuid, "You declined the rules.");
}
}
}Re-exports§
pub use bindings::rift::plugin::types::BossBarSpec;pub use bindings::rift::plugin::types::BusMessage;pub use bindings::rift::plugin::types::ChatAttempt;pub use bindings::rift::plugin::types::ChatKind;pub use bindings::rift::plugin::types::CommandSpec;pub use bindings::rift::plugin::types::Connection;pub use bindings::rift::plugin::types::Context;pub use bindings::rift::plugin::types::DialogResponse;pub use bindings::rift::plugin::types::Hook;pub use bindings::rift::plugin::types::HttpRequest;pub use bindings::rift::plugin::types::HttpResponse;pub use bindings::rift::plugin::types::Level;pub use bindings::rift::plugin::types::Ping;pub use bindings::rift::plugin::types::PlayerInfo;pub use bindings::rift::plugin::types::PlayerRecord;pub use bindings::rift::plugin::types::ProfileProperty;pub use bindings::rift::plugin::types::Registration;pub use bindings::rift::plugin::types::RiftEvent;pub use bindings::rift::plugin::types::RouteDecision;pub use bindings::rift::plugin::types::StatusOverride;pub use bindings::rift::plugin::types::SuggestKind;pub use bindings::rift::plugin::types::Suggestion;pub use bindings::rift::plugin::types::Text;pub use bindings::rift::plugin::types::TitleSpec;
Modules§
- background
- Work that must not hold up the plugin’s hooks.
- bindings
- The raw generated bindings for the
pluginworld, including WASI. - bus
- The message bus. Subscriptions deliver to
Plugin::message. - log
- Rift’s log stream, prefixed with the plugin name by the host.
- meta
- Plugin metadata and clocks.
apiis therift:pluginversion the host implements, such as0.2.0. - permissions
- Rift’s permission store: user nodes and group memberships.
- players
- The joined players, and what Rift remembers about those who left.
targetis a name, a UUID or a connection id. - servers
- Managed servers, through the
rift.control.*operations. Results are the control service’s JSON replies. - store
- The durable key-value store. Namespaces are prefixed with
plugin.<name>.by the host. - text
- Builders for JSON text: each converts into a
Text::Json, so it can be passed wherever text is shown. - timers
- Timers fire through
Plugin::timerwith the returned handle.
Macros§
- export
- Exports a
Pluginimplementation as the component’srift:plugin/handlerinterface. Use it exactly once per plugin crate.
Traits§
Functions§
- config
- Decodes the JSON configuration
init,reloadandwork_initreceive.
Type Aliases§
- Action
- The answer to a decision hook: the contract’s
decisionvariant.