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, Clone, 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, PartialEq, Clone, 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 plugin_id: String,
43 script_path: String,
44 socket_path: String,
45 script_params: String,
46 http_request_id: Option<String>,
47 ) -> Result<ScriptResult, PluginError> {
48 if Command::new("ts-node")
49 .arg("--version")
50 .output()
51 .await
52 .is_err()
53 {
54 return Err(PluginError::SocketError(
55 "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
56 ));
57 }
58
59 let executor_path = std::env::current_dir()
62 .map(|cwd| cwd.join("plugins/lib/executor.ts").display().to_string())
63 .unwrap_or_else(|_| "plugins/lib/executor.ts".to_string());
64
65 let output = Command::new("ts-node")
66 .arg(executor_path) .arg(socket_path) .arg(plugin_id) .arg(script_params) .arg(script_path) .arg(http_request_id.unwrap_or_default()) .stdin(Stdio::null())
73 .stdout(Stdio::piped())
74 .stderr(Stdio::piped())
75 .output()
76 .await
77 .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {}", e)))?;
78
79 let stdout = String::from_utf8_lossy(&output.stdout);
80 let stderr = String::from_utf8_lossy(&output.stderr);
81
82 let (logs, return_value) =
83 Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
84
85 if !output.status.success() {
87 if let Some(error_line) = stderr.lines().find(|l| !l.trim().is_empty()) {
89 if let Ok(error_info) = serde_json::from_str::<serde_json::Value>(error_line) {
90 let message = error_info["message"]
91 .as_str()
92 .unwrap_or(&stderr)
93 .to_string();
94 let status = error_info
95 .get("status")
96 .and_then(|v| v.as_u64())
97 .unwrap_or(500) as u16;
98 let code = error_info
99 .get("code")
100 .and_then(|v| v.as_str())
101 .map(|s| s.to_string());
102 let details = error_info
103 .get("details")
104 .cloned()
105 .or_else(|| error_info.get("data").cloned());
106 return Err(PluginError::HandlerError(super::PluginHandlerPayload {
107 message,
108 status,
109 code,
110 details,
111 logs: Some(logs),
112 traces: None,
113 }));
114 }
115 }
116 return Err(PluginError::HandlerError(super::PluginHandlerPayload {
118 message: stderr.to_string(),
119 status: 500,
120 code: None,
121 details: None,
122 logs: Some(logs),
123 traces: None,
124 }));
125 }
126
127 Ok(ScriptResult {
128 logs,
129 return_value,
130 error: stderr.to_string(),
131 trace: Vec::new(),
132 })
133 }
134
135 fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
136 let mut result = Vec::new();
137 let mut return_value = String::new();
138
139 for log in logs {
140 let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
141 PluginError::PluginExecutionError(format!("Failed to parse log: {}", e))
142 })?;
143
144 if log.level == LogLevel::Result {
145 return_value = log.message;
146 } else {
147 result.push(log);
148 }
149 }
150
151 Ok((result, return_value))
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use std::fs;
158
159 use tempfile::tempdir;
160
161 use super::*;
162
163 static TS_CONFIG: &str = r#"
164 {
165 "compilerOptions": {
166 "target": "es2016",
167 "module": "commonjs",
168 "esModuleInterop": true,
169 "forceConsistentCasingInFileNames": true,
170 "strict": true,
171 "skipLibCheck": true
172 }
173 }
174"#;
175
176 #[tokio::test]
177 async fn test_execute_typescript() {
178 let temp_dir = tempdir().unwrap();
179 let ts_config = temp_dir.path().join("tsconfig.json");
180 let script_path = temp_dir.path().join("test_execute_typescript.ts");
181 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
182
183 let content = r#"
184 export async function handler(api: any, params: any) {
185 console.log('test');
186 console.info('test-info');
187 return 'test-result';
188 }
189 "#;
190 fs::write(script_path.clone(), content).unwrap();
191 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
192
193 let result = ScriptExecutor::execute_typescript(
194 "test-plugin-1".to_string(),
195 script_path.display().to_string(),
196 socket_path.display().to_string(),
197 "{}".to_string(),
198 None,
199 )
200 .await;
201
202 assert!(result.is_ok());
203 let result = result.unwrap();
204 assert_eq!(result.logs[0].level, LogLevel::Log);
205 assert_eq!(result.logs[0].message, "test");
206 assert_eq!(result.logs[1].level, LogLevel::Info);
207 assert_eq!(result.logs[1].message, "test-info");
208 assert_eq!(result.return_value, "test-result");
209 }
210
211 #[tokio::test]
212 async fn test_execute_typescript_with_result() {
213 let temp_dir = tempdir().unwrap();
214 let ts_config = temp_dir.path().join("tsconfig.json");
215 let script_path = temp_dir
216 .path()
217 .join("test_execute_typescript_with_result.ts");
218 let socket_path = temp_dir
219 .path()
220 .join("test_execute_typescript_with_result.sock");
221
222 let content = r#"
223 export async function handler(api: any, params: any) {
224 console.log('test');
225 console.info('test-info');
226 return {
227 test: 'test-result',
228 test2: 'test-result2'
229 };
230 }
231 "#;
232 fs::write(script_path.clone(), content).unwrap();
233 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
234
235 let result = ScriptExecutor::execute_typescript(
236 "test-plugin-1".to_string(),
237 script_path.display().to_string(),
238 socket_path.display().to_string(),
239 "{}".to_string(),
240 None,
241 )
242 .await;
243
244 assert!(result.is_ok());
245 let result = result.unwrap();
246 assert_eq!(result.logs[0].level, LogLevel::Log);
247 assert_eq!(result.logs[0].message, "test");
248 assert_eq!(result.logs[1].level, LogLevel::Info);
249 assert_eq!(result.logs[1].message, "test-info");
250 assert_eq!(
251 result.return_value,
252 "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
253 );
254 }
255
256 #[tokio::test]
257 async fn test_execute_typescript_error() {
258 let temp_dir = tempdir().unwrap();
259 let ts_config = temp_dir.path().join("tsconfig.json");
260 let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
261 let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
262
263 let content = "console.logger('test');";
264 fs::write(script_path.clone(), content).unwrap();
265 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
266
267 let result = ScriptExecutor::execute_typescript(
268 "test-plugin-1".to_string(),
269 script_path.display().to_string(),
270 socket_path.display().to_string(),
271 "{}".to_string(),
272 None,
273 )
274 .await;
275
276 assert!(result.is_err());
278
279 if let Err(PluginError::HandlerError(ctx)) = result {
280 assert_eq!(ctx.status, 500);
283 assert!(!ctx.message.is_empty());
285 } else {
286 panic!("Expected PluginError::HandlerError, got: {:?}", result);
287 }
288 }
289
290 #[tokio::test]
291 async fn test_execute_typescript_handler_json_error() {
292 let temp_dir = tempdir().unwrap();
293 let ts_config = temp_dir.path().join("tsconfig.json");
294 let script_path = temp_dir
295 .path()
296 .join("test_execute_typescript_handler_json_error.ts");
297 let socket_path = temp_dir
298 .path()
299 .join("test_execute_typescript_handler_json_error.sock");
300
301 let content = r#"
304 export async function handler(_api: any, _params: any) {
305 const err: any = new Error('Validation failed');
306 err.code = 'VALIDATION_FAILED';
307 err.status = 422;
308 err.details = { field: 'email' };
309 throw err;
310 }
311 "#;
312 fs::write(&script_path, content).unwrap();
313 fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
314
315 let result = ScriptExecutor::execute_typescript(
316 "test-plugin-json-error".to_string(),
317 script_path.display().to_string(),
318 socket_path.display().to_string(),
319 "{}".to_string(),
320 None,
321 )
322 .await;
323
324 match result {
325 Err(PluginError::HandlerError(ctx)) => {
326 assert_eq!(ctx.message, "Validation failed");
327 assert_eq!(ctx.status, 422);
328 assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
329 let d = ctx.details.expect("details should be present");
330 assert_eq!(d["field"].as_str(), Some("email"));
331 }
332 other => panic!("Expected HandlerError, got: {:?}", other),
333 }
334 }
335 #[tokio::test]
336 async fn test_parse_logs_error() {
337 let temp_dir = tempdir().unwrap();
338 let ts_config = temp_dir.path().join("tsconfig.json");
339 let script_path = temp_dir.path().join("test_execute_typescript.ts");
340 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
341
342 let invalid_content = r#"
343 export async function handler(api: any, params: any) {
344 // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
345 process.stdout.write('invalid json line\n');
346 process.stdout.write('{"level":"log","message":"valid"}\n');
347 process.stdout.write('another invalid line\n');
348 return 'test';
349 }
350 "#;
351 fs::write(script_path.clone(), invalid_content).unwrap();
352 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
353
354 let result = ScriptExecutor::execute_typescript(
355 "test-plugin-1".to_string(),
356 script_path.display().to_string(),
357 socket_path.display().to_string(),
358 "{}".to_string(),
359 None,
360 )
361 .await;
362
363 assert!(result.is_err());
364 assert!(result
365 .err()
366 .unwrap()
367 .to_string()
368 .contains("Failed to parse log"));
369 }
370}