ferridriver_script/bindings/
plugins.rs1use std::collections::BTreeMap;
20use std::future::Future;
21use std::sync::Arc;
22use std::time::Duration;
23
24use rquickjs::function::{Func, Opt};
25use rquickjs::promise::{MaybePromise, Promised};
26use rquickjs::{Ctx, IntoJs, JsLifetime, Module, Object, Value, class::Class, class::Trace};
27
28use super::bdd::{tool_dispatch, tool_names};
29use super::http_client::HttpClientJs;
30use crate::bindings::convert::{json_to_js, serde_from_js};
31use crate::command_spec::CommandSpec;
32use crate::engine::SessionProcsUd;
33use crate::error::ScriptError;
34use crate::session_procs::{self, SessionProcs};
35
36#[derive(Debug, Clone)]
40pub struct PluginBinding {
41 pub bytecode: Arc<[u8]>,
46}
47
48#[derive(JsLifetime, Trace)]
59#[rquickjs::class(rename = "PluginCommands")]
60pub struct PluginCommandsJs {
61 #[qjs(skip_trace)]
62 allowed: Arc<BTreeMap<String, CommandSpec>>,
63 #[qjs(skip_trace)]
64 procs: Option<Arc<SessionProcs>>,
65}
66
67impl PluginCommandsJs {
68 fn cmd_err(verb: &'static str, msg: impl std::fmt::Display) -> rquickjs::Error {
69 rquickjs::Error::new_from_js_message(verb, "Error", msg.to_string())
70 }
71
72 fn spec(&self, verb: &'static str, name: &str) -> rquickjs::Result<CommandSpec> {
73 self.allowed.get(name).cloned().ok_or_else(|| {
74 Self::cmd_err(
75 verb,
76 format!("\"{name}\" is not in the commands allow-list for this tool"),
77 )
78 })
79 }
80
81 fn vars_of<'js>(ctx: &Ctx<'js>, vars: Opt<Value<'js>>) -> rquickjs::Result<BTreeMap<String, serde_json::Value>> {
82 match vars.0 {
83 Some(v) if !v.is_undefined() && !v.is_null() => serde_from_js(ctx, v),
84 _ => Ok(BTreeMap::new()),
85 }
86 }
87
88 fn registry(&self, verb: &'static str) -> rquickjs::Result<&Arc<SessionProcs>> {
89 self
90 .procs
91 .as_ref()
92 .ok_or_else(|| Self::cmd_err(verb, "persistent commands are unavailable in this context"))
93 }
94}
95
96#[rquickjs::methods]
97impl PluginCommandsJs {
98 #[qjs(rename = "run")]
100 pub async fn run<'js>(&self, ctx: Ctx<'js>, name: String, vars: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
101 let spec = self.spec("commands.run", &name)?;
102 let vars_map = Self::vars_of(&ctx, vars)?;
103 let resolved = spec
104 .resolve(&vars_map)
105 .map_err(|m| Self::cmd_err("commands.run", format!("{name}: {m}")))?;
106 let value = Box::pin(session_procs::run_oneshot(&resolved))
107 .await
108 .map_err(|m| Self::cmd_err("commands.run", format!("{name}: {m}")))?;
109 json_to_js(&ctx, &value)
110 }
111
112 #[qjs(rename = "start")]
115 pub fn start<'js>(&self, ctx: Ctx<'js>, name: String, vars: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
116 let spec = self.spec("commands.start", &name)?;
117 let vars_map = Self::vars_of(&ctx, vars)?;
118 let resolved = spec
119 .resolve(&vars_map)
120 .map_err(|m| Self::cmd_err("commands.start", format!("{name}: {m}")))?;
121 let pid = self
122 .registry("commands.start")?
123 .start(&name, &resolved)
124 .map_err(|m| Self::cmd_err("commands.start", format!("{name}: {m}")))?;
125 json_to_js(&ctx, &serde_json::json!({ "name": name, "pid": pid }))
126 }
127
128 #[qjs(rename = "status")]
130 pub fn status<'js>(&self, ctx: Ctx<'js>, name: String) -> rquickjs::Result<Value<'js>> {
131 let value = self
132 .registry("commands.status")?
133 .status(&name)
134 .map_err(|m| Self::cmd_err("commands.status", m))?;
135 json_to_js(&ctx, &value)
136 }
137
138 #[qjs(rename = "stop")]
140 pub fn stop(&self, name: String) -> rquickjs::Result<()> {
141 self
142 .registry("commands.stop")?
143 .stop(&name)
144 .map_err(|m| Self::cmd_err("commands.stop", m))
145 }
146}
147
148fn rq(e: &ScriptError) -> rquickjs::Error {
149 rquickjs::Error::new_from_js_message("plugins", "Error", e.message.clone())
150}
151
152pub fn install_plugins(ctx: &Ctx<'_>, files: &[PluginBinding]) -> rquickjs::Result<()> {
157 for file in files {
158 #[allow(unsafe_code)]
163 let module = unsafe { Module::load(ctx.clone(), &file.bytecode) }?;
164 let (_evaluated, _promise) = module.eval()?;
168 }
169
170 let names = tool_names(ctx).map_err(|e| rq(&e))?;
171 let plugins_obj = Object::new(ctx.clone())?;
172 for (idx, name) in names.into_iter().enumerate() {
173 let f = Func::from(move |ctx, call_args| dispatch_tool(ctx, idx, call_args));
177 plugins_obj.set(name.as_str(), f)?;
178 }
179 ctx.globals().set("plugins", plugins_obj)?;
180 Ok(())
181}
182
183fn dispatch_tool<'js>(
194 ctx: Ctx<'js>,
195 idx: usize,
196 call_args: Opt<Value<'js>>,
197) -> Promised<impl std::future::Future<Output = rquickjs::Result<Value<'js>>> + 'js> {
198 Promised::from(async move {
199 let d = tool_dispatch(&ctx, idx).map_err(|e| rq(&e))?;
200
201 let arg = Object::new(ctx.clone())?;
202 let undef = Value::new_undefined(ctx.clone());
203 arg.set("args", call_args.0.unwrap_or_else(|| undef.clone()))?;
204
205 let g = ctx.globals();
206 arg.set("page", g.get::<_, Value<'js>>("page").unwrap_or_else(|_| undef.clone()))?;
207 arg.set(
208 "context",
209 g.get::<_, Value<'js>>("context").unwrap_or_else(|_| undef.clone()),
210 )?;
211
212 let net_policy: Option<Arc<[String]>> = if d.allowed_net.is_empty() {
216 None
217 } else {
218 Some(Arc::from(d.allowed_net.as_slice()))
219 };
220
221 let req_val: Value<'js> = g.get("request").unwrap_or_else(|_| undef.clone());
225 let request_out: Value<'js> = match net_policy.clone() {
226 Some(net) => match Class::<HttpClientJs>::from_value(&req_val) {
227 Ok(cls) => {
228 let inner = cls.borrow().inner_arc();
229 let guarded = Class::instance(ctx.clone(), HttpClientJs::with_net(inner, net))?;
230 guarded.into_js(&ctx)?
231 },
232 Err(_) => req_val,
233 },
234 None => req_val,
235 };
236 arg.set("request", request_out)?;
237
238 let procs = ctx.userdata::<SessionProcsUd>().map(|u| u.0.clone());
239 let commands = Class::instance(
240 ctx.clone(),
241 PluginCommandsJs {
242 allowed: Arc::new(d.allowed_commands),
243 procs,
244 },
245 )?;
246 arg.set("commands", commands)?;
247
248 let policy_cell = ctx
258 .userdata::<crate::bindings::fetch::NetPolicyUd>()
259 .map(|u| u.0.clone());
260
261 let handler = d.handler;
262 let timeout_ms = d.timeout_ms;
263 let inner = async move {
264 let mp: MaybePromise<'js> = handler.call((arg,))?;
265 let fut = mp.into_future::<Value<'js>>();
266 match timeout_ms {
267 Some(t) => match tokio::time::timeout(Duration::from_millis(t), fut).await {
268 Ok(r) => r,
269 Err(_) => Err(rquickjs::Error::new_from_js_message(
270 "plugins",
271 "Error",
272 format!("tool timed out after {t}ms"),
273 )),
274 },
275 None => fut.await,
276 }
277 };
278
279 match policy_cell {
280 None => inner.await,
281 Some(cell) => {
282 let mut inner = std::pin::pin!(inner);
283 std::future::poll_fn(move |cx2| {
284 let prev = cell.swap(net_policy.clone());
285 let r = inner.as_mut().poll(cx2);
286 cell.swap(prev);
287 r
288 })
289 .await
290 },
291 }
292 })
293}