/// The Rift plugin contract.
///
/// One event schema and one host API, shared by every execution mode: the
/// sandboxed configuration script, persistent Lua and WebAssembly plugins and
/// remote bus responders. Rust host types mirror these definitions with serde
/// derives; the bus uses their JSON form.
///
/// Rules that keep C and Go guests pleasant (see docs/plugins.md):
/// - WASI 0.2, no `async`, no `stream` or `future`.
/// - No `resource` types: host-managed things are plain `u64` handles.
/// - Plain data only: no `flags`, no nested options, tuples only as pairs.
/// - Plugin configuration and free-form payloads are JSON strings.
/// - Every hook is an export and every export has a default in each SDK.
/// - The guest is single-threaded; the host never calls one instance
/// concurrently.
/// - Additive changes keep compiled components loading: new host functions,
/// new types and new interfaces, never a changed type or a new case in an
/// existing enum or variant. Wasmtime matches `@0.2.x` names with each
/// other, so a component built against 0.2.0 loads on every later 0.2.
/// - Breaking changes move to the next minor version until 1.0. 0.2.0 is
/// 0.1.5 without its duplicates: one `text` type instead of plain and
/// `-json` forms, one `decision`, and `dialog`, `chat` and `kick` in
/// `handler`. Components built against 0.1 do not load.
package rift:plugin@0.2.0;
interface types {
/// Log severity.
enum level {
trace,
debug,
info,
warn,
error,
}
/// Text shown to a player.
variant text {
/// 1..=1024 bytes, shown exactly as written.
plain(string),
/// A JSON text component of at most 32 KiB, for colours, line
/// breaks, hover text and click events, including the `mini` and
/// `legacy` authoring objects (see docs/text.md). Rift spells hover
/// and click events the way each player's client reads them.
json(string),
}
/// One property of a player's profile, as the session server signed it.
record profile-property {
/// For example `textures`, the skin and cape.
name: string,
value: string,
signature: option<string>,
}
/// A joined player in the process-wide directory.
record player-info {
connection-id: u64,
/// 32 lowercase hexadecimal digits, without dashes.
uuid: string,
name: string,
/// The backend the player is connected to.
server: string,
protocol: s32,
/// The address the player connected from, without the port.
ip: string,
/// The profile properties the player logged in with.
properties: list<profile-property>,
/// The permission node a viewer needs to see the player, from their
/// `rift.visible-to` meta, or `none` when everyone sees them. `list`
/// and `get` return hidden players too; Rift leaves them out of tab
/// lists, counts and completion for viewers without the node.
visible-to: option<string>,
/// The language the player's client reported, as the client spells
/// it (`en_us`; old clients send `en_US`), or `none` while Rift does
/// not know it yet. The client sends it after login: during
/// configuration from 1.20.2, after Join Game before.
locale: option<string>,
/// The language Rift's own messages use for the player: the one set
/// with `players.set-language`, else `locale`.
language: option<string>,
}
/// What Rift remembers about a player who joined at least once, online
/// or not.
record player-record {
uuid: string,
/// The name the player last joined with.
name: string,
/// Unix seconds.
first-seen: u64,
/// Unix seconds: the last join or quit.
last-seen: u64,
/// Empty when `players.record_ip` is off.
last-ip: string,
last-server: string,
online: bool,
/// The language set with `players.set-language`, if any.
language: option<string>,
}
/// The session an authenticated player hook is about. Fields that do not
/// apply to a hook are `none` or empty.
record context {
connection-id: u64,
uuid: string,
name: string,
listener: string,
hostname: string,
peer-ip: string,
protocol: s32,
server: option<string>,
default-server: option<string>,
/// `before-transfer`/`after-transfer`: the destination.
target: option<string>,
/// `before-transfer`: why the transfer happens; `disconnect`: why the
/// session closed.
reason: option<string>,
/// `after-transfer`: whether the transfer completed.
success: option<bool>,
/// `command`/`suggest`, and `chat` for a command: the command name
/// and the text after it.
command: option<string>,
args: string,
/// `suggest`: the argument being completed and its 1-based position.
word: string,
position: u32,
/// The language set for the player with `players.set-language`, known
/// from `login` on, else the client's locale once it is known.
language: option<string>,
/// `command`/`suggest`: the operator console runs the command, not a
/// player. `connection-id` is 0, `uuid` is empty and `name` is
/// `CONSOLE`; only commands registered with `console` get here.
console: bool,
}
/// Metadata available right after TCP accept, before any bytes are read.
record connection {
listener: string,
peer-addr: string,
peer-ip: string,
peer-port: u16,
local-addr: string,
local-ip: string,
local-port: u16,
default-backend: option<string>,
}
/// `route`: keep the configured policy, pick a configured backend or
/// group, or close the connection.
variant route-decision {
default,
backend(string),
reject(option<string>),
}
/// The answer to a decision hook. Each hook accepts a subset:
/// - login, before-transfer, chat: continue, deny
/// - initial-server, kick: continue, deny, server
/// - command: continue, message, server, queue, leave-queue
variant decision {
continue,
deny(text),
server(string),
message(text),
queue(string),
leave-queue,
}
record suggestion {
text: string,
tooltip: option<text>,
}
/// A server-list ping, before any backend contact.
record ping {
listener: string,
hostname: string,
protocol: s32,
peer-ip: string,
/// The routed backend or group; `none` for an unmatched hostname.
backend: option<string>,
maintenance: bool,
online: u64,
max-players: u64,
}
/// Fields a `status` hook replaces for one ping. Clients show the
/// version and the sample lines as `ยง` codes.
record status-override {
motd: option<text>,
version: option<text>,
protocol: option<s32>,
online: option<u32>,
max-players: option<u32>,
sample: option<list<text>>,
/// A `data:image/png;base64,...` URI.
favicon: option<string>,
}
/// An authenticated request to `/ext` or `/ext/*` on the web server.
record http-request {
method: string,
path: string,
query: string,
body: string,
headers: list<tuple<string, string>>,
/// The status object with operator detail, as a JSON object, plus
/// `operator`: the authenticated caller as `{name, permissions,
/// groups}`. `permissions` is `null` for full access and `groups` is
/// `null` when the caller is not limited to service groups. Any
/// caller with the `extensions` permission reaches this hook, so check
/// `operator` before acting for it.
context-json: string,
}
record http-response {
status: u16,
content-type: string,
body: string,
}
/// A bus message delivered to a subscription made with `bus.subscribe`.
record bus-message {
/// The handle `bus.subscribe` returned.
handle: u64,
subject: string,
payload: list<u8>,
reply: option<string>,
}
/// A Rift event, mirroring a `rift.events.*` publication.
record rift-event {
/// For example `rift.events.players` or `rift.events.instances`.
subject: string,
/// The event body as published on the bus (a JSON object).
payload-json: string,
}
/// How a command's arguments are completed.
variant suggest-kind {
none,
/// A fixed list, filtered by prefix.
fixed(list<string>),
/// Online player names.
players,
/// Servers the player may join.
servers,
/// Ask the plugin's `suggest` export.
dynamic,
}
record command-spec {
/// 1..=64 bytes of `a-z0-9_-`; `server` and `hub` are reserved.
name: string,
/// Other names for the command, each like `name`, at most 16. The
/// hooks always see `name` in `ctx.command`.
aliases: list<string>,
/// The permission node a player needs to see and run the command.
/// Empty means every player may see and run it.
permission: string,
suggest: suggest-kind,
/// Operators may run the command from the console (`rift admin
/// plugins run`); the hook then sees `ctx.console`.
console: bool,
}
/// Events a plugin can receive. The host skips exports a plugin does not
/// list, so unused hooks cost nothing per event.
enum hook {
route,
login,
initial-server,
before-transfer,
command,
suggest,
status,
http,
join,
after-transfer,
disconnect,
message,
timer,
event,
/// Never implied by an empty list: Rift asks about every message of
/// every player.
chat,
/// Never implied by an empty list.
kick,
}
/// What `init` returns. Subscriptions and timers are created from inside
/// `init` through the host API.
record registration {
commands: list<command-spec>,
/// Events to deliver. An empty list delivers every hook except
/// `chat` and `kick`. `dialog` and `job-done` need no entry: they go
/// to the plugin that showed the dialog or started the job.
hooks: list<hook>,
}
/// A boss bar shown to one player.
record boss-bar-spec {
text: text,
/// 0.0 to 1.0.
progress: f32,
/// pink, blue, red, green, yellow, purple or white.
color: string,
/// progress, notched_6, notched_10, notched_12 or notched_20.
style: string,
darken-sky: bool,
play-music: bool,
create-fog: bool,
}
/// A title; times are ticks. At least one of `title` and `subtitle` is
/// set; without `title` only the subtitle is shown.
record title-spec {
title: option<text>,
subtitle: option<text>,
fade-in: s32,
stay: s32,
fade-out: s32,
}
/// What a player sent through the chat box.
enum chat-kind {
/// A chat message.
message,
/// A command that neither Rift nor a plugin owns, on its way to the
/// backend.
command,
}
/// A chat message or backend command a player sent, before the backend
/// sees it; the input of the `chat` hook.
record chat-attempt {
/// The player. `server` is their current backend. For `command`,
/// `ctx.command` is the root name and `ctx.args` the rest.
ctx: context,
kind: chat-kind,
/// The message, or the command line without the leading slash.
text: string,
}
/// A click on a plugin action of a dialog this plugin showed with
/// `players.dialog`.
record dialog-response {
/// The player who clicked. `server` is their current backend; the
/// command, target and suggestion fields are empty.
ctx: context,
/// The action's `id` as the plugin wrote it, such as `submit`.
action: string,
/// The player's input values by key, as a JSON object: strings for
/// text and single-option inputs, booleans, numbers. `{}` for a
/// `custom` action. They come from the client: check them like any
/// player input.
values-json: string,
/// The action's `payload` (`custom`) or `additions`
/// (`dynamic/custom`) as JSON, exactly as the plugin wrote it. Rift
/// keeps it, so the client can neither see nor change it.
payload-json: option<string>,
}
}
/// The joined players. `target` is a name, a UUID or a connection id.
interface players {
use types.{player-info, player-record, title-spec, boss-bar-spec, text};
/// Players, optionally only those on one server or service group.
%list: func(server: option<string>) -> result<list<player-info>, string>;
get: func(target: string) -> result<option<player-info>, string>;
/// Names starting with `prefix`, sorted.
names: func(prefix: option<string>) -> list<string>;
/// Each returns whether the player was online.
message: func(target: string, text: text) -> result<bool, string>;
action-bar: func(target: string, text: text) -> result<bool, string>;
title: func(target: string, title: title-spec) -> result<bool, string>;
/// Both `none` restores the configured tab list.
tab-list: func(target: string, header: option<text>, footer: option<text>) -> result<bool, string>;
/// `none` removes the bar called `name`.
boss-bar: func(target: string, name: string, bar: option<boss-bar-spec>) -> result<bool, string>;
kick: func(target: string, reason: text) -> result<bool, string>;
/// Show a dialog to a player on Minecraft 1.21.6 or newer, replacing any
/// open one. `dialog-json` is a dialog in the vanilla JSON format; see
/// docs/dialogs.md for the accepted subset. Buttons whose `custom` or
/// `dynamic/custom` action has an `id` without a namespace come back
/// through the `dialog` hook. Returns false when the player is offline,
/// older than 1.21.6 or has too many undelivered actions; an invalid
/// dialog is an error.
dialog: func(target: string, dialog-json: string) -> result<bool, string>;
/// Close the dialog this Rift showed, if it is still open.
clear-dialog: func(target: string) -> result<bool, string>;
transfer: func(target: string, server: string) -> result<bool, string>;
/// Returns how many players received the message.
broadcast: func(text: text) -> result<u32, string>;
/// What Rift remembers about a player who joined at least once, by UUID
/// or by the name they last joined with (case-insensitive), whether they
/// are online or not. `none` for a player Rift never saw.
lookup: func(target: string) -> result<option<player-record>, string>;
/// Remembered players whose name starts with `prefix`, most recently
/// seen first, at most `limit` (1..=100).
search: func(prefix: string, limit: u32) -> result<list<player-record>, string>;
/// Set the language Rift's own messages use for a player, online or not:
/// a tag of 2..=16 bytes of `a-z0-9_`, such as `de` or `de_de`; `none`
/// goes back to the client's locale. `target` is a UUID or a name Rift
/// has seen. It is remembered across sessions, so it is known at
/// `login`. Returns whether the player is known.
set-language: func(target: string, language: option<string>) -> result<bool, string>;
}
/// The durable key-value store. Namespaces are prefixed with
/// `plugin.<name>.` for persistent plugins.
interface store {
get: func(namespace: string, key: string) -> result<option<list<u8>>, string>;
set: func(namespace: string, key: string, value: list<u8>) -> result<_, string>;
/// Returns whether an entry existed.
delete: func(namespace: string, key: string) -> result<bool, string>;
/// Adds `delta` to an integer entry (missing counts as 0) and returns it.
increment: func(namespace: string, key: string, delta: s64) -> result<s64, string>;
/// Entries whose key starts with `prefix`, sorted by key.
%list: func(namespace: string, prefix: string) -> result<list<tuple<string, list<u8>>>, string>;
}
/// Rift's permission store: user nodes and group memberships.
interface permissions {
/// Whether the node is true in the player's current contexts.
has: func(uuid: string, node: string) -> bool;
/// Set the user's global permission node; `none` removes it.
set: func(uuid: string, node: string, value: option<bool>) -> result<_, string>;
/// Add the user to a group, until `expires-at` Unix seconds if given
/// (replacing an earlier expiry). Returns whether anything changed.
add-group: func(uuid: string, group: string, expires-at: option<u64>) -> result<bool, string>;
remove-group: func(uuid: string, group: string) -> result<bool, string>;
/// The player's groups, including inherited and `default`.
groups: func(uuid: string) -> list<string>;
/// Every group with its weight, display name and nodes, as a JSON object.
list-groups: func() -> string;
/// One meta value in the player's current contexts.
meta: func(uuid: string, key: string) -> option<string>;
/// Set the user's meta node `key` in exactly the contexts of
/// `contexts-json` (a JSON object such as `{"server":"lobby-1"}`; `none`
/// is global), expiring at `expires-at` Unix seconds if given; a `none`
/// value removes the node in those contexts. Returns whether anything
/// changed.
set-meta: func(uuid: string, key: string, value: option<string>, contexts-json: option<string>, expires-at: option<u64>) -> result<bool, string>;
}
/// The message bus.
interface bus {
/// Returns how many subscribers received the message.
publish: func(subject: string, payload: list<u8>, reply: option<string>) -> result<u32, string>;
/// Send a request and wait for the first reply.
request: func(subject: string, payload: list<u8>, timeout-ms: u32) -> result<list<u8>, string>;
/// Messages arrive through the `message` export with this handle.
/// `queue-group` load-balances between subscribers of the same group.
subscribe: func(subject: string, queue-group: option<string>) -> result<u64, string>;
unsubscribe: func(handle: u64) -> bool;
/// Append to a durable stream; returns the sequence number.
stream-publish: func(subject: string, payload: list<u8>) -> result<u64, string>;
}
/// Managed servers, through the same `rift.control.*` operations as the bus.
/// Results are the control service's JSON replies.
interface servers {
create: func(group: string) -> result<string, string>;
remove: func(name: string) -> result<string, string>;
start: func(name: string) -> result<string, string>;
stop: func(name: string) -> result<string, string>;
download: func(template: string) -> result<string, string>;
/// Any other `rift.control.<operation>` (status, servers, groups, nodes,
/// templates, transfer, drain, maintenance, reload) with a JSON payload.
control: func(operation: string, payload-json: string) -> result<string, string>;
}
/// Timers fire through the `timer` export with the returned handle.
interface timers {
/// Repeats every `ms` milliseconds (at least 10).
every: func(ms: u32) -> u64;
/// Fires once after `ms` milliseconds.
after: func(ms: u32) -> u64;
/// Returns whether the timer existed.
cancel: func(handle: u64) -> bool;
}
/// Work that must not hold up the plugin's hooks. A plugin runs one call at
/// a time, so a slow lookup in `login` would keep `chat` waiting past its
/// deadline. Instead the hook defers its answer, hands the lookup to a
/// background worker and returns; the answer follows from `job-done`.
interface background {
use types.{decision};
/// Call inside `login`, `initial-server`, `before-transfer`, `command`,
/// `chat` or `kick`: the hook's own return value is ignored, and the
/// answer is the one given to `resolve` with the returned ticket. The
/// hook's deadline keeps running: an answer that comes too late counts
/// as a missed deadline. An error outside those hooks, or when the call
/// was deferred already.
defer: func() -> result<u64, string>;
/// Answer a deferred hook. Returns false when nobody waits for the
/// ticket any more (the deadline passed, or it was answered).
resolve: func(ticket: u64, decision: decision) -> result<bool, string>;
/// Queue `payload` for the plugin's background workers (manifest
/// `[background] workers`, an error without any): one of them calls
/// `work(payload)`, and its result comes back through `job-done` with
/// the returned handle. At most 1024 jobs wait at a time.
run: func(payload: list<u8>) -> result<u64, string>;
/// Whether this instance is a background worker.
is-worker: func() -> bool;
}
/// Rift's log stream, prefixed with the plugin name.
interface log {
use types.{level};
log: func(level: level, message: string);
}
interface meta {
name: func() -> string;
version: func() -> string;
/// Guest path of the plugin's private data directory (`/data`).
data-dir: func() -> string;
/// Guest path of the configuration directory (`/host`), if granted.
config-dir: func() -> option<string>;
/// Monotonic seconds since Rift started.
clock: func() -> f64;
/// Unix time in seconds.
time: func() -> s64;
/// The `rift:plugin` version this host implements, such as `0.2.0`.
api: func() -> string;
}
/// Hooks the host calls. Every export has a default in each SDK, and the
/// host skips hooks the plugin does not list in its registration.
interface handler {
use types.{
context, connection, route-decision, decision, suggestion, ping,
status-override, http-request, http-response, bus-message, rift-event,
registration, dialog-response, chat-attempt,
};
/// Called once per instance with the `rift.plugin(name, config)` table
/// encoded as JSON (`{}` without one).
init: func(config-json: string) -> result<registration, string>;
/// The configuration changed and the manifest sets `hot_reload = true`.
reload: func(config-json: string) -> result<_, string>;
shutdown: func(reason: string);
route: func(connection: connection) -> route-decision;
login: func(ctx: context) -> decision;
initial-server: func(ctx: context) -> decision;
before-transfer: func(ctx: context) -> decision;
command: func(ctx: context) -> decision;
suggest: func(ctx: context) -> list<suggestion>;
status: func(ping: ping) -> option<status-override>;
http: func(request: http-request) -> option<http-response>;
/// A chat message or a backend command before the backend sees it, for
/// mutes and filters; only for a registration that lists `chat`.
/// `continue` passes the packet on unchanged; `deny` drops it and shows
/// the player the text. Withholding one signed message breaks a 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. Commands that Rift or a
/// plugin owns never come here. The deadline is 50 ms unless the manifest
/// sets `deadlines.chat`.
chat: func(attempt: chat-attempt) -> decision;
/// A backend kicked a player in play; only for a registration that lists
/// `kick`. `ctx.server` is the backend and `ctx.reason` its reason
/// without formatting. `continue` leaves it to `network.on_kick`, `deny`
/// disconnects the player with the text, `server` moves them there.
kick: func(ctx: context) -> decision;
join: func(ctx: context);
after-transfer: func(ctx: context);
disconnect: func(ctx: context);
message: func(message: bus-message);
timer: func(handle: u64);
event: func(event: rift-event);
/// A player clicked a plugin action of a dialog this plugin showed.
dialog: func(response: dialog-response);
/// A job started with `background.run` finished: what `work` returned,
/// or why it failed.
job-done: func(handle: u64, outcome: result<list<u8>, string>);
/// Background workers only (manifest `[background] workers`): each is
/// its own instance of the component with its own memory, and receives
/// no hooks and makes no registration. `work-init` runs once per worker
/// before its first job, with the plugin's configuration.
work-init: func(config-json: string) -> result<_, string>;
/// One job queued with `background.run`, on a worker. It may block: the
/// plugin's hooks keep running meanwhile. The limit is 30 s unless the
/// manifest sets `deadlines.work`.
work: func(payload: list<u8>) -> result<list<u8>, string>;
}
/// Every import and export, without WASI: what the host requires.
world core {
import players;
import store;
import permissions;
import bus;
import servers;
import timers;
import background;
import log;
import meta;
export handler;
}
/// The world guests build against. TinyGo's `wasip2` target expects the
/// WASI CLI import set even when a plugin does not use it.
world plugin {
include wasi:cli/imports@0.2.0;
include core;
}