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(Box::new(
107 super::PluginHandlerPayload {
108 message,
109 status,
110 code,
111 details,
112 logs: Some(logs),
113 traces: None,
114 },
115 )));
116 }
117 }
118 return Err(PluginError::HandlerError(Box::new(
120 super::PluginHandlerPayload {
121 message: stderr.to_string(),
122 status: 500,
123 code: None,
124 details: None,
125 logs: Some(logs),
126 traces: None,
127 },
128 )));
129 }
130
131 Ok(ScriptResult {
132 logs,
133 return_value,
134 error: stderr.to_string(),
135 trace: Vec::new(),
136 })
137 }
138
139 fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
140 let mut result = Vec::new();
141 let mut return_value = String::new();
142
143 for log in logs {
144 let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
145 PluginError::PluginExecutionError(format!("Failed to parse log: {e}"))
146 })?;
147
148 if log.level == LogLevel::Result {
149 return_value = log.message;
150 } else {
151 result.push(log);
152 }
153 }
154
155 Ok((result, return_value))
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use std::fs;
162
163 use tempfile::tempdir;
164
165 use super::*;
166
167 static TS_CONFIG: &str = r#"
168 {
169 "compilerOptions": {
170 "target": "es2016",
171 "module": "commonjs",
172 "esModuleInterop": true,
173 "forceConsistentCasingInFileNames": true,
174 "strict": true,
175 "skipLibCheck": true
176 }
177 }
178"#;
179
180 #[tokio::test]
181 async fn test_execute_typescript() {
182 let temp_dir = tempdir().unwrap();
183 let ts_config = temp_dir.path().join("tsconfig.json");
184 let script_path = temp_dir.path().join("test_execute_typescript.ts");
185 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
186
187 let content = r#"
188 export async function handler(api: any, params: any) {
189 console.log('test');
190 console.info('test-info');
191 return 'test-result';
192 }
193 "#;
194 fs::write(script_path.clone(), content).unwrap();
195 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
196
197 let result = ScriptExecutor::execute_typescript(
198 "test-plugin-1".to_string(),
199 script_path.display().to_string(),
200 socket_path.display().to_string(),
201 "{}".to_string(),
202 None,
203 )
204 .await;
205
206 assert!(result.is_ok());
207 let result = result.unwrap();
208 assert_eq!(result.logs[0].level, LogLevel::Log);
209 assert_eq!(result.logs[0].message, "test");
210 assert_eq!(result.logs[1].level, LogLevel::Info);
211 assert_eq!(result.logs[1].message, "test-info");
212 assert_eq!(result.return_value, "test-result");
213 }
214
215 #[tokio::test]
216 async fn test_execute_typescript_with_result() {
217 let temp_dir = tempdir().unwrap();
218 let ts_config = temp_dir.path().join("tsconfig.json");
219 let script_path = temp_dir
220 .path()
221 .join("test_execute_typescript_with_result.ts");
222 let socket_path = temp_dir
223 .path()
224 .join("test_execute_typescript_with_result.sock");
225
226 let content = r#"
227 export async function handler(api: any, params: any) {
228 console.log('test');
229 console.info('test-info');
230 return {
231 test: 'test-result',
232 test2: 'test-result2'
233 };
234 }
235 "#;
236 fs::write(script_path.clone(), content).unwrap();
237 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
238
239 let result = ScriptExecutor::execute_typescript(
240 "test-plugin-1".to_string(),
241 script_path.display().to_string(),
242 socket_path.display().to_string(),
243 "{}".to_string(),
244 None,
245 )
246 .await;
247
248 assert!(result.is_ok());
249 let result = result.unwrap();
250 assert_eq!(result.logs[0].level, LogLevel::Log);
251 assert_eq!(result.logs[0].message, "test");
252 assert_eq!(result.logs[1].level, LogLevel::Info);
253 assert_eq!(result.logs[1].message, "test-info");
254 assert_eq!(
255 result.return_value,
256 "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
257 );
258 }
259
260 #[tokio::test]
261 async fn test_execute_typescript_error() {
262 let temp_dir = tempdir().unwrap();
263 let ts_config = temp_dir.path().join("tsconfig.json");
264 let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
265 let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
266
267 let content = "console.logger('test');";
268 fs::write(script_path.clone(), content).unwrap();
269 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
270
271 let result = ScriptExecutor::execute_typescript(
272 "test-plugin-1".to_string(),
273 script_path.display().to_string(),
274 socket_path.display().to_string(),
275 "{}".to_string(),
276 None,
277 )
278 .await;
279
280 assert!(result.is_err());
282
283 if let Err(PluginError::HandlerError(ctx)) = result {
284 assert_eq!(ctx.status, 500);
287 assert!(!ctx.message.is_empty());
289 } else {
290 panic!("Expected PluginError::HandlerError, got: {:?}", result);
291 }
292 }
293
294 #[tokio::test]
295 async fn test_execute_typescript_handler_json_error() {
296 let temp_dir = tempdir().unwrap();
297 let ts_config = temp_dir.path().join("tsconfig.json");
298 let script_path = temp_dir
299 .path()
300 .join("test_execute_typescript_handler_json_error.ts");
301 let socket_path = temp_dir
302 .path()
303 .join("test_execute_typescript_handler_json_error.sock");
304
305 let content = r#"
308 export async function handler(_api: any, _params: any) {
309 const err: any = new Error('Validation failed');
310 err.code = 'VALIDATION_FAILED';
311 err.status = 422;
312 err.details = { field: 'email' };
313 throw err;
314 }
315 "#;
316 fs::write(&script_path, content).unwrap();
317 fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
318
319 let result = ScriptExecutor::execute_typescript(
320 "test-plugin-json-error".to_string(),
321 script_path.display().to_string(),
322 socket_path.display().to_string(),
323 "{}".to_string(),
324 None,
325 )
326 .await;
327
328 match result {
329 Err(PluginError::HandlerError(ctx)) => {
330 assert_eq!(ctx.message, "Validation failed");
331 assert_eq!(ctx.status, 422);
332 assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
333 let d = ctx.details.expect("details should be present");
334 assert_eq!(d["field"].as_str(), Some("email"));
335 }
336 other => panic!("Expected HandlerError, got: {:?}", other),
337 }
338 }
339 #[tokio::test]
340 async fn test_parse_logs_error() {
341 let temp_dir = tempdir().unwrap();
342 let ts_config = temp_dir.path().join("tsconfig.json");
343 let script_path = temp_dir.path().join("test_execute_typescript.ts");
344 let socket_path = temp_dir.path().join("test_execute_typescript.sock");
345
346 let invalid_content = r#"
347 export async function handler(api: any, params: any) {
348 // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
349 process.stdout.write('invalid json line\n');
350 process.stdout.write('{"level":"log","message":"valid"}\n');
351 process.stdout.write('another invalid line\n');
352 return 'test';
353 }
354 "#;
355 fs::write(script_path.clone(), invalid_content).unwrap();
356 fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
357
358 let result = ScriptExecutor::execute_typescript(
359 "test-plugin-1".to_string(),
360 script_path.display().to_string(),
361 socket_path.display().to_string(),
362 "{}".to_string(),
363 None,
364 )
365 .await;
366
367 assert!(result.is_err());
368 assert!(result
369 .err()
370 .unwrap()
371 .to_string()
372 .contains("Failed to parse log"));
373 }
374}