Skip to main content

camel_language_js/
language.rs

1//! [`JsLanguage`] — the JavaScript [`Language`] implementation for rust-camel.
2
3use std::sync::Arc;
4
5use camel_language_api::{
6    Expression, JsLimitsConfig, Language, LanguageError, MutatingExpression, Predicate,
7};
8
9use crate::{
10    engine::JsEngine,
11    engines::boa::BoaEngine,
12    expression::{JsExpression, JsMutatingExpression, JsPredicate, validate_to_parse_error},
13};
14
15// ── Resource limits ───────────────────────────────────────────────────────────
16//
17// Coverage (via Boa 0.21 RuntimeLimits):
18//   - Loop iteration count
19//   - Recursion depth
20//   - Stack size
21//
22// Not covered (Boa 0.21 does not expose):
23//   - Heap cap — `deny_unknown_fields` in serde rejects any `max-heap-size`
24//     field in Camel.toml if a user tries to set it.
25//
26// Defaults when not configured (rust-camel runtime defaults, ADR-0011):
27//   - execution_timeout_ms: 5_000
28//   - max_loop_iterations: 100_000  (Boa upstream is u64::MAX)
29//   - max_recursion_depth: 512      (Boa 0.21 upstream default, pinned)
30//   - max_stack_size:     10_240     (Boa 0.21 upstream default, pinned)
31
32/// JavaScript language plugin backed by [Boa](https://boajs.dev).
33///
34/// Implements the [`Language`] trait to produce JS-backed expressions, predicates,
35/// and mutating expressions for use in Apache Camel route definitions.
36///
37/// # Thread Safety
38///
39/// `JsLanguage` is `Clone + Send + Sync`. Evaluations run on one dedicated
40/// worker thread per engine limits-configuration (clones share it).
41///
42/// Isolation contract: each evaluation receives fresh `camel` and `console`
43/// bindings and a fresh declarative environment for lexical declarations.
44/// Configurable global additions are removed, and named intrinsic roots are
45/// verified between evaluations. JavaScript evaluations do not receive
46/// realm isolation: global properties, intrinsic state outside the named
47/// integrity set, heap state, and engine-internal state may survive across
48/// exchanges and routes until realm recycling or process termination.
49/// Route reload and route restart do not reset this state. Script source
50/// must be trusted operator configuration; untrusted or mutually
51/// distrustful code must use the out-of-process `function:` path (ADR-0005).
52///
53/// # Example
54///
55/// ```no_run
56/// use camel_language_js::JsLanguage;
57/// use camel_language_api::Language;
58///
59/// let lang = JsLanguage::new();
60/// let expr = lang.create_expression("camel.headers.get('foo')").unwrap();
61/// ```
62#[derive(Clone)]
63pub struct JsLanguage {
64    engine: Arc<dyn JsEngine>,
65    limits: JsLimitsConfig,
66}
67
68impl JsLanguage {
69    /// Create a new `JsLanguage` with the default [`BoaEngine`] and default limits.
70    pub fn new() -> Self {
71        Self::with_limits(JsLimitsConfig::default())
72    }
73
74    /// Create a `JsLanguage` with a custom [`JsLimitsConfig`].
75    ///
76    /// Uses the default [`BoaEngine`] internally.
77    pub fn with_limits(limits: JsLimitsConfig) -> Self {
78        Self {
79            engine: Arc::new(BoaEngine::new(limits.clone())),
80            limits,
81        }
82    }
83
84    /// Create a `JsLanguage` with a custom [`JsEngine`] implementation.
85    ///
86    /// Useful for testing or providing an alternative JS runtime.
87    /// Uses default limits.
88    pub fn with_engine<E: JsEngine>(engine: E) -> Self {
89        Self::with_engine_and_limits(engine, JsLimitsConfig::default())
90    }
91
92    /// Create a `JsLanguage` with a custom engine and custom limits.
93    pub fn with_engine_and_limits<E: JsEngine>(engine: E, limits: JsLimitsConfig) -> Self {
94        Self {
95            engine: Arc::new(engine),
96            limits,
97        }
98    }
99
100    /// Resolve the execution timeout from limits or the default rust-camel value.
101    fn execution_timeout_ms(&self) -> u64 {
102        self.limits.execution_timeout_ms.unwrap_or(5_000)
103    }
104}
105
106impl Default for JsLanguage {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl Language for JsLanguage {
113    fn name(&self) -> &'static str {
114        "js"
115    }
116
117    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
118        validate_to_parse_error(&self.engine, script)?;
119        Ok(Box::new(JsExpression::new(
120            script.to_string(),
121            Arc::clone(&self.engine),
122            self.execution_timeout_ms(),
123        )))
124    }
125
126    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
127        validate_to_parse_error(&self.engine, script)?;
128        Ok(Box::new(JsPredicate::new(
129            script.to_string(),
130            Arc::clone(&self.engine),
131            self.execution_timeout_ms(),
132        )))
133    }
134
135    fn create_mutating_expression(
136        &self,
137        script: &str,
138    ) -> Result<Box<dyn MutatingExpression>, LanguageError> {
139        validate_to_parse_error(&self.engine, script)?;
140        Ok(Box::new(JsMutatingExpression::new(
141            script.to_string(),
142            Arc::clone(&self.engine),
143            self.execution_timeout_ms(),
144        )))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use camel_language_api::{Body, Exchange, Message};
152    use serde_json::json;
153
154    async fn make_exchange() -> Exchange {
155        let mut msg = Message::default();
156        msg.headers.insert("env".to_string(), json!("prod"));
157        msg.body = Body::Text("payload".to_string());
158        let mut ex = Exchange::new(msg);
159        ex.properties.insert("trace".to_string(), json!("on"));
160        ex
161    }
162
163    #[tokio::test]
164    async fn test_language_name() {
165        assert_eq!(JsLanguage::new().name(), "js");
166    }
167
168    #[tokio::test]
169    async fn test_create_expression_valid() {
170        let lang = JsLanguage::new();
171        let result = lang.create_expression("1 + 1");
172        assert!(result.is_ok());
173    }
174
175    #[tokio::test]
176    async fn test_create_expression_invalid_syntax() {
177        let lang = JsLanguage::new();
178        let result = lang.create_expression("let x = {{{");
179        assert!(result.is_err());
180        // Should be a ParseError
181        assert!(matches!(result, Err(LanguageError::ParseError { .. })));
182    }
183
184    #[tokio::test]
185    async fn test_create_predicate_valid() {
186        let lang = JsLanguage::new();
187        assert!(lang.create_predicate("true").is_ok());
188    }
189
190    #[tokio::test]
191    async fn test_create_predicate_invalid() {
192        let lang = JsLanguage::new();
193        assert!(matches!(
194            lang.create_predicate("let !!!"),
195            Err(LanguageError::ParseError { .. })
196        ));
197    }
198
199    #[tokio::test]
200    async fn test_create_mutating_expression_invalid_syntax() {
201        let lang = JsLanguage::new();
202        let result = lang.create_mutating_expression("let !!!");
203        assert!(result.is_err());
204        assert!(matches!(result, Err(LanguageError::ParseError { .. })));
205    }
206
207    #[tokio::test]
208    async fn test_create_mutating_expression_valid() {
209        let lang = JsLanguage::new();
210        assert!(
211            lang.create_mutating_expression("camel.headers.set('k','v')")
212                .is_ok()
213        );
214    }
215
216    #[tokio::test]
217    async fn test_expression_evaluate() {
218        let lang = JsLanguage::new();
219        let expr = lang.create_expression("camel.headers.get('env')").unwrap();
220        let ex = make_exchange().await;
221        let val = expr.evaluate(&ex).await.unwrap();
222        assert_eq!(val.as_str().unwrap(), "prod");
223    }
224
225    #[tokio::test]
226    async fn test_predicate_matches() {
227        let lang = JsLanguage::new();
228        let pred = lang
229            .create_predicate("camel.headers.get('env') === 'prod'")
230            .unwrap();
231        let ex = make_exchange().await;
232        assert!(pred.matches(&ex).await.unwrap());
233    }
234
235    #[tokio::test]
236    async fn test_mutating_expression_propagates() {
237        let lang = JsLanguage::new();
238        let expr = lang
239            .create_mutating_expression(
240                "camel.headers.set('added', 'yes'); camel.body = 'new'; 'done'",
241            )
242            .unwrap();
243        let mut ex = make_exchange().await;
244        let result = expr.evaluate(&mut ex).await.unwrap();
245        assert_eq!(result.as_str().unwrap(), "done");
246        assert_eq!(
247            ex.input.headers.get("added").unwrap().as_str().unwrap(),
248            "yes"
249        );
250        assert_eq!(ex.input.body.as_text().unwrap(), "new");
251    }
252
253    #[tokio::test]
254    async fn test_default_creates_js_language() {
255        let lang = JsLanguage::default();
256        assert_eq!(lang.name(), "js");
257    }
258
259    #[tokio::test]
260    async fn test_clone_works() {
261        let lang = JsLanguage::new();
262        let lang2 = lang.clone();
263        assert_eq!(lang2.name(), "js");
264        // Both clones should work independently
265        let ex = make_exchange().await;
266        let expr = lang2.create_expression("42").unwrap();
267        let val = expr.evaluate(&ex).await.unwrap();
268        assert_eq!(val.as_i64().unwrap(), 42);
269    }
270}