2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.automation.jsscripting.internal;
15 import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;
17 import java.io.IOException;
18 import java.io.InputStream;
19 import java.nio.channels.SeekableByteChannel;
20 import java.nio.file.AccessMode;
21 import java.nio.file.FileSystems;
22 import java.nio.file.LinkOption;
23 import java.nio.file.NoSuchFileException;
24 import java.nio.file.OpenOption;
25 import java.nio.file.Path;
26 import java.nio.file.Paths;
27 import java.nio.file.attribute.FileAttribute;
28 import java.time.Duration;
29 import java.time.ZonedDateTime;
30 import java.util.Collections;
33 import java.util.function.Consumer;
34 import java.util.function.Function;
36 import javax.script.ScriptContext;
37 import javax.script.ScriptException;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.graalvm.polyglot.Context;
41 import org.graalvm.polyglot.Engine;
42 import org.graalvm.polyglot.HostAccess;
43 import org.graalvm.polyglot.Value;
44 import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
45 import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
46 import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
47 import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
48 import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable;
49 import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
56 * GraalJS ScriptEngine implementation
58 * @author Jonathan Gilbert - Initial contribution
59 * @author Dan Cunningham - Script injections
60 * @author Florian Hotze - Create lock object for multi-thread synchronization; Inject the {@link JSRuntimeFeatures}
61 * into the JS context; Fix memory leak caused by HostObject by making HostAccess reference static
63 public class OpenhabGraalJSScriptEngine
64 extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
66 private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
67 private static final String GLOBAL_REQUIRE = "require(\"@jsscripting-globals\");";
68 private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
69 /** Final CommonJS search path for our library */
70 private static final Path NODE_DIR = Paths.get("node_modules");
71 /** Provides unlimited host access as well as custom translations from JS to Java Objects */
72 private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL)
73 // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
74 .targetTypeMapping(Value.class, ZonedDateTime.class, (v) -> v.hasMember("withFixedOffsetZone"), v -> {
75 return ZonedDateTime.parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString());
76 }, HostAccess.TargetMappingPrecedence.LOW)
78 // Translate JS-Joda Duration to java.time.Duration
79 .targetTypeMapping(Value.class, Duration.class,
80 // picking two members to check as Duration has many common function names
81 (v) -> v.hasMember("minusDuration") && v.hasMember("toNanos"), v -> {
82 return Duration.ofNanos(v.invokeMember("toNanos").asLong());
83 }, HostAccess.TargetMappingPrecedence.LOW)
86 /** Shared lock object for synchronization of multi-thread access */
87 private final Object lock = new Object();
88 private final JSRuntimeFeatures jsRuntimeFeatures;
90 // these fields start as null because they are populated on first use
91 private String engineIdentifier;
92 private Consumer<String> scriptDependencyListener;
94 private boolean initialized = false;
95 private final String globalScript;
98 * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
99 * lifecycle and provides hooks for scripts to do so too.
101 public OpenhabGraalJSScriptEngine(@Nullable String injectionCode, JSScriptServiceUtil jsScriptServiceUtil) {
102 super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
103 this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
104 this.jsRuntimeFeatures = jsScriptServiceUtil.getJSRuntimeFeatures(lock);
106 LOGGER.debug("Initializing GraalJS script engine...");
108 delegate = GraalJSScriptEngine.create(
109 Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
111 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
112 .allowHostAccess(HOST_ACCESS).option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
113 .option("js.nashorn-compat", "true") // to ease migration
114 .option("js.ecmascript-version", "2021") // nashorn compat will enforce es5 compatibility, we
116 .option("js.commonjs-require", "true") // enable CommonJS module support
117 .hostClassLoader(getClass().getClassLoader())
118 .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
120 public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
121 FileAttribute<?>... attrs) throws IOException {
122 if (scriptDependencyListener != null) {
123 scriptDependencyListener.accept(path.toString());
126 if (path.toString().endsWith(".js")) {
127 SeekableByteChannel sbc = null;
128 if (isRootNodePath(path)) {
129 InputStream is = getClass().getResourceAsStream(nodeFileToResource(path));
131 throw new IOException("Could not read " + path.toString());
133 sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
135 sbc = super.newByteChannel(path, options, attrs);
137 return new PrefixedSeekableByteChannel(
138 ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
140 return super.newByteChannel(path, options, attrs);
145 public void checkAccess(Path path, Set<? extends AccessMode> modes,
146 LinkOption... linkOptions) throws IOException {
147 if (isRootNodePath(path)) {
148 if (getClass().getResource(nodeFileToResource(path)) == null) {
149 throw new NoSuchFileException(path.toString());
152 super.checkAccess(path, modes, linkOptions);
157 public Map<String, Object> readAttributes(Path path, String attributes,
158 LinkOption... options) throws IOException {
159 if (isRootNodePath(path)) {
160 return Collections.singletonMap("isRegularFile", true);
162 return super.readAttributes(path, attributes, options);
166 public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
167 if (isRootNodePath(path)) {
170 return super.toRealPath(path, linkOptions);
176 protected void beforeInvocation() {
181 ScriptContext ctx = delegate.getContext();
183 throw new IllegalStateException("Failed to retrieve script context");
186 // these are added post-construction, so we need to fetch them late
187 this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
188 if (this.engineIdentifier == null) {
189 throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
192 ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
193 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
194 if (scriptExtensionAccessor == null) {
195 throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
198 scriptDependencyListener = (Consumer<String>) ctx
199 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
200 if (scriptDependencyListener == null) {
202 "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
205 ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
206 scriptExtensionAccessor, lock);
208 Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
209 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
210 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
212 delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
213 // Injections into the JS runtime
214 delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
215 jsRuntimeFeatures.getFeatures().forEach((key, obj) -> {
216 LOGGER.debug("Injecting {} into the JS runtime...", key);
217 delegate.put(key, obj);
224 } catch (ScriptException e) {
225 LOGGER.error("Could not inject global script", e);
230 public Object invokeFunction(String s, Object... objects) throws ScriptException, NoSuchMethodException {
231 // Synchronize multi-thread access to avoid exceptions when reloading a script file while the script is running
232 synchronized (lock) {
233 return super.invokeFunction(s, objects);
238 public void close() {
239 jsRuntimeFeatures.close();
243 * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc...
245 * @param path a root path
246 * @return whether the given path is a node root directory
248 private boolean isRootNodePath(Path path) {
249 return path.startsWith(path.getRoot().resolve(NODE_DIR));
253 * Converts a root node path to a class resource path for loading local modules
254 * Ex: C:\node_modules\foo.js -> /node_modules/foo.js
256 * @param path a root path, e.g. C:\node_modules\foo.js
257 * @return the class resource path for loading local modules
259 private String nodeFileToResource(Path path) {
260 return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');