This is an automated email from the ASF dual-hosted git repository.

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 440641c69b64 CAMEL-24688: camel-quickjs - controlled camel API, 
variables/exception bindings, compile scripts once per engine (#26309)
440641c69b64 is described below

commit 440641c69b649bfe0eabe4902dd2077923ea0f00
Author: Federico Mariani <[email protected]>
AuthorDate: Mon Sep 14 14:52:04 2026 +0200

    CAMEL-24688: camel-quickjs - controlled camel API, variables/exception 
bindings, compile scripts once per engine (#26309)
    
    * CAMEL-24688: camel-quickjs - controlled camel API, variables/exception 
bindings, compile scripts once per engine
    
    Parity with the other scripting languages within the JSON-only security 
model (review of #25778):
    
    - A frozen `camel` facade (getBody/setBody, get/set/removeHeader, 
get/set/removeProperty,
      get/set/removeVariable, log) backed by QuickJS4J host functions in a 
"camel" builtins module.
      The facade captures the real java_invoke in a closure scripts cannot 
reach and only dispatches
      to that module with a fixed arity per function; java_invoke, 
quickjs4j_engine and camelQuickjs
      stay stubbed on globalThis while a script runs. The current exchange is 
held per thread.
    - New bindings: `variables` and `exception` ({type, message} or null).
    - Route scripts are compiled once per engine (expression form `return 
(script)`, or the eval
      form for statement scripts so their completion value is unchanged) and 
run precompiled; each
      engine keeps a bounded LRU of 1,000 compiled scripts and the engine's own 
unbounded cache is off.
    - Only a trap inside the runtime (a host function that threw, a stack 
overflow, a compile that
      fails on a broken engine) discards the thread's engine; a JavaScript 
exception leaves it usable
      and is not charged an engine teardown. Throwing evaluations count towards 
recycling.
    - A script that does not parse is reported as 
ExpressionIllegalSyntaxException at compile time;
      a compile that fails because the engine is broken is not blamed on the 
script.
    - camel.getBody() applies the same rules as the body binding (a streaming 
body is an error).
    
    Builds on CAMEL-24687 (engine recycling on memory/evaluation count).
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    
    * CAMEL-24688: camel-quickjs - compile the generic eval wrapper once per 
engine, convert removed values
    
    With the engine's own script cache disabled, ScriptingLanguage.evaluate 
recompiled the fixed
    wrapper on every call; it is now compiled once per engine. 
camel.removeHeader/removeProperty/
    removeVariable return the removed value through the same JSON conversion as 
the getters, so a
    removed stream or host object cannot fail inside the host bridge.
    
    ---------
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 .../camel/catalog/docs/quickjs-language.adoc       |  63 +++-
 .../src/main/docs/quickjs-language.adoc            |  63 +++-
 .../camel/language/quickjs/QuickjsHelper.java      | 363 +++++++++++++++++++--
 .../camel/language/quickjs/QuickjsLanguage.java    | 229 +++++++++----
 .../language/quickjs/QuickjsCamelApiTest.java      | 246 ++++++++++++++
 .../language/quickjs/QuickjsErrorHandlingTest.java |   8 +-
 6 files changed, 843 insertions(+), 129 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
index 75e9e2158e18..be1b07df3142 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickjs-language.adoc
@@ -33,9 +33,9 @@ include::partial$language-options.adoc[]
 
 == Variables
 
-The following variables are bound for each evaluation. Values are JSON 
snapshots, not live Java
-objects. This data-only binding is the intended Preview design: there is no 
host-object bridge to
-live `Exchange`, `Message`, or `CamelContext` instances.
+The following variables are bound for each evaluation. Values are JSON 
snapshots taken before the
+script runs, not live Java objects. Live `Exchange`, `Message` and 
`CamelContext` instances are
+never exposed; the `camel` API below is the way to read the current state or 
to change it.
 
 [width="100%",cols="10%,10%,80%",options="header",]
 |=======================================================================
@@ -44,21 +44,54 @@ live `Exchange`, `Message`, or `CamelContext` instances.
 |headers |Object |the message headers after JSON conversion
 |properties |Object |the exchange properties after JSON conversion
 |exchangeId |String |the exchange id
+|variables |Object |the exchange variables after JSON conversion (an empty 
object when there are none)
+|exception |Object or null |`null`, or `{ type, message }` of the exception on 
the exchange (or the caught exception in an error handler)
+|camel |Object |the controlled Camel API, see below
 |=======================================================================
 
 `message`, `exchange`, and `context` are not bound. Scripts that refer to them 
raise a JavaScript
 `ReferenceError`.
 
-The following is *not* supported:
+Assigning to `headers`, `properties`, `variables` or `body` inside a script 
changes only the
+JavaScript snapshot. It does not mutate the Camel `Exchange`. Use the `camel` 
API, or the
+expression result (for example `.transform().quickjs(...)`) when you need to 
change the message.
 
-[source,javascript]
+=== The camel API
+
+`camel` is a frozen object whose functions read and write the *current* 
exchange through
+QuickJS4J host functions. Arguments and results cross the boundary as JSON, so 
a value written
+with `camel.setHeader` is stored as a `String`, `Number`, `Boolean`, `List` or 
`Map`.
+
+[width="100%",cols="30%,70%",options="header",]
+|=======================================================================
+|Function |Description
+|`camel.getBody()` |the current message body (JSON snapshot; a streaming body 
raises the same error as the `body` binding)
+|`camel.setBody(value)` |replaces the message body
+|`camel.getHeader(name)` |a header of the current message (JSON snapshot)
+|`camel.setHeader(name, value)` |sets a header on the current message
+|`camel.removeHeader(name)` |removes a header and returns its previous value
+|`camel.getProperty(name)` / `camel.setProperty(name, value)` / 
`camel.removeProperty(name)` |the same for exchange properties
+|`camel.getVariable(name)` / `camel.setVariable(name, value)` / 
`camel.removeVariable(name)` |the same for exchange variables
+|`camel.log(level, message)` |logs through the 
`org.apache.camel.language.quickjs.script` logger at `trace`, `debug`, `info`, 
`warn` or `error`
+|=======================================================================
+
+[source,java]
 ----
-exchange.getMessage().setHeader('foo', 'bar')
+from("direct:start")
+    .setBody().quickjs("camel.setHeader('processed', true); 
body.toUpperCase()")
+    .to("mock:result");
 ----
 
-Assigning to `headers` or `body` inside a script changes only the JavaScript 
snapshot. It does not
-mutate the Camel `Exchange`. Use the expression result (for example 
`.transform().quickjs(...)`)
-when you need to change the message body.
+The `camel` API is only available while a route expression is evaluated; the 
generic
+`ScriptingLanguage.evaluate(script, bindings, resultType)` entry point has no 
current exchange.
+
+=== Expressions and statements
+
+A script that is a single expression (`body.amount > 100`, `{ a: body }`) is 
compiled as an
+expression and returns its value. Any other script (several statements, a 
trailing semicolon, a
+`var` declaration) is evaluated as statements and returns its completion 
value, the value of the
+last statement, exactly as `eval` would. Note that `{ a: 1 }` is therefore an 
object when it is
+the whole script but a block statement when it is followed by other statements.
 
 The generic `ScriptingLanguage.evaluate(script, bindings, resultType)` API 
uses caller-supplied
 map keys as JavaScript function parameters. Those keys must be valid 
JavaScript identifiers
@@ -101,7 +134,11 @@ directly; the strings `true`/`false` are parsed; any other 
non-empty, non-null v
 
 == Engine lifecycle
 
-Every worker thread owns one QuickJS engine, created on first use and closed 
when the language stops.
+Every worker thread owns one QuickJS engine, created on first use and closed 
when the language stops. Each
+engine keeps the last 1,000 route expressions it evaluated in compiled form, 
so a script is compiled once per
+thread and then only executed. A JavaScript exception thrown by a script 
leaves the engine usable; a trap inside
+the runtime (a `camel` function that failed, a stack overflow) does not, and 
the engine of that thread is then
+discarded and recreated on the next evaluation.
 QuickJS keeps every module it has evaluated until its context is freed, and 
QuickJS4J evaluates a
 module per call, so an engine grows with every evaluation. The language 
therefore recycles a
 thread's engine once its WebAssembly memory exceeds `engineMaxMemory` (64 MB) 
or it has run
@@ -126,10 +163,8 @@ evaluations do not include stale error output.
 
 QuickJS4J host plumbing is not available to user scripts: `java_invoke` throws 
a
 `TypeError` and `quickjs4j_engine` is undefined, including when accessed 
through
-`globalThis`.
-
-This is not the same as GraalVM `HostAccess.ALL`. There is no opt-in to call 
methods on
-`Exchange` or `Message`.
+`globalThis`. The `camel` API is the only host bridge scripts can reach, and 
it only
+dispatches to the functions listed above.
 
 == Usage
 
diff --git a/components/camel-quickjs/src/main/docs/quickjs-language.adoc 
b/components/camel-quickjs/src/main/docs/quickjs-language.adoc
index 75e9e2158e18..be1b07df3142 100644
--- a/components/camel-quickjs/src/main/docs/quickjs-language.adoc
+++ b/components/camel-quickjs/src/main/docs/quickjs-language.adoc
@@ -33,9 +33,9 @@ include::partial$language-options.adoc[]
 
 == Variables
 
-The following variables are bound for each evaluation. Values are JSON 
snapshots, not live Java
-objects. This data-only binding is the intended Preview design: there is no 
host-object bridge to
-live `Exchange`, `Message`, or `CamelContext` instances.
+The following variables are bound for each evaluation. Values are JSON 
snapshots taken before the
+script runs, not live Java objects. Live `Exchange`, `Message` and 
`CamelContext` instances are
+never exposed; the `camel` API below is the way to read the current state or 
to change it.
 
 [width="100%",cols="10%,10%,80%",options="header",]
 |=======================================================================
@@ -44,21 +44,54 @@ live `Exchange`, `Message`, or `CamelContext` instances.
 |headers |Object |the message headers after JSON conversion
 |properties |Object |the exchange properties after JSON conversion
 |exchangeId |String |the exchange id
+|variables |Object |the exchange variables after JSON conversion (an empty 
object when there are none)
+|exception |Object or null |`null`, or `{ type, message }` of the exception on 
the exchange (or the caught exception in an error handler)
+|camel |Object |the controlled Camel API, see below
 |=======================================================================
 
 `message`, `exchange`, and `context` are not bound. Scripts that refer to them 
raise a JavaScript
 `ReferenceError`.
 
-The following is *not* supported:
+Assigning to `headers`, `properties`, `variables` or `body` inside a script 
changes only the
+JavaScript snapshot. It does not mutate the Camel `Exchange`. Use the `camel` 
API, or the
+expression result (for example `.transform().quickjs(...)`) when you need to 
change the message.
 
-[source,javascript]
+=== The camel API
+
+`camel` is a frozen object whose functions read and write the *current* 
exchange through
+QuickJS4J host functions. Arguments and results cross the boundary as JSON, so 
a value written
+with `camel.setHeader` is stored as a `String`, `Number`, `Boolean`, `List` or 
`Map`.
+
+[width="100%",cols="30%,70%",options="header",]
+|=======================================================================
+|Function |Description
+|`camel.getBody()` |the current message body (JSON snapshot; a streaming body 
raises the same error as the `body` binding)
+|`camel.setBody(value)` |replaces the message body
+|`camel.getHeader(name)` |a header of the current message (JSON snapshot)
+|`camel.setHeader(name, value)` |sets a header on the current message
+|`camel.removeHeader(name)` |removes a header and returns its previous value
+|`camel.getProperty(name)` / `camel.setProperty(name, value)` / 
`camel.removeProperty(name)` |the same for exchange properties
+|`camel.getVariable(name)` / `camel.setVariable(name, value)` / 
`camel.removeVariable(name)` |the same for exchange variables
+|`camel.log(level, message)` |logs through the 
`org.apache.camel.language.quickjs.script` logger at `trace`, `debug`, `info`, 
`warn` or `error`
+|=======================================================================
+
+[source,java]
 ----
-exchange.getMessage().setHeader('foo', 'bar')
+from("direct:start")
+    .setBody().quickjs("camel.setHeader('processed', true); 
body.toUpperCase()")
+    .to("mock:result");
 ----
 
-Assigning to `headers` or `body` inside a script changes only the JavaScript 
snapshot. It does not
-mutate the Camel `Exchange`. Use the expression result (for example 
`.transform().quickjs(...)`)
-when you need to change the message body.
+The `camel` API is only available while a route expression is evaluated; the 
generic
+`ScriptingLanguage.evaluate(script, bindings, resultType)` entry point has no 
current exchange.
+
+=== Expressions and statements
+
+A script that is a single expression (`body.amount > 100`, `{ a: body }`) is 
compiled as an
+expression and returns its value. Any other script (several statements, a 
trailing semicolon, a
+`var` declaration) is evaluated as statements and returns its completion 
value, the value of the
+last statement, exactly as `eval` would. Note that `{ a: 1 }` is therefore an 
object when it is
+the whole script but a block statement when it is followed by other statements.
 
 The generic `ScriptingLanguage.evaluate(script, bindings, resultType)` API 
uses caller-supplied
 map keys as JavaScript function parameters. Those keys must be valid 
JavaScript identifiers
@@ -101,7 +134,11 @@ directly; the strings `true`/`false` are parsed; any other 
non-empty, non-null v
 
 == Engine lifecycle
 
-Every worker thread owns one QuickJS engine, created on first use and closed 
when the language stops.
+Every worker thread owns one QuickJS engine, created on first use and closed 
when the language stops. Each
+engine keeps the last 1,000 route expressions it evaluated in compiled form, 
so a script is compiled once per
+thread and then only executed. A JavaScript exception thrown by a script 
leaves the engine usable; a trap inside
+the runtime (a `camel` function that failed, a stack overflow) does not, and 
the engine of that thread is then
+discarded and recreated on the next evaluation.
 QuickJS keeps every module it has evaluated until its context is freed, and 
QuickJS4J evaluates a
 module per call, so an engine grows with every evaluation. The language 
therefore recycles a
 thread's engine once its WebAssembly memory exceeds `engineMaxMemory` (64 MB) 
or it has run
@@ -126,10 +163,8 @@ evaluations do not include stale error output.
 
 QuickJS4J host plumbing is not available to user scripts: `java_invoke` throws 
a
 `TypeError` and `quickjs4j_engine` is undefined, including when accessed 
through
-`globalThis`.
-
-This is not the same as GraalVM `HostAccess.ALL`. There is no opt-in to call 
methods on
-`Exchange` or `Message`.
+`globalThis`. The `camel` API is the only host bridge scripts can reach, and 
it only
+dispatches to the functions listed above.
 
 == Usage
 
diff --git 
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
 
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
index 0ee17537a417..ac5f97bca2d4 100644
--- 
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
+++ 
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsHelper.java
@@ -26,13 +26,18 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import io.roastedroot.quickjs4j.core.Builtins;
 import io.roastedroot.quickjs4j.core.Engine;
 import io.roastedroot.quickjs4j.core.GuestException;
 import io.roastedroot.quickjs4j.core.GuestFunction;
+import io.roastedroot.quickjs4j.core.HostFunction;
 import io.roastedroot.quickjs4j.core.Invokables;
+import io.roastedroot.quickjs4j.core.ScriptCache;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.ExpressionEvaluationException;
@@ -41,6 +46,8 @@ import org.apache.camel.Message;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.StreamCache;
 import org.apache.camel.util.StringHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import run.endive.runtime.ByteArrayMemory;
 import run.endive.runtime.Memory;
 
@@ -53,37 +60,18 @@ final class QuickjsHelper {
     static final String FUNCTION_NAME = "camelEval";
 
     /**
-     * Evaluates {@code script} with a copy of {@code bindings} as function 
parameters so {@code var} declarations do
-     * not leak into {@code globalThis} between Camel exchanges. Binding names 
must be valid JavaScript identifiers.
-     * QuickJS4J host identifiers on {@code globalThis} are replaced with 
stubs while the user script runs and restored
-     * afterwards so result delivery still works on a reused engine. Do not 
{@code delete} those properties: the host
-     * function {@code java_invoke} is installed once per engine.
+     * Name of the QuickJS4J builtins module (and of the JavaScript object) 
that exposes the controlled Camel API to
+     * scripts: {@code camel.getHeader('foo')}, {@code camel.setBody(...)}, 
and so on.
      */
-    static final String EVAL_WRAPPER
-            = """
-                    export function camelEval(bindings, script) {
-                      const names = Object.keys(bindings);
-                      const values = names.map(name => bindings[name]);
-                      const fn = new Function(...names, 
"__camel_quickjs_script",
-                          '"use strict";'
-                          + 'const java_invoke = () => { throw new 
TypeError("java_invoke is not available"); };'
-                          + 'const quickjs4j_engine = undefined;'
-                          + 'const previousJavaInvoke = 
globalThis.java_invoke;'
-                          + 'const previousQuickjs4jEngine = 
globalThis.quickjs4j_engine;'
-                          + 'const previousCamelQuickjs = 
globalThis.camelQuickjs;'
-                          + 'try {'
-                          + 'globalThis.java_invoke = java_invoke;'
-                          + 'globalThis.quickjs4j_engine = undefined;'
-                          + 'globalThis.camelQuickjs = undefined;'
-                          + 'return eval(__camel_quickjs_script);'
-                          + '} finally {'
-                          + 'globalThis.java_invoke = previousJavaInvoke;'
-                          + 'globalThis.quickjs4j_engine = 
previousQuickjs4jEngine;'
-                          + 'globalThis.camelQuickjs = previousCamelQuickjs;'
-                          + '}');
-                      return fn(...values, script);
-                    }
-                    """;
+    static final String CAMEL_MODULE = "camel";
+
+    /**
+     * The exchange bindings every route expression sees, in the order they 
are passed to the script function.
+     */
+    static final List<String> EXCHANGE_BINDINGS
+            = List.of("body", "headers", "properties", "exchangeId", 
"variables", "exception");
+
+    private static final Logger LOG = 
LoggerFactory.getLogger("org.apache.camel.language.quickjs.script");
 
     private static final ObjectMapper MAPPER = Engine.DEFAULT_OBJECT_MAPPER;
 
@@ -98,18 +86,137 @@ final class QuickjsHelper {
             "protected", "public", "return", "static", "super", "switch", 
"this", "throw", "true", "try", "typeof",
             "var", "void", "while", "with", "yield");
 
+    /**
+     * The controlled Camel API. Each entry is one host function: its 
JavaScript name, its parameter names (the arity is
+     * fixed so the JSON argument array always has every slot) and the Java 
implementation, which receives the current
+     * {@link Exchange} and the JSON-decoded arguments.
+     */
+    private static final List<CamelFunction> CAMEL_API = List.of(
+            new CamelFunction(
+                    "getBody", List.of(), Object.class,
+                    // the same rules as the body binding: a streaming body is 
an error, not a silent null
+                    (exchange, args) -> 
toJsonCompatible(exchange.getMessage().getBody(), exchange, true)),
+            new CamelFunction(
+                    "setBody", List.of("value"), Void.class,
+                    (exchange, args) -> {
+                        exchange.getMessage().setBody(args.get(0));
+                        return null;
+                    }),
+            new CamelFunction(
+                    "getHeader", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.getMessage().getHeader(name(args)), exchange, false)),
+            new CamelFunction(
+                    "setHeader", List.of("name", "value"), Void.class,
+                    (exchange, args) -> {
+                        exchange.getMessage().setHeader(name(args), 
args.get(1));
+                        return null;
+                    }),
+            new CamelFunction(
+                    "removeHeader", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.getMessage().removeHeader(name(args)), exchange, 
false)),
+            new CamelFunction(
+                    "getProperty", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.getProperty(name(args)), exchange, false)),
+            new CamelFunction(
+                    "setProperty", List.of("name", "value"), Void.class,
+                    (exchange, args) -> {
+                        exchange.setProperty(name(args), args.get(1));
+                        return null;
+                    }),
+            new CamelFunction(
+                    "removeProperty", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.removeProperty(name(args)), exchange, false)),
+            new CamelFunction(
+                    "getVariable", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.getVariable(name(args)), exchange, false)),
+            new CamelFunction(
+                    "setVariable", List.of("name", "value"), Void.class,
+                    (exchange, args) -> {
+                        exchange.setVariable(name(args), args.get(1));
+                        return null;
+                    }),
+            new CamelFunction(
+                    "removeVariable", List.of("name"), Object.class,
+                    (exchange, args) -> 
toJsonCompatible(exchange.removeVariable(name(args)), exchange, false)),
+            new CamelFunction(
+                    "log", List.of("level", "message"), Void.class,
+                    (exchange, args) -> {
+                        log(String.valueOf(args.get(0)), 
String.valueOf(args.get(1)));
+                        return null;
+                    }));
+
+    /**
+     * JavaScript that builds the {@code camel} facade once per module 
evaluation. It captures the real
+     * {@code java_invoke} in a closure that user scripts cannot reach, and 
only ever dispatches to the
+     * {@value #CAMEL_MODULE} builtins module with a fixed argument arity per 
function.
+     */
+    static final String CAMEL_FACADE = camelFacade();
+
+    /**
+     * Prologue and epilogue shared by every script function: QuickJS4J host 
identifiers on {@code globalThis} are
+     * replaced with stubs while the user script runs and restored afterwards 
so result delivery still works on a reused
+     * engine. Do not {@code delete} those properties: the host function 
{@code java_invoke} is installed once per
+     * engine.
+     */
+    private static final String SANDBOX_ENTER = """
+            const __camel_prev_invoke = globalThis.java_invoke;
+            const __camel_prev_engine = globalThis.quickjs4j_engine;
+            const __camel_prev_module = globalThis.camelQuickjs;
+            const __camel_prev_camel = globalThis.camel;
+            const __camel_stub_invoke = () => { throw new 
TypeError("java_invoke is not available"); };
+            try {
+              globalThis.java_invoke = __camel_stub_invoke;
+              globalThis.quickjs4j_engine = undefined;
+              globalThis.camelQuickjs = undefined;
+              globalThis.camel = __camelFacade;
+            """;
+
+    private static final String SANDBOX_EXIT = """
+            } finally {
+              globalThis.java_invoke = __camel_prev_invoke;
+              globalThis.quickjs4j_engine = __camel_prev_engine;
+              globalThis.camelQuickjs = __camel_prev_module;
+              globalThis.camel = __camel_prev_camel;
+            }
+            """;
+
+    /**
+     * Generic wrapper used by {@code ScriptingLanguage.evaluate(script, 
bindings, resultType)}: the script text is
+     * passed as an argument and evaluated with a copy of {@code bindings} as 
function parameters so {@code var}
+     * declarations do not leak into {@code globalThis} between calls. Binding 
names must be valid JavaScript
+     * identifiers.
+     */
+    static final String EVAL_WRAPPER = CAMEL_FACADE
+                                       + """
+                                               export function 
camelEval(bindings, script) {
+                                                 const names = 
Object.keys(bindings);
+                                                 const values = names.map(name 
=> bindings[name]);
+                                                 const fn = new 
Function(...names, "camel", "java_invoke", "quickjs4j_engine", 
"__camel_quickjs_script",
+                                                     '"use strict"; return 
eval(__camel_quickjs_script);');
+                                               """
+                                       + indent(SANDBOX_ENTER, 2)
+                                       + "    return fn(...values, 
__camelFacade, __camel_stub_invoke, undefined, script);\n"
+                                       + indent(SANDBOX_EXIT, 2)
+                                       + "}\n";
+
     private QuickjsHelper() {
     }
 
-    static Engine newEngine(ByteArrayOutputStream stderr, Memory[] memory) {
+    static Engine newEngine(ByteArrayOutputStream stderr, Supplier<Exchange> 
currentExchange, Memory[] memory) {
+        Builtins.Builder camel = Builtins.builder(CAMEL_MODULE);
+        for (CamelFunction function : CAMEL_API) {
+            camel.add(function.hostFunction(currentExchange));
+        }
         return Engine.builder()
                 .withStdout(new DiscardingOutputStream())
                 .withStderr(stderr)
+                .withCache(NoScriptCache.INSTANCE)
                 .withMemoryFactory(limits -> {
                     // keep a handle on the WebAssembly linear memory so the 
language can watch it grow
                     memory[0] = new ByteArrayMemory(limits);
                     return memory[0];
                 })
+                .addBuiltins(camel.build())
                 .addInvokables(Invokables.builder(MODULE_NAME)
                         .add(evalFunction())
                         .build())
@@ -121,15 +228,107 @@ final class QuickjsHelper {
         return new GuestFunction(FUNCTION_NAME, List.of(Object.class, 
String.class), Object.class);
     }
 
+    /**
+     * The JavaScript library for one route expression: the script is 
embedded, so QuickJS compiles it once per engine
+     * instead of on every evaluation. An expression is compiled as {@code 
return (script)}; a script that is not a
+     * single expression (statements, a trailing semicolon) falls back to 
{@code eval} so it keeps its completion value,
+     * exactly as before.
+     */
+    static String scriptLibrary(String script, boolean expressionForm) {
+        StringBuilder sb = new StringBuilder(CAMEL_FACADE);
+        sb.append("function __camelScript(").append(String.join(", ", 
EXCHANGE_BINDINGS))
+                .append(", camel, java_invoke, quickjs4j_engine) {\n  \"use 
strict\";\n");
+        if (expressionForm) {
+            sb.append("  return (\n").append(script).append("\n  );\n}\n");
+        } else {
+            sb.append("  return eval(__camelSource);\n}\n");
+            sb.append("const __camelSource = 
").append(jsStringLiteral(script)).append(";\n");
+        }
+        sb.append("export function camelEval(b, s) {\n");
+        sb.append(indent(SANDBOX_ENTER, 1));
+        sb.append("    return __camelScript(");
+        for (String name : EXCHANGE_BINDINGS) {
+            sb.append("b.").append(name).append(", ");
+        }
+        sb.append("__camelFacade, __camel_stub_invoke, undefined);\n");
+        sb.append(indent(SANDBOX_EXIT, 1));
+        sb.append("}\n");
+        return sb.toString();
+    }
+
+    static byte[] compileScript(Engine engine, ByteArrayOutputStream stderr, 
String script) {
+        stderr.reset();
+        try {
+            return engine.compilePortableGuestFunction(scriptLibrary(script, 
true));
+        } catch (RuntimeException e) {
+            if (!isCompileFailure(e)) {
+                throw e;
+            }
+            // not a single expression: check it is valid as statements, then 
keep completion-value semantics
+            // through eval (a script that is invalid either way fails here, 
before anything runs)
+            stderr.reset();
+            try {
+                engine.compilePortableGuestFunction("function __camelProbe() 
{\n" + script + "\n}\n");
+            } catch (RuntimeException probe) {
+                if (!isCompileFailure(probe)) {
+                    throw probe;
+                }
+                // the script does not parse either way, unless the engine 
itself can no longer compile anything
+                stderr.reset();
+                try {
+                    engine.compilePortableGuestFunction("function 
__camelProbe() {\n1\n}\n");
+                } catch (RuntimeException broken) {
+                    throw new IllegalStateException("camel-quickjs engine can 
no longer compile scripts", broken);
+                }
+                throw new ExpressionIllegalSyntaxException(script, probe);
+            }
+            stderr.reset();
+            return engine.compilePortableGuestFunction(scriptLibrary(script, 
false));
+        } finally {
+            stderr.reset();
+        }
+    }
+
+    /**
+     * Whether the exception is QuickJS refusing to compile a library (which 
QuickJS4J reports as "Failed to compile JS
+     * code" without naming the error class), or a runtime SyntaxError.
+     */
+    static boolean isCompileFailure(Throwable thrown) {
+        for (Throwable current = thrown; current != null; current = 
current.getCause()) {
+            String message = current.getMessage();
+            if (message != null && message.contains("Failed to compile JS 
code")) {
+                return true;
+            }
+        }
+        return isSyntaxError(thrown);
+    }
+
     static Map<String, Object> exchangeBindings(Exchange exchange) {
         Map<String, Object> bindings = new LinkedHashMap<>();
         bindings.put("body", toJsonCompatible(exchange.getMessage().getBody(), 
exchange, true));
         bindings.put("headers", 
toJsonCompatibleMap(exchange.getMessage().getHeaders(), exchange, false));
         bindings.put("properties", 
toJsonCompatibleMap(exchange.getAllProperties(), exchange, false));
         bindings.put("exchangeId", exchange.getExchangeId());
+        bindings.put("variables",
+                exchange.hasVariables() ? 
toJsonCompatibleMap(exchange.getVariables(), exchange, false) : Map.of());
+        bindings.put("exception", exceptionBinding(exchange));
         return bindings;
     }
 
+    private static Map<String, Object> exceptionBinding(Exchange exchange) {
+        Throwable t = exchange.getException();
+        if (t == null) {
+            t = exchange.getProperty(Exchange.EXCEPTION_CAUGHT, 
Throwable.class);
+        }
+        if (t == null) {
+            return null;
+        }
+        Map<String, Object> map = new LinkedHashMap<>();
+        map.put("type", t.getClass().getName());
+        map.put("message", t.getMessage());
+        return map;
+    }
+
     static Map<String, Object> toJsonCompatibleBindings(Map<String, Object> 
bindings, Exchange exchange) {
         if (bindings == null || bindings.isEmpty()) {
             return Map.of();
@@ -268,7 +467,7 @@ final class QuickjsHelper {
                 return true;
             }
             String message = current.getMessage();
-            if (message != null && (message.contains("SyntaxError") || 
message.contains("Failed to compile JS code"))) {
+            if (message != null && message.contains("SyntaxError")) {
                 return true;
             }
             if (current instanceof GuestException && message != null && 
message.contains("SyntaxError")) {
@@ -289,6 +488,106 @@ final class QuickjsHelper {
                                             + " cannot be exposed to 
camel-quickjs without consuming the message body");
     }
 
+    private static String name(List<Object> args) {
+        Object name = args.get(0);
+        if (name == null) {
+            throw new IllegalArgumentException("camel-quickjs: a name is 
required");
+        }
+        return String.valueOf(name);
+    }
+
+    private static void log(String level, String message) {
+        switch (level.toLowerCase()) {
+            case "trace" -> LOG.trace(message);
+            case "debug" -> LOG.debug(message);
+            case "warn" -> LOG.warn(message);
+            case "error" -> LOG.error(message);
+            default -> LOG.info(message);
+        }
+    }
+
+    private static String camelFacade() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("const __camelFacade = (() => {\n");
+        sb.append("  const invoke = globalThis.java_invoke;\n");
+        sb.append("  const call = (name, args) => 
JSON.parse(invoke(\"").append(CAMEL_MODULE)
+                .append("\", name, JSON.stringify(args)));\n");
+        sb.append("  return Object.freeze({\n");
+        for (CamelFunction function : CAMEL_API) {
+            String params = String.join(", ", function.params());
+            sb.append("    ").append(function.name()).append(": 
(").append(params).append(") => call(\"")
+                    .append(function.name()).append("\", 
[").append(params).append("]),\n");
+        }
+        sb.append("  });\n})();\n");
+        return sb.toString();
+    }
+
+    static String jsStringLiteral(String s) {
+        try {
+            return MAPPER.writeValueAsString(s);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("Cannot encode script as a 
JavaScript string", e);
+        }
+    }
+
+    private static String indent(String block, int levels) {
+        String pad = "  ".repeat(levels);
+        StringBuilder sb = new StringBuilder();
+        for (String line : block.split("\n", -1)) {
+            if (line.isEmpty()) {
+                continue;
+            }
+            sb.append(pad).append(line).append('\n');
+        }
+        return sb.toString();
+    }
+
+    /**
+     * One function of the controlled Camel API.
+     */
+    private record CamelFunction(String name, List<String> params, Class<?> 
returnType,
+            BiFunction<Exchange, List<Object>, Object> body) {
+
+        @SuppressWarnings("rawtypes")
+        HostFunction hostFunction(Supplier<Exchange> currentExchange) {
+            List<Class> paramTypes = new ArrayList<>(params.size());
+            for (String param : params) {
+                paramTypes.add("value".equals(param) ? Object.class : 
String.class);
+            }
+            return new HostFunction(name, paramTypes, returnType, args -> {
+                Exchange exchange = currentExchange.get();
+                if (exchange == null) {
+                    throw new IllegalStateException(
+                            "camel." + name + " is only available while 
evaluating a route expression");
+                }
+                return body.apply(exchange, args);
+            });
+        }
+    }
+
+    /**
+     * QuickJS4J caches every compiled library by a SHA-256 of its source in 
an unbounded map; camel-quickjs keeps its
+     * own bounded per-engine cache of compiled scripts, so the engine's cache 
is disabled.
+     */
+    private static final class NoScriptCache implements ScriptCache {
+        static final NoScriptCache INSTANCE = new NoScriptCache();
+
+        @Override
+        public boolean exists(byte[] code) {
+            return false;
+        }
+
+        @Override
+        public void set(byte[] code, byte[] compiled) {
+            // not cached here
+        }
+
+        @Override
+        public byte[] get(byte[] code) {
+            return null;
+        }
+    }
+
     /**
      * Drops {@code console.log} / WASI output so a reused {@link Engine} does 
not accumulate stdout.
      */
diff --git 
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
 
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
index 97a3a2c85730..805302f2814b 100644
--- 
a/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
+++ 
b/components/camel-quickjs/src/main/java/org/apache/camel/language/quickjs/QuickjsLanguage.java
@@ -17,6 +17,7 @@
 package org.apache.camel.language.quickjs;
 
 import java.io.ByteArrayOutputStream;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentLinkedQueue;
@@ -25,10 +26,12 @@ import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
 import io.roastedroot.quickjs4j.core.Engine;
+import io.roastedroot.quickjs4j.core.GuestException;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
 import org.apache.camel.Expression;
 import org.apache.camel.ExpressionEvaluationException;
+import org.apache.camel.ExpressionIllegalSyntaxException;
 import org.apache.camel.Predicate;
 import org.apache.camel.Service;
 import org.apache.camel.spi.ScriptingLanguage;
@@ -40,19 +43,31 @@ import run.endive.runtime.Memory;
  * Camel expression language for JavaScript via <a 
href="https://github.com/roastedroot/quickjs4j";>QuickJS4J</a>.
  *
  * <p>
- * Scripts see only JSON-serializable data bindings: {@code body}, {@code 
headers}, {@code properties}, and
- * {@code exchangeId}. Live {@code Exchange}, {@code Message}, and {@code 
CamelContext} objects are not bound, so
- * {@code exchange.getMessage()} is a JavaScript {@code ReferenceError} rather 
than Java interop.
+ * Scripts see JSON-serializable data bindings: {@code body}, {@code headers}, 
{@code properties}, {@code exchangeId},
+ * {@code variables} and {@code exception}, plus the controlled {@code camel} 
API ({@code camel.getHeader(name)},
+ * {@code camel.setHeader(name, value)}, {@code camel.setBody(value)}, ...) 
that reads and writes the current
+ * {@code Exchange} through host functions. Live {@code Exchange}, {@code 
Message}, and {@code CamelContext} objects are
+ * never bound, so {@code exchange.getMessage()} is a JavaScript {@code 
ReferenceError} rather than Java interop.
+ * </p>
+ * <p>
+ * Every worker thread owns one QuickJS engine, and each engine keeps a 
bounded cache of compiled scripts, so a route
+ * expression is compiled once per thread and then only executed.
  * </p>
  */
 @Language("quickjs")
 public class QuickjsLanguage extends TypedLanguageSupport implements 
ScriptingLanguage, Service {
 
+    /**
+     * Compiled scripts kept per engine (per worker thread), least recently 
used first out.
+     */
+    static final int COMPILED_SCRIPTS_PER_ENGINE = 1000;
+
     /**
      * Every evaluation executes a module in the QuickJS runtime, and QuickJS 
keeps evaluated modules until its context
-     * is freed, so an engine grows with every evaluation (about 12 KB each). 
An engine is therefore recycled once its
-     * WebAssembly memory exceeds {@link #getEngineMaxMemory()} or it has run 
{@link #getEngineMaxEvaluations()}
-     * evaluations: it is closed and the thread creates a fresh one on its 
next evaluation.
+     * is freed, so an engine grows with every evaluation (~12 KB each 
measured with camel-quickjs 4.23). An engine is
+     * therefore recycled once its WebAssembly memory exceeds {@link 
#getEngineMaxMemory()} or it has run
+     * {@link #getEngineMaxEvaluations()} evaluations: it is closed and the 
thread creates a fresh one, which recompiles
+     * its scripts on demand.
      */
     private volatile long engineMaxMemory = 64L * 1024 * 1024;
     private volatile int engineMaxEvaluations = 50_000;
@@ -60,6 +75,7 @@ public class QuickjsLanguage extends TypedLanguageSupport 
implements ScriptingLa
     private final AtomicInteger generation = new AtomicInteger();
     private final ConcurrentLinkedQueue<Engine> engines = new 
ConcurrentLinkedQueue<>();
     private final ThreadLocal<EngineState> engine = new ThreadLocal<>();
+    private final ThreadLocal<Exchange> currentExchange = new ThreadLocal<>();
     private final Lock engineLock = new ReentrantLock();
 
     /**
@@ -114,45 +130,120 @@ public class QuickjsLanguage extends 
TypedLanguageSupport implements ScriptingLa
      * Evaluates {@code script} with optional {@code bindings} as JavaScript 
function parameters. Binding names must be
      * valid JavaScript identifiers; invalid names fail with {@link 
ExpressionEvaluationException} rather than a raw
      * JavaScript {@code SyntaxError}. Route expressions do not use this map — 
they always bind {@code body},
-     * {@code headers}, {@code properties}, and {@code exchangeId}.
+     * {@code headers}, {@code properties}, {@code exchangeId}, {@code 
variables} and {@code exception}. There is no
+     * current exchange, so the {@code camel} API is not usable from this 
entry point.
      */
     @Override
     public <T> T evaluate(String script, Map<String, Object> bindings, 
Class<T> resultType) {
         script = loadResource(script);
+        Map<String, Object> jsonBindings = 
QuickjsHelper.toJsonCompatibleBindings(bindings, null);
+        EngineState state = currentEngine();
+        state.stderr.reset();
+        Object result;
+        boolean discarded = false;
         try {
-            Object result = eval(script, 
QuickjsHelper.toJsonCompatibleBindings(bindings, null));
-            return convert(result, resultType, getCamelContext(), null);
+            result = state.engine.invokePrecompiledGuestFunction(
+                    QuickjsHelper.MODULE_NAME,
+                    QuickjsHelper.FUNCTION_NAME,
+                    List.of(jsonBindings, script),
+                    state.evalWrapper());
         } catch (Exception e) {
+            discarded = discardIfPoisoned(state, e);
             throw QuickjsHelper.wrapFailure(script, null, e);
+        } finally {
+            state.stderr.reset();
+            if (!discarded && state.exhausted(engineMaxMemory, 
engineMaxEvaluations)) {
+                discard(state);
+            }
         }
+        return convert(result, resultType, getCamelContext(), null);
     }
 
     Object evaluateExpression(String script, Exchange exchange) {
+        Exchange previous = currentExchange.get();
+        currentExchange.set(exchange);
         try {
-            Object result = eval(script, 
QuickjsHelper.exchangeBindings(exchange));
+            Map<String, Object> bindings = 
QuickjsHelper.exchangeBindings(exchange);
+            EngineState state = currentEngine();
+            Object result;
+            boolean discarded = false;
+            try {
+                byte[] compiled = state.compiled(script);
+                state.stderr.reset();
+                result = state.engine.invokePrecompiledGuestFunction(
+                        QuickjsHelper.MODULE_NAME,
+                        QuickjsHelper.FUNCTION_NAME,
+                        List.of(bindings, ""),
+                        compiled);
+            } catch (Exception e) {
+                discarded = discardIfPoisoned(state, e);
+                throw QuickjsHelper.wrapFailure(script, exchange, e);
+            } finally {
+                // Drop this evaluation's WASI stderr so a reused Engine 
cannot accumulate it.
+                state.stderr.reset();
+                // a script that throws has still evaluated (and QuickJS kept) 
its module: count it as well
+                if (!discarded && state.exhausted(engineMaxMemory, 
engineMaxEvaluations)) {
+                    discard(state);
+                }
+            }
             return convert(result, Object.class, exchange.getContext(), 
exchange);
-        } catch (Exception e) {
-            throw QuickjsHelper.wrapFailure(script, exchange, e);
+        } finally {
+            if (previous != null) {
+                currentExchange.set(previous);
+            } else {
+                currentExchange.remove();
+            }
         }
     }
 
-    private Object eval(String script, Map<String, Object> bindings) {
-        EngineState state = currentEngine();
-        state.stderr.reset();
+    private EngineState currentEngine() {
+        return currentEngine(0);
+    }
+
+    private EngineState currentEngine(int attempt) {
+        if (attempt > 8) {
+            throw new IllegalStateException("camel-quickjs engine was 
invalidated while being created");
+        }
+        int gen = generation.get();
+        EngineState state = engine.get();
+        if (state != null && state.generation == gen) {
+            return state;
+        }
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+        Memory[] memory = new Memory[1];
+        Engine created = QuickjsHelper.newEngine(stderr, currentExchange::get, 
memory);
+        if (generation.get() != gen) {
+            closeUnpublished(created);
+            return currentEngine(attempt + 1);
+        }
+        engineLock.lock();
         try {
-            return state.engine.invokeGuestFunction(
-                    QuickjsHelper.MODULE_NAME,
-                    QuickjsHelper.FUNCTION_NAME,
-                    List.of(bindings, script),
-                    QuickjsHelper.EVAL_WRAPPER);
-        } finally {
-            // Drop this evaluation's WASI stderr so a reused Engine cannot 
accumulate it.
-            state.stderr.reset();
-            // a script that throws has still evaluated (and QuickJS kept) its 
module: count it as well
-            if (state.exhausted(engineMaxMemory, engineMaxEvaluations)) {
-                discard(state);
+            if (generation.get() == gen) {
+                engines.add(created);
+                state = new EngineState(gen, created, stderr, memory[0]);
+                engine.set(state);
+                return state;
             }
+        } finally {
+            engineLock.unlock();
         }
+        closeUnpublished(created);
+        return currentEngine(attempt + 1);
+    }
+
+    /**
+     * A JavaScript exception ({@link GuestException}) and a script that does 
not parse
+     * ({@link ExpressionIllegalSyntaxException}) leave the QuickJS runtime 
usable. Anything else (a host function that
+     * threw, a stack overflow, a compile that fails for another reason) is a 
trap inside the WebAssembly instance,
+     * after which the next compile panics with "RefCell already borrowed": 
the engine is dropped and this thread gets a
+     * fresh one on its next evaluation.
+     */
+    private boolean discardIfPoisoned(EngineState state, Exception e) {
+        if (e instanceof GuestException || e instanceof 
ExpressionIllegalSyntaxException) {
+            return false;
+        }
+        discard(state);
+        return true;
     }
 
     /**
@@ -168,6 +259,18 @@ public class QuickjsLanguage extends TypedLanguageSupport 
implements ScriptingLa
         }
     }
 
+    int trackedEngineCount() {
+        return engines.size();
+    }
+
+    /**
+     * WebAssembly memory of the calling thread's engine, in bytes (for tests).
+     */
+    long engineMemory() {
+        EngineState state = engine.get();
+        return state == null ? 0 : state.memoryBytes();
+    }
+
     public long getEngineMaxMemory() {
         return engineMaxMemory;
     }
@@ -191,50 +294,11 @@ public class QuickjsLanguage extends TypedLanguageSupport 
implements ScriptingLa
     }
 
     /**
-     * WebAssembly memory of the calling thread's engine, in bytes (for tests).
+     * Number of compiled scripts cached by the engine of the calling thread 
(for tests).
      */
-    long engineMemory() {
-        EngineState state = engine.get();
-        return state == null ? 0 : state.memoryBytes();
-    }
-
-    private EngineState currentEngine() {
-        return currentEngine(0);
-    }
-
-    private EngineState currentEngine(int attempt) {
-        if (attempt > 8) {
-            throw new IllegalStateException("camel-quickjs engine was 
invalidated while being created");
-        }
-        int gen = generation.get();
+    int compiledScriptCount() {
         EngineState state = engine.get();
-        if (state != null && state.generation == gen) {
-            return state;
-        }
-        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
-        Memory[] memory = new Memory[1];
-        Engine created = QuickjsHelper.newEngine(stderr, memory);
-        if (generation.get() != gen) {
-            closeUnpublished(created);
-            return currentEngine(attempt + 1);
-        }
-        engineLock.lock();
-        try {
-            if (generation.get() == gen) {
-                engines.add(created);
-                state = new EngineState(gen, created, stderr, memory[0]);
-                engine.set(state);
-                return state;
-            }
-        } finally {
-            engineLock.unlock();
-        }
-        closeUnpublished(created);
-        return currentEngine(attempt + 1);
-    }
-
-    int trackedEngineCount() {
-        return engines.size();
+        return state == null ? 0 : state.compiled.size();
     }
 
     private static void closeUnpublished(Engine created) {
@@ -265,12 +329,22 @@ public class QuickjsLanguage extends TypedLanguageSupport 
implements ScriptingLa
         return resultType.cast(value);
     }
 
+    /**
+     * One engine and its compiled scripts; only ever used by the thread that 
created it.
+     */
     private static final class EngineState {
         private final int generation;
         private final Engine engine;
         private final ByteArrayOutputStream stderr;
         private final Memory memory;
         private int evaluations;
+        private byte[] evalWrapper;
+        private final Map<String, byte[]> compiled = new LinkedHashMap<>(64, 
0.75f, true) {
+            @Override
+            protected boolean removeEldestEntry(Map.Entry<String, byte[]> 
eldest) {
+                return size() > COMPILED_SCRIPTS_PER_ENGINE;
+            }
+        };
 
         private EngineState(int generation, Engine engine, 
ByteArrayOutputStream stderr, Memory memory) {
             this.generation = generation;
@@ -287,5 +361,24 @@ public class QuickjsLanguage extends TypedLanguageSupport 
implements ScriptingLa
             evaluations++;
             return evaluations >= maxEvaluations || memoryBytes() > maxMemory;
         }
+
+        /**
+         * The generic eval wrapper, compiled once per engine (the engine's 
own script cache is disabled).
+         */
+        byte[] evalWrapper() {
+            if (evalWrapper == null) {
+                evalWrapper = 
engine.compilePortableGuestFunction(QuickjsHelper.EVAL_WRAPPER);
+            }
+            return evalWrapper;
+        }
+
+        byte[] compiled(String script) {
+            byte[] code = compiled.get(script);
+            if (code == null) {
+                code = QuickjsHelper.compileScript(engine, stderr, script);
+                compiled.put(script, code);
+            }
+            return code;
+        }
     }
 }
diff --git 
a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsCamelApiTest.java
 
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsCamelApiTest.java
new file mode 100644
index 000000000000..7071df3efa88
--- /dev/null
+++ 
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsCamelApiTest.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.language.quickjs;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExpressionEvaluationException;
+import org.apache.camel.ExpressionIllegalSyntaxException;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.spi.Language;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+/**
+ * The controlled {@code camel} API (mutation through host functions), the 
{@code variables} / {@code exception}
+ * bindings and the per-engine compiled script cache.
+ */
+class QuickjsCamelApiTest {
+
+    private static CamelContext context;
+    private static Language language;
+
+    @BeforeAll
+    static void startContext() {
+        context = new DefaultCamelContext();
+        context.start();
+        language = context.resolveLanguage("quickjs");
+    }
+
+    @AfterAll
+    static void stopContext() {
+        context.stop();
+    }
+
+    private static Exchange exchange(Object body) {
+        Exchange exchange = new DefaultExchange(context);
+        exchange.getMessage().setBody(body);
+        return exchange;
+    }
+
+    @Test
+    void setHeaderWritesThroughToTheExchange() {
+        Exchange exchange = exchange("Hello");
+        language.createExpression(
+                "camel.setHeader('processed', true); camel.setHeader('count', 
3); camel.log('info', 'set'); body")
+                .evaluate(exchange, Object.class);
+        
assertThat(exchange.getMessage().getHeader("processed")).isEqualTo(true);
+        assertThat(exchange.getMessage().getHeader("count")).isEqualTo(3);
+    }
+
+    @Test
+    void getHeaderReadsTheLiveExchange() {
+        Exchange exchange = exchange("Hello");
+        exchange.getMessage().setHeader("foo", "bar");
+        Object result = language.createExpression("camel.setHeader('foo', 
'baz'); camel.getHeader('foo')")
+                .evaluate(exchange, Object.class);
+        // the headers binding is a snapshot taken before the script ran, the 
API sees the live value
+        assertThat(result).isEqualTo("baz");
+        Object snapshot = language.createExpression("camel.setHeader('foo', 
'qux'); headers.foo")
+                .evaluate(exchange, Object.class);
+        assertThat(snapshot).isEqualTo("baz");
+        assertThat(exchange.getMessage().getHeader("foo")).isEqualTo("qux");
+    }
+
+    @Test
+    void removeHeaderPropertyAndVariable() {
+        Exchange exchange = exchange("Hello");
+        exchange.getMessage().setHeader("h", 1);
+        exchange.setProperty("p", 2);
+        exchange.setVariable("v", 3);
+        Object removed = language.createExpression(
+                "[camel.removeHeader('h'), camel.removeProperty('p'), 
camel.removeVariable('v')]")
+                .evaluate(exchange, Object.class);
+        assertThat(removed).isEqualTo(List.of(1, 2, 3));
+        assertThat(exchange.getMessage().getHeader("h")).isNull();
+        assertThat(exchange.getProperty("p")).isNull();
+        assertThat(exchange.getVariable("v")).isNull();
+    }
+
+    @Test
+    void setBodyAndSetPropertyWithStructuredValues() {
+        Exchange exchange = exchange("Hello");
+        language.createExpression("camel.setBody({ greeting: body, items: [1, 
2] }); camel.setProperty('tag', 'x')")
+                .evaluate(exchange, Object.class);
+        
assertThat(exchange.getMessage().getBody()).isEqualTo(Map.of("greeting", 
"Hello", "items", List.of(1, 2)));
+        assertThat(exchange.getProperty("tag")).isEqualTo("x");
+    }
+
+    @Test
+    void variablesAreBoundAndWritable() {
+        Exchange exchange = exchange("Hello");
+        exchange.setVariable("who", "World");
+        Object result = language.createExpression("camel.setVariable('seen', 
variables.who); body + ' ' + variables.who")
+                .evaluate(exchange, Object.class);
+        assertThat(result).isEqualTo("Hello World");
+        assertThat(exchange.getVariable("seen")).isEqualTo("World");
+        
assertThat(language.createExpression("camel.getVariable('seen')").evaluate(exchange,
 String.class))
+                .isEqualTo("World");
+        // no variables at all binds an empty object
+        
assertThat(language.createExpression("Object.keys(variables).length").evaluate(exchange("x"),
 Integer.class))
+                .isZero();
+    }
+
+    @Test
+    void exceptionBindingIsNullOrTypeAndMessage() {
+        Exchange exchange = exchange("Hello");
+        assertThat(language.createExpression("exception === 
null").evaluate(exchange, Boolean.class)).isTrue();
+        exchange.setException(new IllegalStateException("boom"));
+        assertThat(language.createExpression("exception.type + ': ' + 
exception.message").evaluate(exchange,
+                String.class)).isEqualTo("java.lang.IllegalStateException: 
boom");
+        Exchange caught = exchange("Hello");
+        caught.setProperty(Exchange.EXCEPTION_CAUGHT, new 
IllegalArgumentException("caught"));
+        
assertThat(language.createExpression("exception.message").evaluate(caught, 
String.class)).isEqualTo("caught");
+    }
+
+    @Test
+    void camelApiIsNotAvailableWithoutAnExchange() {
+        QuickjsLanguage quickjs = (QuickjsLanguage) language;
+        Throwable thrown = catchThrowable(() -> 
quickjs.evaluate("camel.getBody()", Map.of(), Object.class));
+        assertThat(thrown).isInstanceOf(ExpressionEvaluationException.class);
+        assertThat(thrown.getCause()).hasMessageContaining("only available 
while evaluating a route expression");
+    }
+
+    @Test
+    void camelFacadeCannotReachTheRawHostBridge() {
+        Exchange exchange = exchange("x");
+        assertThat(language.createExpression("typeof 
camel.getHeader").evaluate(exchange, String.class))
+                .isEqualTo("function");
+        assertThat(language.createExpression("typeof 
globalThis.camel.setBody").evaluate(exchange, String.class))
+                .isEqualTo("function");
+        
assertThat(language.createExpression("Object.isFrozen(camel)").evaluate(exchange,
 Boolean.class)).isTrue();
+        // the facade closes over the real java_invoke; nothing on it or on 
the script's scope leaks it
+        assertThat(language.createExpression("typeof 
java_invoke").evaluate(exchange, String.class))
+                .isEqualTo("function");
+        Throwable thrown = catchThrowable(
+                () -> language.createExpression("java_invoke('camel', 
'getBody', '[]')").evaluate(exchange,
+                        Object.class));
+        assertThat(thrown).isInstanceOf(ExpressionEvaluationException.class)
+                .hasMessageContaining("java_invoke is not available");
+        // a JavaScript exception leaves the engine usable
+        
assertThat(language.createExpression("camel.getBody()").evaluate(exchange, 
String.class)).isEqualTo("x");
+        
assertThat(language.createExpression("Object.keys(camel).includes('invoke')").evaluate(exchange,
+                Boolean.class)).isFalse();
+    }
+
+    @Test
+    void guestExceptionKeepsTheEngine() {
+        QuickjsLanguage quickjs = (QuickjsLanguage) language;
+        Exchange exchange = exchange("x");
+        language.createExpression("body").evaluate(exchange, String.class);
+        assertThat(quickjs.trackedEngineCount()).isEqualTo(1);
+        long memory = quickjs.engineMemory();
+        for (String script : new String[] { "throw new Error('boom')", 
"body.nope.deeper", "var a = 1; throw a" }) {
+            Throwable thrown = catchThrowable(() -> 
language.createExpression(script).evaluate(exchange, Object.class));
+            
assertThat(thrown).isInstanceOf(ExpressionEvaluationException.class);
+            // same engine: not discarded, and it still compiles and runs new 
scripts
+            assertThat(quickjs.trackedEngineCount()).isEqualTo(1);
+            assertThat(quickjs.engineMemory()).isGreaterThanOrEqualTo(memory);
+            assertThat(language.createExpression("body + '/' + '" + 
script.length() + "'").evaluate(exchange, String.class))
+                    .isEqualTo("x/" + script.length());
+        }
+    }
+
+    @Test
+    void hostTrapDiscardsTheEngineAndTheNextEvaluationRecovers() {
+        QuickjsLanguage quickjs = (QuickjsLanguage) language;
+        Exchange exchange = exchange("x");
+        language.createExpression("body").evaluate(exchange, String.class);
+        assertThat(quickjs.trackedEngineCount()).isEqualTo(1);
+        // a host function that throws is a trap inside the runtime: the 
engine is dropped
+        Throwable thrown = catchThrowable(
+                () -> 
language.createExpression("camel.getHeader(null)").evaluate(exchange, 
Object.class));
+        assertThat(thrown).isInstanceOf(ExpressionEvaluationException.class)
+                .isNotInstanceOf(ExpressionIllegalSyntaxException.class);
+        assertThat(quickjs.trackedEngineCount()).isZero();
+        // and a valid script is neither blamed for the runtime state nor 
rejected
+        assertThat(language.createExpression("body + 103").evaluate(exchange, 
String.class)).isEqualTo("x103");
+        assertThat(quickjs.trackedEngineCount()).isEqualTo(1);
+    }
+
+    @Test
+    void streamingBodyIsAnErrorThroughTheApiAsWell() {
+        // the body binding rejects a streaming body 
(QuickjsSerializationTest); camel.getBody() must agree
+        Exchange exchange = exchange(new ByteArrayInputStream(new byte[] { 1 
}));
+        Throwable thrown = catchThrowable(
+                () -> 
language.createExpression("camel.getBody()").evaluate(exchange, Object.class));
+        assertThat(thrown).isInstanceOf(ExpressionEvaluationException.class)
+                .hasMessageContaining("cannot be exposed to camel-quickjs 
without consuming the message body");
+    }
+
+    @Test
+    void expressionAndStatementFormsKeepTheirValue() {
+        Exchange exchange = exchange(20);
+        // single expression: compiled as return (...)
+        assertThat(language.createExpression("body * 2 + 
1").evaluate(exchange, Integer.class)).isEqualTo(41);
+        // object literal is an expression, not a block, when compiled as an 
expression
+        assertThat(language.createExpression("{ a: body }").evaluate(exchange, 
Object.class))
+                .isEqualTo(Map.of("a", 20));
+        // statements: evaluated for their completion value
+        assertThat(language.createExpression("var x = body; x = x + 1; 
x").evaluate(exchange, Integer.class))
+                .isEqualTo(21);
+        assertThat(language.createExpression("body + 1;").evaluate(exchange, 
Integer.class)).isEqualTo(21);
+        assertThat(language.createExpression("let y = 2; body * 
y").evaluate(exchange, Integer.class)).isEqualTo(40);
+    }
+
+    @Test
+    void compiledScriptsAreCachedPerEngineAndBounded() {
+        QuickjsLanguage quickjs = (QuickjsLanguage) language;
+        Exchange exchange = exchange(1);
+        String script = "body + 1000";
+        for (int i = 0; i < 3; i++) {
+            assertThat(language.createExpression(script).evaluate(exchange, 
Integer.class)).isEqualTo(1001);
+        }
+        assertThat(quickjs.compiledScriptCount()).isPositive();
+        int before = quickjs.compiledScriptCount();
+        language.createExpression(script).evaluate(exchange, Integer.class);
+        assertThat(quickjs.compiledScriptCount()).isEqualTo(before);
+        for (int i = 0; i < QuickjsLanguage.COMPILED_SCRIPTS_PER_ENGINE + 50; 
i++) {
+            language.createExpression("body + " + i).evaluate(exchange, 
Integer.class);
+        }
+        
assertThat(quickjs.compiledScriptCount()).isEqualTo(QuickjsLanguage.COMPILED_SCRIPTS_PER_ENGINE);
+    }
+}
diff --git 
a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsErrorHandlingTest.java
 
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsErrorHandlingTest.java
index 417ce92ab376..6f425fe2ca7e 100644
--- 
a/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsErrorHandlingTest.java
+++ 
b/components/camel-quickjs/src/test/java/org/apache/camel/language/quickjs/QuickjsErrorHandlingTest.java
@@ -59,8 +59,14 @@ class QuickjsErrorHandlingTest {
     @Test
     void invalidJavaScriptIsCamelException() {
         Exchange exchange = exchange();
+        // a script that does not parse, as an expression or as statements, is 
reported before anything runs
         assertThatThrownBy(() -> language().createExpression("function 
{{{").evaluate(exchange, Object.class))
-                .isInstanceOfAny(ExpressionIllegalSyntaxException.class, 
ExpressionEvaluationException.class);
+                .isInstanceOf(ExpressionIllegalSyntaxException.class);
+        assertThatThrownBy(() -> language().createExpression("var a = 1; a +* 
1").evaluate(exchange, Object.class))
+                .isInstanceOf(ExpressionIllegalSyntaxException.class);
+        // and the engine is still usable afterwards
+        assertThatThrownBy(() -> language().createExpression("body +* 
1").evaluate(exchange, Object.class))
+                .isInstanceOf(ExpressionIllegalSyntaxException.class);
     }
 
     @Test

Reply via email to