use anyhow::Context; use libloading::{Library, Symbol}; use once_cell::sync::OnceCell; use owly_news_module_api::{take_cstring, SYMBOL_INVOKE, SYMBOL_NAME}; use std::collections::HashMap; use std::ffi::CString; use std::os::raw::c_char; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::Mutex; use tracing::info; type ModuleNameFn = unsafe extern "C" fn() -> *const c_char; type ModuleInvokeFn = unsafe extern "C" fn(*const c_char, *const c_char) -> *mut c_char; pub struct ModuleHandle { _lib: Arc, invoke: ModuleInvokeFn, } impl ModuleHandle { pub fn invoke_json(&self, op: &str, payload: serde_json::Value) -> anyhow::Result { let op_c = CString::new(op)?; let payload_c = CString::new(serde_json::to_string(&payload)?)?; let out_ptr = unsafe { (self.invoke)(op_c.as_ptr(), payload_c.as_ptr()) }; let out = unsafe { take_cstring(out_ptr) }?; let val = serde_json::from_str(&out).context("module returned invalid JSON")?; Ok(val) } } pub struct ModuleHost { // Lazy cache of loaded modules by logical name loaded: Mutex>>, modules_dir: PathBuf, } static DEFAULT_HOST: OnceCell> = OnceCell::new(); impl ModuleHost { pub fn default() -> Arc { DEFAULT_HOST .get_or_init(|| { Arc::new(Self::new( std::env::var_os("OWLY_MODULES_DIR") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("target/modules")), // default location )) }) .clone() } pub fn new(modules_dir: PathBuf) -> Self { Self { loaded: Mutex::new(HashMap::new()), modules_dir, } } pub async fn get(&self, name: &str) -> anyhow::Result> { if let Some(h) = self.loaded.lock().await.get(name).cloned() { return Ok(h); } let handle = Arc::new(self.load_module(name)?); self.loaded.lock().await.insert(name.to_string(), handle.clone()); Ok(handle) } fn load_module(&self, name: &str) -> anyhow::Result { let lib_path = resolve_module_path(&self.modules_dir, name)?; info!(module = name, path = %lib_path.display(), "loading module"); // SAFETY: we keep Library alive in ModuleHandle to ensure symbols remain valid let lib = unsafe { Library::new(lib_path) }.with_context(|| "failed to load module library")?; // Validate and bind symbols let name_fn: Symbol = unsafe { lib.get(SYMBOL_NAME.as_bytes()) } .with_context(|| "missing symbol `module_name`")?; let invoke_fn: Symbol = unsafe { lib.get(SYMBOL_INVOKE.as_bytes()) } .with_context(|| "missing symbol `module_invoke`")?; // Optional: verify reported name matches requested let c_name_ptr = unsafe { name_fn() }; let c_name = unsafe { std::ffi::CStr::from_ptr(c_name_ptr) }.to_string_lossy().into_owned(); if c_name != name { anyhow::bail!("module reported name `{c_name}`, expected `{name}`"); } // Copy the function pointer before moving the library let invoke_fn_copy = *invoke_fn; Ok(ModuleHandle { _lib: Arc::new(lib), invoke: invoke_fn_copy, }) } } fn resolve_module_path(dir: &Path, name: &str) -> anyhow::Result { #[cfg(target_os = "windows")] const EXT: &str = "dll"; #[cfg(target_os = "macos")] const EXT: &str = "dylib"; #[cfg(all(unix, not(target_os = "macos")))] const EXT: &str = "so"; let fname = format!("lib{name}.{EXT}"); let path = dir.join(fname); if !path.exists() { anyhow::bail!("module `{name}` not found at {}", path.display()); } Ok(path) }