opcua-service/main/java/de/opcua/app/scripting/GraalScriptEngine.java
2026-05-11 19:40:18 +02:00

369 lines
14 KiB
Java

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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> bindings) {
Object result = execute(conditionScript, bindings);
return toBoolean(result);
}
/** Execute script asynchronously. */
public CompletableFuture<Object> executeAsync(String script) {
return CompletableFuture.supplyAsync(() -> execute(script));
}
public CompletableFuture<Object> executeAsync(String script, Map<String, Object> 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<String, Object> bindings) {
if (bindings == null) return;
var jsBindings = context.getBindings("js");
for (Map.Entry<String, Object> 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<String, String> readMultiple(String[] nodeIds) {
Map<String, String> results = new HashMap<>();
if (nodeIds == null) return results;
for (String nodeId : nodeIds) {
try {
results.put(nodeId, read(nodeId));
} catch (Exception e) {
results.put(nodeId, "<error: " + e.getMessage() + ">");
}
}
return results;
}
public Map<String, String> readMultiple(List<String> nodeIds) {
Map<String, String> results = new HashMap<>();
if (nodeIds == null) return results;
for (String nodeId : nodeIds) {
try {
results.put(nodeId, read(nodeId));
} catch (Exception e) {
results.put(nodeId, "<error: " + e.getMessage() + ">");
}
}
return results;
}
public boolean writeMultiple(Map<String, Object> valuesByNodeId) {
if (valuesByNodeId == null) return true;
boolean ok = true;
for (Map.Entry<String, Object> 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<String, String> headers) {
try {
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).GET();
if (headers != null) headers.forEach(builder::header);
HttpResponse<String> 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<String, String> 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<String> 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<String, String> 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<String> 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<String, String> headers) {
try {
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE();
if (headers != null) headers.forEach(builder::header);
HttpResponse<String> 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<String, java.util.List<String>> headers;
public RestResponse(int status, String body, Map<String, java.util.List<String>> headers) {
this.status = status;
this.body = body;
this.headers = headers;
}
public int getStatus() { return status; }
public String getBody() { return body; }
public Map<String, java.util.List<String>> 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);
}
}
}