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