Skip to main content

featherbit/plugins/script/
mod.rs

1//! Scripted plugin host (`script`).
2//!
3//! Runs user-provided scripts as graph nodes behind the same `Plugin` trait
4//! as native plugins. Scripts are parsed and validated once at policy-compile
5//! time (in `from_config`), not per request; script failures surface as
6//! `PluginExecutionError` exactly like native failures, routing through the
7//! node's error port.
8
9pub mod lua_runtime;
10
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15use crate::context::Context;
16use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
17
18/// Executes a scripted plugin as a graph node.
19///
20/// The script receives the full `Context` (request, response, message) and
21/// returns a possibly modified copy; anything it writes into `ctx.message`
22/// is visible to downstream nodes. Currently only the Lua (Luau) runtime is
23/// supported.
24pub struct ScriptPlugin {
25    runtime: ScriptRuntime,
26}
27
28/// Dispatch over the available scripting runtimes (only Lua today; Python is
29/// planned but not implemented).
30enum ScriptRuntime {
31    Lua(lua_runtime::LuaRuntime),
32}
33
34impl ScriptPlugin {
35    /// Builds the plugin from node config, loading and validating the script
36    /// immediately so bad scripts fail at policy-compile time.
37    ///
38    /// Accepted keys:
39    /// - `runtime` (string, default `"lua"`): scripting runtime; any other
40    ///   value is an error.
41    /// - `source` (string): path to a script file, read at compile time.
42    /// - `inline` (string): script text embedded in the config. One of
43    ///   `source` or `inline` is required (`source` wins if both are set);
44    ///   omitting both is an error, as is an unreadable `source` file.
45    /// - `timeout_ms` (integer, default `5000`): script execution timeout;
46    ///   currently stored by the Lua runtime but not yet enforced.
47    /// - `modules_path` (string, default: the `source` script's parent
48    ///   directory; none for `inline`): directory the sandboxed `require`
49    ///   resolves modules from.
50    ///
51    /// ```yaml
52    /// type: script
53    /// config:
54    ///   runtime: lua
55    ///   source: scripts/enrich.lua
56    ///   timeout_ms: 2000
57    /// ```
58    ///
59    /// The script must define a global `execute(ctx)` function that returns
60    /// the (possibly modified) context table:
61    ///
62    /// ```lua
63    /// function execute(ctx)
64    ///     ctx.request.headers["x-enriched"] = {"true"}
65    ///     ctx.message.user_tier = "gold"
66    ///     return ctx
67    /// end
68    /// ```
69    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
70        let runtime_name = config
71            .get("runtime")
72            .and_then(|v| v.as_str())
73            .unwrap_or("lua");
74
75        let source_path = config
76            .get("source")
77            .and_then(|v| v.as_str())
78            .map(String::from);
79
80        let inline_source = config
81            .get("inline")
82            .and_then(|v| v.as_str())
83            .map(String::from);
84
85        if source_path.is_none() && inline_source.is_none() {
86            return Err("script plugin requires 'source' or 'inline'".to_string());
87        }
88
89        let source = if let Some(ref path) = source_path {
90            std::fs::read_to_string(path)
91                .map_err(|e| format!("Failed to read script '{}': {}", path, e))?
92        } else {
93            inline_source.unwrap()
94        };
95
96        let timeout_ms = config
97            .get("timeout_ms")
98            .and_then(|v| v.as_u64())
99            .unwrap_or(5000);
100
101        // modules_path: explicit config, or derive from the script's parent directory
102        let modules_path = config
103            .get("modules_path")
104            .and_then(|v| v.as_str())
105            .map(PathBuf::from)
106            .or_else(|| {
107                source_path
108                    .as_ref()
109                    .and_then(|p| PathBuf::from(p).parent().map(|p| p.to_path_buf()))
110            });
111
112        let runtime = match runtime_name {
113            "lua" => {
114                let rt = lua_runtime::LuaRuntime::new(&source, timeout_ms, modules_path)?;
115                ScriptRuntime::Lua(rt)
116            }
117            other => {
118                return Err(format!("Unknown runtime: '{}' — supported: lua", other));
119            }
120        };
121
122        Ok(Self { runtime })
123    }
124}
125
126#[async_trait]
127impl Plugin for ScriptPlugin {
128    fn plugin_type(&self) -> &str {
129        "script"
130    }
131
132    async fn execute(
133        &self,
134        ctx: Context,
135        _named_inputs: &HashMap<String, serde_json::Value>,
136    ) -> PluginResult {
137        match &self.runtime {
138            ScriptRuntime::Lua(rt) => match rt.execute(ctx) {
139                Ok(new_ctx) => Ok(PluginOutput {
140                    context: new_ctx,
141                    named_outputs: HashMap::new(),
142                }),
143                Err(e) => Err(PluginExecutionError {
144                    context: e.context,
145                    error: e.error,
146                }),
147            },
148        }
149    }
150}