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`): wall-clock budget for one
46 /// execution, covering both loading the source and the `execute(ctx)`
47 /// call. Enforced by a Luau VM interrupt, which fires at instruction
48 /// boundaries -- a runaway loop is stopped and the node fails with
49 /// `LUA_TIMEOUT`, but time inside a Rust callback or `require`'s file
50 /// IO is not interrupted. The same budget bounds the validation run at
51 /// policy-compile time. `0` disables enforcement.
52 /// - `modules_path` (string, default: the `source` script's parent
53 /// directory; none for `inline`): directory the sandboxed `require`
54 /// resolves modules from.
55 ///
56 /// ```yaml
57 /// type: script
58 /// config:
59 /// runtime: lua
60 /// source: scripts/enrich.lua
61 /// timeout_ms: 2000
62 /// ```
63 ///
64 /// The script must define a global `execute(ctx)` function that returns
65 /// the (possibly modified) context table:
66 ///
67 /// ```lua
68 /// function execute(ctx)
69 /// ctx.request.headers["x-enriched"] = {"true"}
70 /// ctx.message.user_tier = "gold"
71 /// return ctx
72 /// end
73 /// ```
74 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
75 let runtime_name = config
76 .get("runtime")
77 .and_then(|v| v.as_str())
78 .unwrap_or("lua");
79
80 let source_path = config
81 .get("source")
82 .and_then(|v| v.as_str())
83 .map(String::from);
84
85 let inline_source = config
86 .get("inline")
87 .and_then(|v| v.as_str())
88 .map(String::from);
89
90 if source_path.is_none() && inline_source.is_none() {
91 return Err("script plugin requires 'source' or 'inline'".to_string());
92 }
93
94 let source = if let Some(ref path) = source_path {
95 std::fs::read_to_string(path)
96 .map_err(|e| format!("Failed to read script '{}': {}", path, e))?
97 } else {
98 inline_source.unwrap()
99 };
100
101 let timeout_ms = config
102 .get("timeout_ms")
103 .and_then(|v| v.as_u64())
104 .unwrap_or(5000);
105
106 // modules_path: explicit config, or derive from the script's parent directory
107 let modules_path = config
108 .get("modules_path")
109 .and_then(|v| v.as_str())
110 .map(PathBuf::from)
111 .or_else(|| {
112 source_path
113 .as_ref()
114 .and_then(|p| PathBuf::from(p).parent().map(|p| p.to_path_buf()))
115 });
116
117 let runtime = match runtime_name {
118 "lua" => {
119 let rt = lua_runtime::LuaRuntime::new(&source, timeout_ms, modules_path)?;
120 ScriptRuntime::Lua(rt)
121 }
122 other => {
123 return Err(format!("Unknown runtime: '{}' — supported: lua", other));
124 }
125 };
126
127 Ok(Self { runtime })
128 }
129}
130
131#[async_trait]
132impl Plugin for ScriptPlugin {
133 fn plugin_type(&self) -> &str {
134 "script"
135 }
136
137 async fn execute(&self, ctx: Context) -> PluginResult {
138 match &self.runtime {
139 ScriptRuntime::Lua(rt) => match rt.execute(ctx) {
140 Ok((new_ctx, Some(port))) => Ok(PluginOutput::on_port(new_ctx, port)),
141 Ok((new_ctx, None)) => Ok(PluginOutput::success(new_ctx)),
142 Err(e) => Err(PluginExecutionError {
143 context: e.context,
144 error: e.error,
145 }),
146 },
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
155
156 fn plugin(inline: &str) -> ScriptPlugin {
157 let mut cfg = HashMap::new();
158 cfg.insert("runtime".to_string(), serde_json::json!("lua"));
159 cfg.insert("inline".to_string(), serde_json::json!(inline));
160 ScriptPlugin::from_config(&cfg).unwrap()
161 }
162
163 fn ctx() -> Context {
164 Context {
165 request: GatewayRequest {
166 method: "GET".to_string(),
167 path: "/".to_string(),
168 host: "localhost".to_string(),
169 scheme: "http".to_string(),
170 headers: HashMap::new(),
171 query_params: HashMap::new(),
172 body: bytes::Bytes::new(),
173 remote_addr: "127.0.0.1:1".to_string(),
174 protocol: Protocol::Http1,
175 },
176 response: GatewayResponse {
177 status_code: 0,
178 headers: HashMap::new(),
179 body: bytes::Bytes::new(),
180 stream: None,
181 },
182 message: HashMap::new(),
183 errors: Vec::new(),
184 }
185 }
186
187 /// The plugin is where the runtime's answer becomes a graph port.
188 #[tokio::test]
189 async fn test_respond_becomes_the_respond_port() {
190 let out = plugin("function execute(ctx) return ctx, \"respond\" end")
191 .execute(ctx())
192 .await
193 .unwrap();
194 assert_eq!(out.port, Some("respond"));
195 }
196
197 #[tokio::test]
198 async fn test_plain_return_is_success() {
199 let out = plugin("function execute(ctx) return ctx end")
200 .execute(ctx())
201 .await
202 .unwrap();
203 assert_eq!(out.port, None);
204 }
205}