Skip to main content

camel_language_js/engines/
boa.rs

1//! [`BoaEngine`] — JS engine backed by [Boa](https://boajs.dev).
2//!
3//! `boa_engine::Context` is `!Send`, so execution happens in a fresh context
4//! per `eval` call. This avoids cross-thread sharing issues and prevents state
5//! leaks between evaluations.
6
7use boa_engine::{Context, JsValue, Source, js_string};
8
9use crate::{
10    bindings,
11    engine::{JsEngine, JsEvalResult, JsExchange},
12    error::JsLanguageError,
13    value::js_to_value,
14};
15
16/// A [`JsEngine`] implementation backed by Boa.
17///
18/// Each call to [`eval`](BoaEngine::eval) creates a fresh `Context`.
19/// This is intentional: it prevents state leaks between independent expressions.
20#[derive(Debug, Clone, Default)]
21pub struct BoaEngine;
22
23impl BoaEngine {
24    #[must_use]
25    pub const fn new() -> Self {
26        Self
27    }
28}
29
30impl JsEngine for BoaEngine {
31    fn eval(&self, source: &str, exchange: JsExchange) -> Result<JsEvalResult, JsLanguageError> {
32        let mut ctx = Context::default();
33
34        // Set up console -> tracing
35        bindings::register_console(&mut ctx);
36
37        // Set up `camel` global
38        let camel_obj = bindings::build_camel_global(&exchange, &mut ctx).map_err(|e| {
39            JsLanguageError::Execution {
40                message: e.to_string(),
41            }
42        })?;
43
44        ctx.global_object()
45            .set(
46                js_string!("camel"),
47                JsValue::from(camel_obj),
48                false,
49                &mut ctx,
50            )
51            .map_err(|e| JsLanguageError::Execution {
52                message: format!("camel global set: {e}"),
53            })?;
54
55        // Execute
56        let result = ctx
57            .eval(Source::from_bytes(source.as_bytes()))
58            .map_err(|e| JsLanguageError::Execution {
59                message: e.to_string(),
60            })?;
61
62        let return_value = js_to_value(&result, &mut ctx)?;
63
64        // Extract modified exchange state
65        let modified = bindings::extract_camel_state(&mut ctx)?;
66
67        Ok(JsEvalResult {
68            return_value,
69            headers: modified.headers,
70            body: modified.body,
71            properties: modified.properties,
72        })
73    }
74
75    fn validate(&self, source: &str) -> Result<(), JsLanguageError> {
76        // TODO(perf): Context::default() initializes full JS runtime. For high-throughput
77        // validation, consider a cached or pooled context.
78        let mut ctx = Context::default();
79        let _script =
80            boa_engine::Script::parse(Source::from_bytes(source.as_bytes()), None, &mut ctx)
81                .map_err(|e| JsLanguageError::Parse {
82                    message: e.to_string(),
83                })?;
84
85        Ok(())
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use serde_json::json;
93
94    fn make_exchange() -> JsExchange {
95        JsExchange::from_headers_body_properties(
96            [("foo".to_string(), json!("bar"))].into_iter().collect(),
97            json!("hello"),
98            [("key".to_string(), json!("val"))].into_iter().collect(),
99        )
100    }
101
102    #[test]
103    fn test_eval_return_value() {
104        let engine = BoaEngine::new();
105        let result = engine.eval("1 + 1", JsExchange::default()).unwrap();
106        assert_eq!(result.return_value.as_i64().unwrap(), 2);
107    }
108
109    #[test]
110    fn test_eval_header_access() {
111        let engine = BoaEngine::new();
112        let ex = make_exchange();
113        let result = engine.eval("camel.headers.get('foo')", ex).unwrap();
114        assert_eq!(result.return_value.as_str().unwrap(), "bar");
115    }
116
117    #[test]
118    fn test_eval_body_getter() {
119        let engine = BoaEngine::new();
120        let ex = make_exchange();
121        let result = engine.eval("camel.body", ex).unwrap();
122        assert_eq!(result.return_value.as_str().unwrap(), "hello");
123    }
124
125    #[test]
126    fn test_mutating_set_header_propagates() {
127        let engine = BoaEngine::new();
128        let ex = make_exchange();
129        let result = engine
130            .eval("camel.headers.set('newkey', 'newval'); 'done'", ex)
131            .unwrap();
132        assert_eq!(result.return_value.as_str().unwrap(), "done");
133        assert_eq!(
134            result.headers.get("newkey").unwrap().as_str().unwrap(),
135            "newval"
136        );
137    }
138
139    #[test]
140    fn test_mutating_body_propagates() {
141        let engine = BoaEngine::new();
142        let ex = make_exchange();
143        let result = engine
144            .eval("camel.body = 'modified'; camel.body", ex)
145            .unwrap();
146        assert_eq!(result.body.as_str().unwrap(), "modified");
147    }
148
149    #[test]
150    fn test_console_log_no_crash() {
151        let engine = BoaEngine::new();
152        let result = engine
153            .eval("console.log('test'); 42", JsExchange::default())
154            .unwrap();
155        assert_eq!(result.return_value.as_i64().unwrap(), 42);
156    }
157
158    #[test]
159    fn test_validate_valid() {
160        let engine = BoaEngine::new();
161        assert!(engine.validate("let x = 1 + 1;").is_ok());
162    }
163
164    #[test]
165    fn test_validate_invalid() {
166        let engine = BoaEngine::new();
167        assert!(engine.validate("let x = {{{").is_err());
168    }
169
170    #[test]
171    fn test_eval_property_access() {
172        let engine = BoaEngine::new();
173        let ex = make_exchange();
174        let result = engine.eval("camel.properties.get('key')", ex).unwrap();
175        assert_eq!(result.return_value.as_str().unwrap(), "val");
176    }
177
178    #[test]
179    fn test_eval_runtime_error_returns_err() {
180        let engine = BoaEngine::new();
181        let result = engine.eval("throw new Error('boom')", JsExchange::default());
182        assert!(result.is_err());
183        let msg = result.unwrap_err().to_string();
184        assert!(
185            msg.contains("boom")
186                || msg.to_lowercase().contains("execution")
187                || msg.to_lowercase().contains("error")
188        );
189    }
190
191    #[test]
192    fn test_eval_syntax_error_returns_err() {
193        let engine = BoaEngine::new();
194        let result = engine.eval("let x = {{{", JsExchange::default());
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn test_eval_missing_header_returns_undefined() {
200        let engine = BoaEngine::new();
201        let ex = make_exchange();
202        // Getting a key that doesn't exist should return undefined (maps to null in serde_json)
203        let result = engine.eval("camel.headers.get('nonexistent')", ex).unwrap();
204        assert!(result.return_value.is_null());
205    }
206
207    #[test]
208    fn test_properties_mutation_propagates() {
209        let engine = BoaEngine::new();
210        let ex = make_exchange();
211        let result = engine
212            .eval("camel.properties.set('newprop', 'newval'); 'done'", ex)
213            .unwrap();
214        assert_eq!(result.return_value.as_str().unwrap(), "done");
215        assert_eq!(
216            result.properties.get("newprop").unwrap().as_str().unwrap(),
217            "newval"
218        );
219    }
220
221    #[test]
222    fn test_headers_keys() {
223        let engine = BoaEngine::new();
224        let ex = make_exchange();
225        let result = engine.eval("camel.headers.keys()", ex).unwrap();
226        let keys: Vec<&str> = result
227            .return_value
228            .as_array()
229            .unwrap()
230            .iter()
231            .map(|v| v.as_str().unwrap())
232            .collect();
233        assert!(keys.contains(&"foo"));
234    }
235
236    #[test]
237    fn test_headers_has() {
238        let engine = BoaEngine::new();
239        let ex = make_exchange();
240        let r1 = engine.eval("camel.headers.has('foo')", ex.clone()).unwrap();
241        assert!(r1.return_value.as_bool().unwrap());
242        let r2 = engine.eval("camel.headers.has('missing')", ex).unwrap();
243        assert!(!r2.return_value.as_bool().unwrap());
244    }
245
246    #[test]
247    fn test_headers_remove() {
248        let engine = BoaEngine::new();
249        let ex = make_exchange();
250        let result = engine
251            .eval("camel.headers.remove('foo'); camel.headers.has('foo')", ex)
252            .unwrap();
253        assert!(!result.return_value.as_bool().unwrap());
254        assert!(!result.headers.contains_key("foo"));
255    }
256}