loadable-node-abi 0.2.0

abi_stable FFI surface for RemoteMedia SDK loadable plugins (NodePlugin, FfiNode, FfiNodeFactory). Pinned independently of the rest of the SDK so plugins can lock to a single ABI version.
Documentation
//! ABI-stable plugin interface.
//!
//! Both the host and the plugin depend on this crate. Everything that
//! crosses the `dlopen` boundary is defined here and uses `abi_stable`
//! types (`RVec`, `RString`, `RResult`, `RBox`, sabi-trait objects).
//!
//! `RuntimeData` itself is **not** in this surface — instead it is
//! serialized to msgpack bytes (`RVec<u8>` via `rmp-serde::to_vec_named`)
//! at the boundary. That keeps `remotemedia-core` out of the FFI
//! contract entirely, so a plugin built against a different rustc /
//! feature set can still load.
//!
//! For the full contract — wire format, versioning policy, plugin
//! author rules, change history — see
//! [`docs/LOADABLE_NODE_ABI.md`](../../../docs/LOADABLE_NODE_ABI.md).

use abi_stable::{
    declare_root_module_statics,
    library::RootModule,
    package_version_strings, sabi_trait,
    sabi_types::VersionStrings,
    std_types::{RBox, RErr, ROk, RResult, RString, RVec},
    StableAbi,
};
use async_ffi::{FfiFuture, FutureExt};

/// FFI-safe node.
///
/// `process` returns an `FfiFuture` — an ABI-stable future the host
/// can `.await` directly. Plugin-side async runtimes (or none) work as
/// long as the future polls to completion without referencing
/// runtime-specific globals; in practice, plugins that call back into
/// host services do so by polling synchronous state from the async
/// block.
///
/// # Forward compatibility (multi-output extension)
///
/// `process` is marked `#[sabi(last_prefix_field)]` — that's the cut
/// between the original FFI surface (1.x) and any methods added in
/// minor versions. Methods added below it must carry a default impl
/// so older plugins (whose vtables only expose `process`) continue to
/// load. Hosts compiled against the newer ABI then transparently fall
/// back to the default whenever a plugin omits the new method.
///
/// `process_multi` is the multi-output sibling of `process`: a node's
/// `process_streaming` callback can fire N times per input (think
/// SileroVAD emitting `Json(event)` plus the audio passthrough), and
/// the single-output `process` would silently drop everything but the
/// first emission. `process_multi` returns the full `RVec` so the
/// host can dispatch each blob into the streaming callback chain.
///
/// The default impl wraps `process` as a 1-element `RVec` so plugins
/// that only implement single-output stay correct (just lossy when
/// the underlying node was actually multi-output — same behaviour as
/// before this method existed).
#[sabi_trait]
pub trait FfiNode: Send + Sync + 'static {
    fn node_type(&self) -> RString;

    #[sabi(last_prefix_field)]
    fn process(&self, input: RVec<u8>) -> FfiFuture<RResult<RVec<u8>, RString>>;

    /// Multi-output process. Returns ALL emissions from one input as
    /// a flat `RVec<RVec<u8>>` (each inner vec is one rmp-serde
    /// `RuntimeData` blob, in emission order).
    ///
    /// Default impl: delegate to `process` and wrap its single output
    /// as a 1-element vec. Plugins that haven't been rebuilt against
    /// the multi-output ABI keep working — just without multi-output
    /// semantics. Plugins that override this method get full N-output
    /// fidelity through the `LoadableNodeAdapter` host wiring.
    fn process_multi(&self, input: RVec<u8>) -> FfiFuture<RResult<RVec<RVec<u8>>, RString>> {
        let fut = self.process(input);
        async move {
            match fut.await {
                ROk(out) => ROk(RVec::from(vec![out])),
                RErr(e) => RErr(e),
            }
        }
        .into_ffi()
    }

    /// One-time, per-session initialization hook for lazy-load plugins.
    ///
    /// Forwarded from the host's `AsyncStreamingNode::initialize()`
    /// once per session, before the first `process` call. Plugins that
    /// do all their work eagerly inside `FfiNodeFactory::create()`
    /// (e.g. audio2face's `Audio2FaceLipSyncNode::load`, live2d-render's
    /// `WgpuBackend::new`) can leave this defaulted. Plugins with a
    /// non-trivial init (e.g. llama-cpp spawning a worker thread that
    /// loads a multi-GB GGUF) must override it — without forwarding,
    /// the worker is never spawned and `process` returns "worker not
    /// running".
    ///
    /// `session_id` and `node_id` are forwarded as RStrings so the
    /// plugin can log / tag work with them. `emit_progress` is NOT
    /// forwarded today — progress events emitted from inside a
    /// loadable plugin's `initialize()` are silently dropped. Plugin
    /// authors who need progress visibility should wrap heavy init in
    /// the host (e.g. via `WarmSessionPool::prewarm` which fires its
    /// own progress before delegating).
    ///
    /// Default impl: no-op. Older plugins not rebuilt against this
    /// method keep compiling, just without lazy-init semantics.
    fn initialize(
        &self,
        _session_id: RString,
        _node_id: RString,
    ) -> FfiFuture<RResult<(), RString>> {
        async { ROk(()) }.into_ffi()
    }
}

/// Owned trait object for an FFI node.
///
/// `sabi_trait` drops the lifetime parameter when the trait has a
/// `'static` bound, so the alias does not name a lifetime.
pub type FfiNodeBox = FfiNode_TO<RBox<()>>;

/// FFI-safe factory — produces FfiNode instances from a JSON params blob.
#[sabi_trait]
pub trait FfiNodeFactory: Send + Sync + 'static {
    fn node_type(&self) -> RString;
    fn create(&self, params: RString) -> RResult<FfiNodeBox, RString>;
}

/// Owned trait object for an FFI factory.
pub type FfiNodeFactoryBox = FfiNodeFactory_TO<RBox<()>>;

/// Root module exported by every plugin.
///
/// abi_stable validates layout, abi_stable version, and prefix-type
/// compatibility when the host calls `NodePluginRef::load_from_file`.
#[repr(C)]
#[derive(StableAbi)]
#[sabi(kind(Prefix(prefix_ref = NodePluginRef)))]
#[sabi(missing_field(panic))]
pub struct NodePlugin {
    /// Returns every factory this plugin provides.
    #[sabi(last_prefix_field)]
    pub list_factories: extern "C" fn() -> RVec<FfiNodeFactoryBox>,
}

impl RootModule for NodePluginRef {
    declare_root_module_statics! {NodePluginRef}
    const BASE_NAME: &'static str = "node_plugin";
    const NAME: &'static str = "node_plugin";
    const VERSION_STRINGS: VersionStrings = package_version_strings!();
}