openzeppelin_relayer/services/plugins/
script_executor.rs1use 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 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) .arg(socket_path) .arg(script_params) .arg(script_path) .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}