feat: add wasm plugin api (#1675)

* feat: wasm plugin api base

* fix: make metadata macro work

* chore: fix clippy lints

* Implement guest registering of events

* chore: remove hardcoded v0.1.0  dep in wasm_host

* feat: add events to wasm

* feat: add caching of components

* fix: clippy lints

* chore: update Cargo.lock

* fix: add feature flag for link section

* feat: add base for command wit definitions

* feat: implement arg variants for command wit interface

* feat: add commented out command-tree arg variant

* feat: add wit definitions for text-component and command tree

Introduces the text interface with a builder-pattern resource,
argument-type variant, command-node resource, and register-command
on context. Migrates event and command-sender to use the new
text-component resource.

* feat: add stubs for wasm host

* chore: update wasmtime to v42

* feat: implement three methods of wit textcomponent

* Add consume function

* feat: implement all text-component host methods

Replace todo!() stubs with working implementations for all
HostTextComponent trait methods: style setters, click events,
hover events, add_text, get_text, and encode.

* feat: allow events to be modified

* feat: add some of base for commands

* chore: fix spelling mistake

* chore: remove unused dep

* feat: implement all argument-type to consumer mappings

* Add command exports

* log plugin load errors

* Some commands work

* Some more Commands work

* Update how commands are registered

* Implement locale

* Use handler id with require

* chore: fix some warnings

* Add PlayerLeaveEvent

* chore: fix all clippy warnings

* fix: add more descriptive error message

---------

Co-authored-by: Purdze <r.s.sutton@hotmail.co.uk>
Co-authored-by: Alexander Medvedev <lilalexmed@proton.me>
This commit is contained in:
Bjorn Beishline
2026-03-09 10:12:48 -07:00
committed by GitHub
parent 7e1e411993
commit 576d76dfd6
47 changed files with 4826 additions and 89 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "pumpkin-plugin-api"
# This should be different then the pumpkin server verion
version = "0.1.0"
edition.workspace = true
[dependencies]
wit-bindgen = { workspace = true }
tracing = { workspace = true }
postcard = { workspace = true }
tracing-serde-structured = { workspace = true }
[package.metadata.component]
target = { path = "../pumpkin-plugin-wit" }

View File

@@ -0,0 +1,59 @@
use std::{
collections::BTreeMap,
sync::{
Mutex,
atomic::{AtomicU32, Ordering},
},
};
pub use crate::wit::pumpkin::plugin::command::Command;
use crate::{
Result, Server,
command::CommandNode,
wit::pumpkin::plugin::command::{CommandError, CommandSender, ConsumedArgs},
};
pub(crate) static NEXT_COMMAND_ID: AtomicU32 = AtomicU32::new(0);
pub(crate) static COMMAND_HANDLERS: Mutex<BTreeMap<u32, Box<dyn CommandHandler>>> =
Mutex::new(BTreeMap::new());
pub trait CommandHandler: Send + Sync {
fn handle(
&self,
sender: CommandSender,
server: Server,
args: ConsumedArgs,
) -> Result<i32, CommandError>;
}
impl Command {
/// Registers a command handler with the plugin.
pub fn execute<H: CommandHandler + Send + Sync + 'static>(self, handler: H) -> Command {
let id = NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed);
COMMAND_HANDLERS
.lock()
.unwrap()
.insert(id, Box::new(handler));
self.execute_with_handler_id(id);
self
}
}
impl CommandNode {
/// Registers a command handler with the plugin.
pub fn execute<H: CommandHandler + Send + Sync + 'static>(self, handler: H) -> CommandNode {
let id = NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed);
COMMAND_HANDLERS
.lock()
.unwrap()
.insert(id, Box::new(handler));
self.execute_with_handler_id(id);
self
}
}

View File

@@ -0,0 +1,107 @@
use std::{
collections::BTreeMap,
marker::PhantomData,
pin::Pin,
sync::{
Mutex,
atomic::{AtomicU32, Ordering},
},
};
pub use crate::wit::pumpkin::plugin::event::{
Event, EventPriority, PlayerJoinEventData, PlayerLeaveEventData,
};
use crate::{Context, Result, Server, wit::pumpkin::plugin::event::EventType};
pub(crate) static NEXT_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
pub(crate) static EVENT_HANDLERS: Mutex<BTreeMap<u32, Box<dyn ErasedEventHandler>>> =
Mutex::new(BTreeMap::new());
pub trait FromIntoEvent: Sized {
const EVENT_TYPE: EventType;
fn from_event(event: Event) -> Self;
fn into_event(self) -> Event;
}
impl FromIntoEvent for PlayerJoinEventData {
const EVENT_TYPE: EventType = EventType::PlayerJoinEvent;
fn from_event(event: Event) -> Self {
match event {
Event::PlayerJoinEvent(data) => data,
_ => panic!("unexpected event"),
}
}
fn into_event(self) -> Event {
Event::PlayerJoinEvent(self)
}
}
impl FromIntoEvent for PlayerLeaveEventData {
const EVENT_TYPE: EventType = EventType::PlayerLeaveEvent;
fn from_event(event: Event) -> Self {
match event {
Event::PlayerLeaveEvent(data) => data,
_ => panic!("unexpected event"),
}
}
fn into_event(self) -> Event {
Event::PlayerLeaveEvent(self)
}
}
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait EventHandler<E> {
fn handle(&self, server: Server, event: E) -> E;
}
pub(crate) trait ErasedEventHandler: Send + Sync {
fn handle_erased(&self, server: Server, event: Event) -> Event;
}
struct HandlerWrapper<E, H> {
handler: H,
_phantom: PhantomData<E>,
}
impl<E: FromIntoEvent + Send + Sync, H: EventHandler<E> + Send + Sync> ErasedEventHandler
for HandlerWrapper<E, H>
{
fn handle_erased(&self, server: Server, event: Event) -> Event {
let specific_event = E::from_event(event);
self.handler.handle(server, specific_event).into_event()
}
}
impl Context {
/// Registers an event handler with the plugin.
///
/// The handler must implement the [`EventHandler`] trait.
/// If the event is blocking, returning an event from the handler will modify the event.
pub fn register_event_handler<
E: FromIntoEvent + Send + Sync + 'static,
H: EventHandler<E> + Send + Sync + 'static,
>(
&self,
handler: H,
event_priority: EventPriority,
blocking: bool,
) -> Result<u32> {
let id = NEXT_HANDLER_ID.fetch_add(1, Ordering::Relaxed);
let wrapped = HandlerWrapper {
handler,
_phantom: PhantomData::<E>,
};
EVENT_HANDLERS
.lock()
.map_err(|e| e.to_string())?
.insert(id, Box::new(wrapped));
self.register_event(id, E::EVENT_TYPE, event_priority, blocking);
Ok(id)
}
}

View File

@@ -0,0 +1,141 @@
use crate::{
commands::COMMAND_HANDLERS, events::EVENT_HANDLERS, logging::WitSubscriber, text::TextComponent,
};
pub mod commands;
pub mod events;
pub mod command {
pub use crate::wit::pumpkin::plugin::command::{
Command, CommandError, CommandNode, CommandSender, ConsumedArgs,
};
}
pub use wit::pumpkin::plugin::{
context::{Context, Server},
text,
};
pub mod logging;
mod wit {
wit_bindgen::generate!({
skip: ["init-plugin"],
path: "../pumpkin-plugin-wit/v0.1.0",
world: "plugin",
});
use super::Component;
export!(Component);
}
#[cfg(target_arch = "wasm32")]
#[unsafe(link_section = "pumpkin:api-version")]
#[used]
static API_VERSION: [u8; 5] = *b"0.1.0";
struct Component;
pub struct PluginMetadata {
pub name: String,
pub version: String,
pub authors: Vec<String>,
pub description: String,
}
impl wit::exports::pumpkin::plugin::metadata::Guest for Component {
fn get_metadata() -> wit::exports::pumpkin::plugin::metadata::PluginMetadata {
let metadata = plugin().metadata();
wit::exports::pumpkin::plugin::metadata::PluginMetadata {
name: metadata.name,
version: metadata.version,
authors: metadata.authors,
description: metadata.description,
}
}
}
impl wit::Guest for Component {
fn on_load(context: Context) -> Result<(), String> {
plugin().on_load(context)
}
fn on_unload(context: Context) -> Result<(), String> {
plugin().on_unload(context)
}
fn handle_event(event_id: u32, server: Server, event: events::Event) -> events::Event {
let handlers = EVENT_HANDLERS.lock().unwrap();
if let Some(handler) = handlers.get(&event_id) {
handler.handle_erased(server, event)
} else {
event
}
}
fn handle_command(
command_id: u32,
sender: command::CommandSender,
server: Server,
args: command::ConsumedArgs,
) -> Result<i32, command::CommandError> {
let handlers = COMMAND_HANDLERS.lock().unwrap();
if let Some(handler) = handlers.get(&command_id) {
handler.handle(sender, server, args)
} else {
Err(command::CommandError::CommandFailed(TextComponent::text(
&format!("no handler registered for command id {command_id}"),
)))
}
}
}
pub type Result<T, E = String> = core::result::Result<T, E>;
/// The trait that every Pumpkin plugin must implement.
pub trait Plugin: Send + Sync {
/// Create a new instance of the plugin.
fn new() -> Self
where
Self: Sized;
/// Define the metadata for the plugin.
fn metadata(&self) -> PluginMetadata;
/// Called when the plugin is loaded by the server.
fn on_load(&mut self, _context: Context) -> Result<()> {
Ok(())
}
/// Called when the plugin is unloaded by the server.
fn on_unload(&mut self, _context: Context) -> Result<()> {
Ok(())
}
}
#[doc(hidden)]
pub fn register_plugin(build_plugin: fn() -> Box<dyn Plugin>) {
let _ = tracing::subscriber::set_global_default(WitSubscriber::new());
unsafe { PLUGIN = Some((build_plugin)()) }
}
fn plugin() -> &'static mut dyn Plugin {
#[expect(static_mut_refs)]
unsafe {
PLUGIN.as_deref_mut().unwrap()
}
}
static mut PLUGIN: Option<Box<dyn Plugin>> = None;
/// Registers the provided type as a Pumpkin plugin.
///
/// The type must implement the [`Plugin`] trait.
#[macro_export]
macro_rules! register_plugin {
($plugin_type:ty) => {
#[unsafe(export_name = "init-plugin")]
pub extern "C" fn __init_plugin() {
$crate::register_plugin(|| Box::new(<$plugin_type as $crate::Plugin>::new()));
}
};
}

View File

@@ -0,0 +1,68 @@
use std::sync::atomic::{AtomicU64, Ordering};
use tracing_serde_structured::AsSerde;
use crate::wit;
pub(crate) struct WitSubscriber {
next_id: AtomicU64,
}
impl WitSubscriber {
pub fn new() -> Self {
Self {
next_id: AtomicU64::new(1),
}
}
}
impl tracing::Subscriber for WitSubscriber {
fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, _attrs: &tracing::span::Attributes<'_>) -> tracing::span::Id {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
tracing::span::Id::from_u64(id)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
let serialized =
postcard::to_allocvec(&event.as_serde()).expect("failed to serialize tracing event");
wit::pumpkin::plugin::logging::log_tracing(&serialized);
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
/// The log severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
fn to_wit(self) -> wit::pumpkin::plugin::logging::Level {
match self {
LogLevel::Trace => wit::pumpkin::plugin::logging::Level::Trace,
LogLevel::Debug => wit::pumpkin::plugin::logging::Level::Debug,
LogLevel::Info => wit::pumpkin::plugin::logging::Level::Info,
LogLevel::Warn => wit::pumpkin::plugin::logging::Level::Warn,
LogLevel::Error => wit::pumpkin::plugin::logging::Level::Error,
}
}
}
pub fn log(level: LogLevel, message: &str) {
wit::pumpkin::plugin::logging::log(level.to_wit(), message);
}