Skip to main content

Crate rift_plugin

Crate rift_plugin 

Source
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:

HookAccepts
login, before_transfer, chatContinue, deny
initial_server, kickContinue, deny, server
commandContinue, 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>:

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 plugin world, 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. api is the rift:plugin version the host implements, such as 0.2.0.
permissions
Rift’s permission store: user nodes and group memberships.
players
The joined players, and what Rift remembers about those who left. target is 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::timer with the returned handle.

Macros§

export
Exports a Plugin implementation as the component’s rift:plugin/handler interface. Use it exactly once per plugin crate.

Traits§

Plugin
A Rift plugin. Implement the hooks you need and pass the type to export!.

Functions§

config
Decodes the JSON configuration init, reload and work_init receive.

Type Aliases§

Action
The answer to a decision hook: the contract’s decision variant.