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