package de.opcua.app.scripting; import de.opcua.app.opc.OpcUaService; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.HostAccess; import org.graalvm.polyglot.Value; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; /** * GraalVM-based JavaScript engine. * * Important: GraalVM Context is not thread-safe. Therefore every event/script * execution receives its own isolated Context. This allows parallel execution * where every OPC-UA event/action can run in its own thread without overwriting * global JavaScript variables from another event. */ public class GraalScriptEngine { private final OpcUaService opcService; private final Store store; private final RestClient restClient; private final Map globalBindings; private volatile boolean availabilityChecked = false; private volatile boolean available = false; public GraalScriptEngine(OpcUaService opcService, Store store) { this.opcService = opcService; this.store = store; this.restClient = new RestClient(); this.globalBindings = new ConcurrentHashMap<>(); } private Context createContext() { Context context = Context.newBuilder("js") .allowHostAccess(HostAccess.ALL) .allowIO(true) .option("js.ecmascript-version", "2022") .build(); initializeContext(context); return context; } private void initializeContext(Context context) { var bindings = context.getBindings("js"); bindings.putMember("opc", new OpcUaFacade(opcService)); bindings.putMember("rest", restClient); bindings.putMember("store", store); bindings.putMember("console", new Console()); bindings.putMember("sleep", new SleepFunction()); for (Map.Entry entry : globalBindings.entrySet()) { bindings.putMember(entry.getKey(), entry.getValue()); } } public boolean isAvailable() { if (availabilityChecked) return available; synchronized (this) { if (availabilityChecked) return available; try (Context ignored = createContext()) { available = true; System.out.println("[GraalVM] JavaScript engine ready"); } catch (Exception e) { available = false; System.err.println("[GraalVM] Failed to initialize: " + e.getMessage()); System.err.println("[GraalVM] Make sure GraalVM JS dependencies are on the classpath."); } finally { availabilityChecked = true; } return available; } } /** Execute JavaScript code without extra per-event bindings. */ public Object execute(String script) { return execute(script, Map.of()); } /** Execute JavaScript code with event-specific bindings. */ public Object execute(String script, Map bindings) { if (!isAvailable()) { throw new ScriptExecutionException( "GraalVM JavaScript engine not available. Make sure graalvm-js dependencies are on classpath.", null ); } try (Context context = createContext()) { putBindings(context, bindings); Value result = context.eval("js", script == null ? "" : script); return convertValueToJava(result); } catch (Exception e) { throw new ScriptExecutionException("Script execution failed", e); } } /** Evaluate a JavaScript condition and convert the result to boolean semantics. */ public boolean executeCondition(String conditionScript, Map bindings) { Object result = execute(conditionScript, bindings); return toBoolean(result); } /** Execute script asynchronously. */ public CompletableFuture executeAsync(String script) { return CompletableFuture.supplyAsync(() -> execute(script)); } public CompletableFuture executeAsync(String script, Map bindings) { return CompletableFuture.supplyAsync(() -> execute(script, bindings)); } /** Bind a Java object as a global default for all future event contexts. */ public void bind(String name, Object value) { if (name == null || name.isBlank()) return; globalBindings.put(name, value); } /** Get a global binding. Event-local bindings are intentionally not retained. */ public Object get(String name) { return globalBindings.get(name); } private void putBindings(Context context, Map bindings) { if (bindings == null) return; var jsBindings = context.getBindings("js"); for (Map.Entry entry : bindings.entrySet()) { String key = entry.getKey(); if (key == null || key.isBlank()) continue; jsBindings.putMember(key, entry.getValue()); } } private Object convertValueToJava(Value value) { if (value == null || value.isNull()) return null; if (value.isBoolean()) return value.asBoolean(); if (value.isNumber()) { if (value.fitsInInt()) return value.asInt(); if (value.fitsInLong()) return value.asLong(); return value.asDouble(); } if (value.isString()) return value.asString(); if (value.hasArrayElements()) { long size = value.getArraySize(); Object[] array = new Object[(int) size]; for (int i = 0; i < size; i++) { array[i] = convertValueToJava(value.getArrayElement(i)); } return array; } if (value.isHostObject()) return value.asHostObject(); return value.toString(); } private boolean toBoolean(Object result) { if (result == null) return false; if (result instanceof Boolean b) return b; if (result instanceof Number n) return n.doubleValue() != 0.0d; String s = result.toString().trim(); if (s.isEmpty()) return false; if ("true".equalsIgnoreCase(s)) return true; if ("false".equalsIgnoreCase(s)) return false; try { return Double.parseDouble(s) != 0.0d; } catch (NumberFormatException ignored) { return true; } } public void close() { globalBindings.clear(); } public static class OpcUaFacade { private final OpcUaService opcService; public OpcUaFacade(OpcUaService opcService) { this.opcService = opcService; } public String read(String nodeId) { try { return opcService.readValue(nodeId).get(); } catch (Exception e) { throw new RuntimeException("Failed to read node: " + nodeId, e); } } public boolean write(String nodeId, Object value) { try { return opcService.writeValue(nodeId, value).get(); } catch (Exception e) { throw new RuntimeException("Failed to write node: " + nodeId, e); } } public Map readMultiple(String[] nodeIds) { Map results = new HashMap<>(); if (nodeIds == null) return results; for (String nodeId : nodeIds) { try { results.put(nodeId, read(nodeId)); } catch (Exception e) { results.put(nodeId, ""); } } return results; } public Map readMultiple(List nodeIds) { Map results = new HashMap<>(); if (nodeIds == null) return results; for (String nodeId : nodeIds) { try { results.put(nodeId, read(nodeId)); } catch (Exception e) { results.put(nodeId, ""); } } return results; } public boolean writeMultiple(Map valuesByNodeId) { if (valuesByNodeId == null) return true; boolean ok = true; for (Map.Entry entry : valuesByNodeId.entrySet()) { ok &= write(entry.getKey(), entry.getValue()); } return ok; } public boolean isConnected() { return opcService.isConnected(); } } public static class RestClient { private final HttpClient httpClient; public RestClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } public RestResponse get(String url) { return get(url, null); } public RestResponse get(String url, Map headers) { try { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).GET(); if (headers != null) headers.forEach(builder::header); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); return new RestResponse(response.statusCode(), response.body(), response.headers().map()); } catch (Exception e) { throw new RuntimeException("GET request failed: " + url, e); } } public RestResponse post(String url, String body) { return post(url, body, null); } public RestResponse post(String url, String body, Map headers) { try { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(url)) .POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)) .header("Content-Type", "application/json"); if (headers != null) headers.forEach(builder::header); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); return new RestResponse(response.statusCode(), response.body(), response.headers().map()); } catch (Exception e) { throw new RuntimeException("POST request failed: " + url, e); } } public RestResponse put(String url, String body) { return put(url, body, null); } public RestResponse put(String url, String body, Map headers) { try { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(url)) .PUT(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)) .header("Content-Type", "application/json"); if (headers != null) headers.forEach(builder::header); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); return new RestResponse(response.statusCode(), response.body(), response.headers().map()); } catch (Exception e) { throw new RuntimeException("PUT request failed: " + url, e); } } public RestResponse delete(String url) { return delete(url, null); } public RestResponse delete(String url, Map headers) { try { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE(); if (headers != null) headers.forEach(builder::header); HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); return new RestResponse(response.statusCode(), response.body(), response.headers().map()); } catch (Exception e) { throw new RuntimeException("DELETE request failed: " + url, e); } } } public static class RestResponse { private final int status; private final String body; private final Map> headers; public RestResponse(int status, String body, Map> headers) { this.status = status; this.body = body; this.headers = headers; } public int getStatus() { return status; } public String getBody() { return body; } public Map> getHeaders() { return headers; } public String json() { return body; } public boolean isOk() { return status >= 200 && status < 300; } } public static class Console { public void log(Object... messages) { StringBuilder sb = new StringBuilder("[JS] "); for (Object msg : messages) sb.append(msg).append(" "); System.out.println(sb.toString().trim()); } public void error(Object... messages) { StringBuilder sb = new StringBuilder("[JS ERROR] "); for (Object msg : messages) sb.append(msg).append(" "); System.err.println(sb.toString().trim()); } public void warn(Object... messages) { StringBuilder sb = new StringBuilder("[JS WARN] "); for (Object msg : messages) sb.append(msg).append(" "); System.out.println(sb.toString().trim()); } } public static class SleepFunction { public void sleep(long milliseconds) { try { Thread.sleep(milliseconds); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public static class ScriptExecutionException extends RuntimeException { public ScriptExecutionException(String message, Throwable cause) { super(message, cause); } } }