use crate::api::{self, HostBridge};
use bext_plugin_api::lifecycle::LifecyclePlugin;
use bext_plugin_api::types::{PluginManifest, SandboxPermissions};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub struct QuickJsPluginConfig {
pub name: String,
pub path: PathBuf,
pub priority: u32,
pub permissions: SandboxPermissions,
pub config: serde_json::Value,
}
struct QuickJsPlugin {
manifest: PluginManifest,
priority: u32,
rt: rquickjs::Runtime,
ctx: rquickjs::Context,
bridge: Arc<HostBridge>,
deadline: Arc<Mutex<Option<Instant>>>,
}
pub struct QuickJsPluginRuntime {
storage_root: PathBuf,
plugins: Vec<QuickJsPlugin>,
}
impl QuickJsPluginRuntime {
pub fn new(storage_root: PathBuf) -> Self {
Self {
storage_root,
plugins: Vec::new(),
}
}
pub fn load_plugin(&mut self, config: QuickJsPluginConfig) -> Result<(), String> {
if !config.path.exists() {
return Err(format!("JS plugin not found: {}", config.path.display()));
}
tracing::info!(name = %config.name, path = %config.path.display(), "loading QuickJS plugin");
let source = std::fs::read_to_string(&config.path).map_err(|e| format!("read JS: {e}"))?;
let rt = rquickjs::Runtime::new().map_err(|e| format!("create QuickJS runtime: {e}"))?;
rt.set_memory_limit(config.permissions.max_memory_mb as usize * 1024 * 1024);
rt.set_max_stack_size(1024 * 1024);
let deadline: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let deadline_check = deadline.clone();
rt.set_interrupt_handler(Some(Box::new(move || {
if let Ok(guard) = deadline_check.lock() {
if let Some(dl) = *guard {
return Instant::now() > dl;
}
}
false
})));
let bridge = Arc::new(HostBridge::new(
config.name.clone(),
config.permissions,
&self.storage_root,
config.config,
));
let ctx =
rquickjs::Context::full(&rt).map_err(|e| format!("create QuickJS context: {e}"))?;
let bridge_ref = bridge.clone();
ctx.with(|ctx| -> Result<(), String> {
api::register_globals(&ctx, bridge_ref)
.map_err(|e| format!("register globals: {e}"))?;
ctx.eval::<(), _>(source.as_bytes())
.map_err(|e| format!("eval plugin: {e}"))?;
Ok(())
})?;
let manifest = PluginManifest {
name: config.name.clone(),
version: "1.0.0".into(),
description: format!("QuickJS plugin: {}", config.path.display()),
capabilities: vec![bext_plugin_api::types::PluginCapability::Lifecycle],
provides_capabilities: Vec::new(),
requires_capabilities: Vec::new(),
};
tracing::info!(name = %manifest.name, "QuickJS plugin loaded");
self.plugins.push(QuickJsPlugin {
manifest,
priority: config.priority,
rt,
ctx,
bridge,
deadline,
});
Ok(())
}
pub fn into_lifecycle_plugins(&mut self) -> Vec<Box<dyn LifecyclePlugin>> {
std::mem::take(&mut self.plugins)
.into_iter()
.map(|p| {
let max_time_secs = p.bridge.permissions.max_time_secs;
Box::new(QuickJsLifecycleAdapter {
manifest: p.manifest,
priority: p.priority,
state: Mutex::new(QuickJsState {
ctx: p.ctx,
_rt: p.rt,
_bridge: p.bridge,
deadline: p.deadline,
max_time_secs,
}),
}) as Box<dyn LifecyclePlugin>
})
.collect()
}
pub fn len(&self) -> usize {
self.plugins.len()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
struct QuickJsState {
ctx: rquickjs::Context,
_rt: rquickjs::Runtime,
_bridge: Arc<HostBridge>,
deadline: Arc<Mutex<Option<Instant>>>,
max_time_secs: u64,
}
struct QuickJsLifecycleAdapter {
manifest: PluginManifest,
priority: u32,
state: Mutex<QuickJsState>,
}
impl QuickJsLifecycleAdapter {
fn set_deadline(state: &QuickJsState, secs: u64) {
if let Ok(mut dl) = state.deadline.lock() {
*dl = Some(Instant::now() + std::time::Duration::from_secs(secs));
}
}
fn clear_deadline(state: &QuickJsState) {
if let Ok(mut dl) = state.deadline.lock() {
*dl = None;
}
}
fn call_lifecycle(
state: &QuickJsState,
fn_name: &str,
args_json: &[&str],
) -> Result<(), String> {
Self::set_deadline(state, state.max_time_secs);
let result = state.ctx.with(|ctx| -> Result<(), String> {
let globals = ctx.globals();
let func: Option<rquickjs::Function> = globals.get(fn_name).ok();
let func = match func {
Some(f) if f.is_function() => f,
_ => return Ok(()), };
match args_json.len() {
0 => {
func.call::<_, ()>(())
.map_err(|e| format!("{fn_name}: {e}"))?;
}
1 => {
let arg: rquickjs::Value = ctx
.json_parse(args_json[0].to_string())
.unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
func.call::<_, ()>((arg,))
.map_err(|e| format!("{fn_name}: {e}"))?;
}
2 => {
let arg0: rquickjs::Value = ctx
.json_parse(args_json[0].to_string())
.unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
let arg1: rquickjs::Value = ctx
.json_parse(args_json[1].to_string())
.unwrap_or(rquickjs::Value::new_undefined(ctx.clone()));
func.call::<_, ()>((arg0, arg1))
.map_err(|e| format!("{fn_name}: {e}"))?;
}
_ => {
return Err(format!("{fn_name}: too many args (max 2)"));
}
}
Ok(())
});
Self::clear_deadline(state);
result
}
}
impl LifecyclePlugin for QuickJsLifecycleAdapter {
fn name(&self) -> &str {
&self.manifest.name
}
fn priority(&self) -> u32 {
self.priority
}
fn on_server_start(&self, config_json: &str) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
Self::call_lifecycle(&guard, "onServerStart", &[config_json])
}
fn on_server_stop(&self) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
Self::call_lifecycle(&guard, "onServerStop", &[])
}
fn on_request_complete(&self, event_json: &str) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
Self::call_lifecycle(&guard, "onRequestComplete", &[event_json])
}
fn on_cache_write(&self, key: &str, tags_json: &str) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
let key_json = serde_json::to_string(key).unwrap_or_default();
Self::call_lifecycle(&guard, "onCacheWrite", &[&key_json, tags_json])
}
fn on_cache_invalidate(&self, pattern: &str, count: u32) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
let pattern_json = serde_json::to_string(pattern).unwrap_or_default();
let count_json = count.to_string();
Self::call_lifecycle(&guard, "onCacheInvalidate", &[&pattern_json, &count_json])
}
fn on_reload(&self) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
Self::call_lifecycle(&guard, "onReload", &[])
}
fn cleanup(&self) -> Result<(), String> {
let guard = self
.state
.lock()
.map_err(|e| format!("lock poisoned: {e}"))?;
Self::call_lifecycle(&guard, "cleanup", &[])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_temp_plugin(name: &str, source: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("create tempdir");
let path = dir.path().join(format!("{name}.js"));
std::fs::write(&path, source).expect("write plugin file");
(dir, path)
}
fn load_lifecycle(
name: &str,
source: &str,
permissions: SandboxPermissions,
config: serde_json::Value,
) -> (tempfile::TempDir, Box<dyn LifecyclePlugin>) {
let (dir, path) = write_temp_plugin(name, source);
let storage_root = dir.path().join("storage");
let _ = std::fs::create_dir_all(&storage_root);
let mut rt = QuickJsPluginRuntime::new(storage_root);
rt.load_plugin(QuickJsPluginConfig {
name: name.into(),
path,
priority: 500,
permissions,
config,
})
.expect("load plugin");
let mut plugins = rt.into_lifecycle_plugins();
assert_eq!(plugins.len(), 1);
let plugin = plugins.remove(0);
(dir, plugin)
}
#[test]
fn runtime_empty() {
let rt = QuickJsPluginRuntime::new(PathBuf::from("/tmp/bext-quickjs"));
assert!(rt.is_empty());
assert_eq!(rt.len(), 0);
}
#[test]
fn load_nonexistent_fails() {
let mut rt = QuickJsPluginRuntime::new(PathBuf::from("/tmp/bext-quickjs"));
let result = rt.load_plugin(QuickJsPluginConfig {
name: "test".into(),
path: "/nonexistent/plugin.js".into(),
priority: 1000,
permissions: SandboxPermissions::default(),
config: serde_json::Value::Null,
});
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn on_server_start_called() {
let source = r#"
function onServerStart(config) {
// Store something to prove we ran
bext.storage.set("started", "yes");
}
"#;
let (dir, plugin) = load_lifecycle(
"starter",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_server_start("{}");
assert!(result.is_ok(), "on_server_start failed: {:?}", result);
let storage_path = dir.path().join("storage").join("starter").join("started");
assert!(storage_path.exists(), "storage file should exist");
let val = std::fs::read_to_string(&storage_path).unwrap();
assert_eq!(val, "yes");
}
#[test]
fn no_functions_all_noop() {
let source = r#"
// This plugin defines no lifecycle hooks
var x = 42;
"#;
let (_dir, plugin) = load_lifecycle(
"empty",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
assert!(plugin.on_server_start("{}").is_ok());
assert!(plugin.on_server_stop().is_ok());
assert!(plugin.on_request_complete("{}").is_ok());
assert!(plugin.on_cache_write("key", "[]").is_ok());
assert!(plugin.on_cache_invalidate("*", 5).is_ok());
assert!(plugin.on_reload().is_ok());
assert!(plugin.cleanup().is_ok());
}
#[test]
fn on_request_complete_receives_event() {
let source = r#"
function onRequestComplete(event) {
bext.storage.set("status", String(event.status));
bext.storage.set("path", event.path);
}
"#;
let (dir, plugin) = load_lifecycle(
"reqlog",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let event = serde_json::json!({
"path": "/api/products",
"method": "GET",
"status": 200,
"render_time_us": 1500
});
let result = plugin.on_request_complete(&event.to_string());
assert!(result.is_ok(), "on_request_complete failed: {:?}", result);
let storage_dir = dir.path().join("storage").join("reqlog");
assert_eq!(
std::fs::read_to_string(storage_dir.join("status")).unwrap(),
"200"
);
assert_eq!(
std::fs::read_to_string(storage_dir.join("path")).unwrap(),
"/api/products"
);
}
#[test]
fn plugin_manifest_name() {
let source = "var x = 1;";
let (_dir, plugin) = load_lifecycle(
"my-plugin",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
assert_eq!(plugin.name(), "my-plugin");
assert_eq!(plugin.priority(), 500);
}
#[test]
fn memory_limit_enforcement() {
let source = r#"
function onServerStart(config) {
// Allocate big arrays in a loop to bust the heap limit
var arrays = [];
for (var i = 0; i < 100000; i++) {
arrays.push(new Array(10000));
}
}
"#;
let (dir, path) = write_temp_plugin("memhog", source);
let storage_root = dir.path().join("storage");
let _ = std::fs::create_dir_all(&storage_root);
let mut rt = QuickJsPluginRuntime::new(storage_root);
let perms = SandboxPermissions {
max_memory_mb: 2, ..Default::default()
};
rt.load_plugin(QuickJsPluginConfig {
name: "memhog".into(),
path,
priority: 1000,
permissions: perms,
config: serde_json::Value::Null,
})
.expect("load plugin");
let mut plugins = rt.into_lifecycle_plugins();
let plugin = plugins.remove(0);
let result = plugin.on_server_start("{}");
assert!(result.is_err(), "expected memory limit error");
}
#[test]
fn timeout_enforcement() {
let source = r#"
function onServerStart(config) {
while (true) {} // infinite loop
}
"#;
let (dir, path) = write_temp_plugin("looper", source);
let storage_root = dir.path().join("storage");
let _ = std::fs::create_dir_all(&storage_root);
let mut rt = QuickJsPluginRuntime::new(storage_root);
let perms = SandboxPermissions {
max_time_secs: 1, ..Default::default()
};
rt.load_plugin(QuickJsPluginConfig {
name: "looper".into(),
path,
priority: 1000,
permissions: perms,
config: serde_json::Value::Null,
})
.expect("load plugin");
let mut plugins = rt.into_lifecycle_plugins();
let plugin = plugins.remove(0);
let start = Instant::now();
let result = plugin.on_server_start("{}");
let elapsed = start.elapsed();
assert!(result.is_err(), "expected timeout error");
assert!(
elapsed < std::time::Duration::from_secs(5),
"timeout took too long: {:?}",
elapsed
);
}
#[test]
fn console_log_no_panic() {
let source = r#"
function onServerStart(config) {
console.log("hello from plugin");
console.warn("warning msg");
console.error("error msg");
console.info("info msg");
console.debug("debug msg");
}
"#;
let (_dir, plugin) = load_lifecycle(
"logger",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_server_start("{}");
assert!(result.is_ok(), "console logging failed: {:?}", result);
}
#[test]
fn storage_roundtrip() {
let source = r#"
function onServerStart(config) {
// Set a value
var ok = bext.storage.set("counter", "42");
if (!ok) throw new Error("storage.set failed");
// Get it back
var val = bext.storage.get("counter");
if (val !== "42") throw new Error("expected '42', got: " + val);
// Delete it
bext.storage.delete("counter");
// Should be null/undefined now (loose equality covers both)
var deleted = bext.storage.get("counter");
if (deleted != null) throw new Error("expected null/undefined after delete, got: " + deleted);
// Record success
bext.storage.set("roundtrip", "passed");
}
"#;
let (dir, plugin) = load_lifecycle(
"storagetest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_server_start("{}");
assert!(result.is_ok(), "storage roundtrip failed: {:?}", result);
let storage_dir = dir.path().join("storage").join("storagetest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("roundtrip")).unwrap(),
"passed"
);
}
#[test]
fn config_accessible() {
let source = r#"
function onServerStart(config) {
// bext.config is injected at load time
bext.storage.set("markup", String(bext.config.markup_pct));
bext.storage.set("discount", String(bext.config.vip_discount));
}
"#;
let config = serde_json::json!({
"markup_pct": 15,
"vip_discount": 0.1
});
let (dir, plugin) =
load_lifecycle("configtest", source, SandboxPermissions::default(), config);
let result = plugin.on_server_start("{}");
assert!(result.is_ok(), "config access failed: {:?}", result);
let storage_dir = dir.path().join("storage").join("configtest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("markup")).unwrap(),
"15"
);
assert_eq!(
std::fs::read_to_string(storage_dir.join("discount")).unwrap(),
"0.1"
);
}
#[test]
fn on_cache_write_args() {
let source = r#"
function onCacheWrite(key, tags) {
bext.storage.set("cache-key", key);
bext.storage.set("cache-tags", JSON.stringify(tags));
}
"#;
let (dir, plugin) = load_lifecycle(
"cachetest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_cache_write("/products/1", r#"["product","page"]"#);
assert!(result.is_ok(), "on_cache_write failed: {:?}", result);
let storage_dir = dir.path().join("storage").join("cachetest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("cache-key")).unwrap(),
"/products/1"
);
}
#[test]
fn on_cache_invalidate_args() {
let source = r#"
function onCacheInvalidate(pattern, count) {
bext.storage.set("inv-pattern", pattern);
bext.storage.set("inv-count", String(count));
}
"#;
let (dir, plugin) = load_lifecycle(
"invtest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_cache_invalidate("/products/*", 7);
assert!(result.is_ok(), "on_cache_invalidate failed: {:?}", result);
let storage_dir = dir.path().join("storage").join("invtest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("inv-pattern")).unwrap(),
"/products/*"
);
assert_eq!(
std::fs::read_to_string(storage_dir.join("inv-count")).unwrap(),
"7"
);
}
#[test]
fn on_reload_and_cleanup() {
let source = r#"
function onReload() {
bext.storage.set("reloaded", "true");
}
function cleanup() {
bext.storage.set("cleaned", "true");
}
"#;
let (dir, plugin) = load_lifecycle(
"lifecycletest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
assert!(plugin.on_reload().is_ok());
assert!(plugin.cleanup().is_ok());
let storage_dir = dir.path().join("storage").join("lifecycletest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("reloaded")).unwrap(),
"true"
);
assert_eq!(
std::fs::read_to_string(storage_dir.join("cleaned")).unwrap(),
"true"
);
}
#[test]
fn multiple_plugins() {
let dir = tempfile::tempdir().expect("create tempdir");
let storage_root = dir.path().join("storage");
let _ = std::fs::create_dir_all(&storage_root);
let path1 = dir.path().join("plugin1.js");
std::fs::write(
&path1,
r#"function onServerStart(c) { bext.storage.set("who", "plugin1"); }"#,
)
.unwrap();
let path2 = dir.path().join("plugin2.js");
std::fs::write(
&path2,
r#"function onServerStart(c) { bext.storage.set("who", "plugin2"); }"#,
)
.unwrap();
let mut rt = QuickJsPluginRuntime::new(storage_root.clone());
rt.load_plugin(QuickJsPluginConfig {
name: "p1".into(),
path: path1,
priority: 100,
permissions: SandboxPermissions::default(),
config: serde_json::Value::Null,
})
.unwrap();
rt.load_plugin(QuickJsPluginConfig {
name: "p2".into(),
path: path2,
priority: 200,
permissions: SandboxPermissions::default(),
config: serde_json::Value::Null,
})
.unwrap();
assert_eq!(rt.len(), 2);
assert!(!rt.is_empty());
let plugins = rt.into_lifecycle_plugins();
assert_eq!(plugins.len(), 2);
for p in &plugins {
assert!(p.on_server_start("{}").is_ok());
}
assert_eq!(
std::fs::read_to_string(storage_root.join("p1").join("who")).unwrap(),
"plugin1"
);
assert_eq!(
std::fs::read_to_string(storage_root.join("p2").join("who")).unwrap(),
"plugin2"
);
}
#[test]
fn syntax_error_on_load() {
let source = r#"
function onServerStart( {{{ INVALID SYNTAX
"#;
let (dir, path) = write_temp_plugin("badsyntax", source);
let storage_root = dir.path().join("storage");
let _ = std::fs::create_dir_all(&storage_root);
let mut rt = QuickJsPluginRuntime::new(storage_root);
let result = rt.load_plugin(QuickJsPluginConfig {
name: "badsyntax".into(),
path,
priority: 1000,
permissions: SandboxPermissions::default(),
config: serde_json::Value::Null,
});
assert!(result.is_err(), "expected syntax error");
}
#[test]
fn metric_emission() {
let source = r#"
function onRequestComplete(event) {
bext.metric("request_count", 1.0, '{"method":"GET"}');
bext.metric("latency_us", 1500.0); // omitted tags — JS wrapper defaults to "{}"
}
"#;
let (_dir, plugin) = load_lifecycle(
"metrictest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
let result = plugin.on_request_complete(r#"{"status":200}"#);
assert!(result.is_ok(), "metric emission failed: {:?}", result);
}
#[test]
fn on_server_stop() {
let source = r#"
function onServerStop() {
bext.storage.set("stopped", "yes");
}
"#;
let (dir, plugin) = load_lifecycle(
"stoptest",
source,
SandboxPermissions::default(),
serde_json::json!({}),
);
assert!(plugin.on_server_stop().is_ok());
let storage_dir = dir.path().join("storage").join("stoptest");
assert_eq!(
std::fs::read_to_string(storage_dir.join("stopped")).unwrap(),
"yes"
);
}
}