openzeppelin_relayer/services/plugins/
script_executor.rs

1//! This module is responsible for executing a typescript script.
2//!
3//! 1. Checks if `ts-node` is installed.
4//! 2. Executes the script using the `ts-node` command.
5//! 3. Returns the output and errors of the script.
6use serde::{Deserialize, Serialize};
7use std::process::Stdio;
8use tokio::process::Command;
9use utoipa::ToSchema;
10
11use super::PluginError;
12
13#[derive(Serialize, Deserialize, Debug, PartialEq, ToSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16    Log,
17    Info,
18    Error,
19    Warn,
20    Debug,
21    Result,
22}
23
24#[derive(Serialize, Deserialize, Debug, ToSchema)]
25pub struct LogEntry {
26    pub level: LogLevel,
27    pub message: String,
28}
29
30#[derive(Serialize, Deserialize, Debug, ToSchema)]
31pub struct ScriptResult {
32    pub logs: Vec<LogEntry>,
33    pub error: String,
34    pub trace: Vec<serde_json::Value>,
35    pub return_value: String,
36}
37
38pub struct ScriptExecutor;
39
40impl ScriptExecutor {
41    pub async fn execute_typescript(
42        script_path: String,
43        socket_path: String,
44        script_params: String,
45    ) -> Result<ScriptResult, PluginError> {
46        if Command::new("ts-node")
47            .arg("--version")
48            .output()
49            .await
50            .is_err()
51        {
52            return Err(PluginError::SocketError(
53                "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
54            ));
55        }
56
57        // Use the centralized executor script instead of executing user script directly
58        // Use absolute path to avoid working directory issues in CI
59        let executor_path = std::env::current_dir()
60            .map(|cwd| cwd.join("plugins/lib/executor.ts").display().to_string())
61            .unwrap_or_else(|_| "plugins/lib/executor.ts".to_string());
62
63        let output = Command::new("ts-node")
64            .arg(executor_path)       // Execute executor script
65            .arg(socket_path)         // Socket path (argv[2])
66            .arg(script_params)       // Plugin parameters (argv[3])
67            .arg(script_path)         // User script path (argv[4])
68            .stdin(Stdio::null())
69            .stdout(Stdio::piped())
70            .stderr(Stdio::piped())
71            .output()
72            .await
73            .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {}", e)))?;
74
75        let stdout = String::from_utf8_lossy(&output.stdout);
76        let stderr = String::from_utf8_lossy(&output.stderr);
77
78        let (logs, return_value) =
79            Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
80
81        Ok(ScriptResult {
82            logs,
83            return_value,
84            error: stderr.to_string(),
85            trace: Vec::new(),
86        })
87    }
88
89    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
90        let mut result = Vec::new();
91        let mut return_value = String::new();
92
93        for log in logs {
94            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
95                PluginError::PluginExecutionError(format!("Failed to parse log: {}", e))
96            })?;
97
98            if log.level == LogLevel::Result {
99                return_value = log.message;
100            } else {
101                result.push(log);
102            }
103        }
104
105        Ok((result, return_value))
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use std::fs;
112
113    use tempfile::tempdir;
114
115    use super::*;
116
117    static TS_CONFIG: &str = r#"
118    {
119        "compilerOptions": {
120          "target": "es2016",
121          "module": "commonjs",
122          "esModuleInterop": true,
123          "forceConsistentCasingInFileNames": true,
124          "strict": true,
125          "skipLibCheck": true
126        }
127      }
128"#;
129
130    #[tokio::test]
131    async fn test_execute_typescript() {
132        let temp_dir = tempdir().unwrap();
133        let ts_config = temp_dir.path().join("tsconfig.json");
134        let script_path = temp_dir.path().join("test_execute_typescript.ts");
135        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
136
137        let content = r#"
138            export async function handler(api: any, params: any) {
139                console.log('test');
140                console.info('test-info');
141                return 'test-result';
142            }
143        "#;
144        fs::write(script_path.clone(), content).unwrap();
145        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
146
147        let result = ScriptExecutor::execute_typescript(
148            script_path.display().to_string(),
149            socket_path.display().to_string(),
150            "{}".to_string(),
151        )
152        .await;
153
154        assert!(result.is_ok());
155        let result = result.unwrap();
156        assert_eq!(result.logs[0].level, LogLevel::Log);
157        assert_eq!(result.logs[0].message, "test");
158        assert_eq!(result.logs[1].level, LogLevel::Info);
159        assert_eq!(result.logs[1].message, "test-info");
160        assert_eq!(result.return_value, "test-result");
161    }
162
163    #[tokio::test]
164    async fn test_execute_typescript_with_result() {
165        let temp_dir = tempdir().unwrap();
166        let ts_config = temp_dir.path().join("tsconfig.json");
167        let script_path = temp_dir
168            .path()
169            .join("test_execute_typescript_with_result.ts");
170        let socket_path = temp_dir
171            .path()
172            .join("test_execute_typescript_with_result.sock");
173
174        let content = r#"
175            export async function handler(api: any, params: any) {
176                console.log('test');
177                console.info('test-info');
178                return {
179                    test: 'test-result',
180                    test2: 'test-result2'
181                };
182            }
183        "#;
184        fs::write(script_path.clone(), content).unwrap();
185        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
186
187        let result = ScriptExecutor::execute_typescript(
188            script_path.display().to_string(),
189            socket_path.display().to_string(),
190            "{}".to_string(),
191        )
192        .await;
193
194        assert!(result.is_ok());
195        let result = result.unwrap();
196        assert_eq!(result.logs[0].level, LogLevel::Log);
197        assert_eq!(result.logs[0].message, "test");
198        assert_eq!(result.logs[1].level, LogLevel::Info);
199        assert_eq!(result.logs[1].message, "test-info");
200        assert_eq!(
201            result.return_value,
202            "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
203        );
204    }
205
206    #[tokio::test]
207    async fn test_execute_typescript_error() {
208        let temp_dir = tempdir().unwrap();
209        let ts_config = temp_dir.path().join("tsconfig.json");
210        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
211        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
212
213        let content = "console.logger('test');";
214        fs::write(script_path.clone(), content).unwrap();
215        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
216
217        let result = ScriptExecutor::execute_typescript(
218            script_path.display().to_string(),
219            socket_path.display().to_string(),
220            "{}".to_string(),
221        )
222        .await;
223
224        assert!(result.is_ok());
225
226        let result = result.unwrap();
227        assert_eq!(result.error, "");
228
229        assert!(!result.logs.is_empty());
230        assert_eq!(result.logs[0].level, LogLevel::Error);
231        assert!(result.logs[0].message.contains("logger"));
232    }
233
234    #[tokio::test]
235    async fn test_parse_logs_error() {
236        let temp_dir = tempdir().unwrap();
237        let ts_config = temp_dir.path().join("tsconfig.json");
238        let script_path = temp_dir.path().join("test_execute_typescript.ts");
239        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
240
241        let invalid_content = r#"
242            export async function handler(api: any, params: any) {
243                // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
244                process.stdout.write('invalid json line\n');
245                process.stdout.write('{"level":"log","message":"valid"}\n');
246                process.stdout.write('another invalid line\n');
247                return 'test';
248            }
249        "#;
250        fs::write(script_path.clone(), invalid_content).unwrap();
251        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
252
253        let result = ScriptExecutor::execute_typescript(
254            script_path.display().to_string(),
255            socket_path.display().to_string(),
256            "{}".to_string(),
257        )
258        .await;
259
260        assert!(result.is_err());
261        assert!(result
262            .err()
263            .unwrap()
264            .to_string()
265            .contains("Failed to parse log"));
266    }
267}