2 * Copyright (c) 2010-2023 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.io.InputStreamReader;
20 import java.io.Reader;
21 import java.nio.channels.SeekableByteChannel;
22 import java.nio.file.AccessMode;
23 import java.nio.file.FileSystems;
24 import java.nio.file.LinkOption;
25 import java.nio.file.NoSuchFileException;
26 import java.nio.file.OpenOption;
27 import java.nio.file.Path;
28 import java.nio.file.Paths;
29 import java.nio.file.attribute.FileAttribute;
30 import java.time.Duration;
31 import java.time.Instant;
32 import java.time.ZonedDateTime;
33 import java.util.Collections;
36 import java.util.concurrent.locks.Lock;
37 import java.util.concurrent.locks.ReentrantLock;
38 import java.util.function.Consumer;
39 import java.util.function.Function;
41 import javax.script.ScriptContext;
42 import javax.script.ScriptException;
44 import org.eclipse.jdt.annotation.Nullable;
45 import org.graalvm.polyglot.Context;
46 import org.graalvm.polyglot.Engine;
47 import org.graalvm.polyglot.HostAccess;
48 import org.graalvm.polyglot.Source;
49 import org.graalvm.polyglot.Value;
50 import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
51 import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
52 import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
53 import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
54 import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable;
55 import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
56 import org.openhab.core.items.Item;
57 import org.openhab.core.library.types.QuantityType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
61 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
64 * GraalJS ScriptEngine implementation
66 * @author Jonathan Gilbert - Initial contribution
67 * @author Dan Cunningham - Script injections
68 * @author Florian Hotze - Create lock object for multi-thread synchronization; Inject the {@link JSRuntimeFeatures}
69 * into the JS context; Fix memory leak caused by HostObject by making HostAccess reference static; Switch to
70 * {@link Lock} for multi-thread synchronization; globals & openhab-js injection code caching
72 public class OpenhabGraalJSScriptEngine
73 extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
75 private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
76 private static Source GLOBAL_SOURCE;
79 GLOBAL_SOURCE = Source.newBuilder("js", getFileAsReader("node_modules/@jsscripting-globals.js"),
80 "@jsscripting-globals.js").cached(true).build();
81 } catch (IOException e) {
82 throw new RuntimeException("Failed to load @jsscripting-globals.js", e);
86 private static Source OPENHAB_JS_SOURCE;
89 OPENHAB_JS_SOURCE = Source
90 .newBuilder("js", getFileAsReader("node_modules/@openhab-globals.js"), "@openhab-globals.js")
91 .cached(true).build();
92 } catch (IOException e) {
93 throw new RuntimeException("Failed to load @openhab-globals.js", e);
96 private static final String OPENHAB_JS_INJECTION_CODE = "Object.assign(this, require('openhab'));";
98 private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
99 /** Final CommonJS search path for our library */
100 private static final Path NODE_DIR = Paths.get("node_modules");
101 /** Shared Polyglot {@link Engine} across all instances of {@link OpenhabGraalJSScriptEngine} */
102 private static final Engine ENGINE = Engine.newBuilder().allowExperimentalOptions(true)
103 .option("engine.WarnInterpreterOnly", "false").build();
104 /** Provides unlimited host access as well as custom translations from JS to Java Objects */
105 private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL)
106 // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
107 .targetTypeMapping(Value.class, ZonedDateTime.class, v -> v.hasMember("withFixedOffsetZone"),
108 v -> ZonedDateTime.parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString()),
109 HostAccess.TargetMappingPrecedence.LOW)
111 // Translate JS-Joda Duration to java.time.Duration
112 .targetTypeMapping(Value.class, Duration.class,
113 // picking two members to check as Duration has many common function names
114 v -> v.hasMember("minusDuration") && v.hasMember("toNanos"),
115 v -> Duration.ofNanos(v.invokeMember("toNanos").asLong()), HostAccess.TargetMappingPrecedence.LOW)
117 // Translate JS-Joda Instant to java.time.Instant
118 .targetTypeMapping(Value.class, Instant.class,
119 // picking two members to check as Instant has many common function names
120 v -> v.hasMember("toEpochMilli") && v.hasMember("epochSecond"),
121 v -> Instant.ofEpochMilli(v.invokeMember("toEpochMilli").asLong()),
122 HostAccess.TargetMappingPrecedence.LOW)
124 // Translate openhab-js Item to org.openhab.core.items.Item
125 .targetTypeMapping(Value.class, Item.class, v -> v.hasMember("rawItem"),
126 v -> v.getMember("rawItem").as(Item.class), HostAccess.TargetMappingPrecedence.LOW)
128 // Translate openhab-js Quantity to org.openhab.core.library.types.QuantityType
129 .targetTypeMapping(Value.class, QuantityType.class, v -> v.hasMember("raw") && v.hasMember("toUnit"),
130 v -> v.getMember("raw").as(QuantityType.class), HostAccess.TargetMappingPrecedence.LOW)
133 /** {@link Lock} synchronization of multi-thread access */
134 private final Lock lock = new ReentrantLock();
135 private final JSRuntimeFeatures jsRuntimeFeatures;
137 // these fields start as null because they are populated on first use
138 private String engineIdentifier;
139 private @Nullable Consumer<String> scriptDependencyListener;
141 private boolean initialized = false;
142 private final boolean injectionEnabled;
143 private final boolean useIncludedLibrary;
146 * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
147 * lifecycle and provides hooks for scripts to do so too.
149 public OpenhabGraalJSScriptEngine(boolean injectionEnabled, boolean useIncludedLibrary,
150 JSScriptServiceUtil jsScriptServiceUtil, JSDependencyTracker jsDependencyTracker) {
151 super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
152 this.injectionEnabled = injectionEnabled;
153 this.useIncludedLibrary = useIncludedLibrary;
154 this.jsRuntimeFeatures = jsScriptServiceUtil.getJSRuntimeFeatures(lock);
156 LOGGER.debug("Initializing GraalJS script engine...");
158 delegate = GraalJSScriptEngine.create(ENGINE,
159 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
160 .allowHostAccess(HOST_ACCESS)
161 .option("js.commonjs-require-cwd", jsDependencyTracker.getLibraryPath().toString())
162 .option("js.nashorn-compat", "true") // Enable Nashorn compat mode as openhab-js relies on
164 // https://github.com/oracle/graaljs/blob/master/docs/user/NashornMigrationGuide.md#accessors
165 .option("js.ecmascript-version", "2022") // If Nashorn compat is enabled, it will enforce ES5
166 // compatibility, we want ECMA2022
167 .option("js.commonjs-require", "true") // Enable CommonJS module support
168 .hostClassLoader(getClass().getClassLoader())
169 .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
171 public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
172 FileAttribute<?>... attrs) throws IOException {
173 Consumer<String> localScriptDependencyListener = scriptDependencyListener;
174 if (localScriptDependencyListener != null) {
175 localScriptDependencyListener.accept(path.toString());
178 if (path.toString().endsWith(".js")) {
179 SeekableByteChannel sbc = null;
180 if (isRootNodePath(path)) {
181 InputStream is = getClass().getResourceAsStream(nodeFileToResource(path));
183 throw new IOException("Could not read " + path.toString());
185 sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
187 sbc = super.newByteChannel(path, options, attrs);
189 return new PrefixedSeekableByteChannel(
190 ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
192 return super.newByteChannel(path, options, attrs);
197 public void checkAccess(Path path, Set<? extends AccessMode> modes,
198 LinkOption... linkOptions) throws IOException {
199 if (isRootNodePath(path)) {
200 if (getClass().getResource(nodeFileToResource(path)) == null) {
201 throw new NoSuchFileException(path.toString());
204 super.checkAccess(path, modes, linkOptions);
209 public Map<String, Object> readAttributes(Path path, String attributes,
210 LinkOption... options) throws IOException {
211 if (isRootNodePath(path)) {
212 return Collections.singletonMap("isRegularFile", true);
214 return super.readAttributes(path, attributes, options);
218 public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
219 if (isRootNodePath(path)) {
222 return super.toRealPath(path, linkOptions);
228 protected void beforeInvocation() {
229 super.beforeInvocation();
237 ScriptContext ctx = delegate.getContext();
239 throw new IllegalStateException("Failed to retrieve script context");
242 // these are added post-construction, so we need to fetch them late
243 this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
244 if (this.engineIdentifier == null) {
245 throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
248 ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
249 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
250 if (scriptExtensionAccessor == null) {
251 throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
254 scriptDependencyListener = (Consumer<String>) ctx
255 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
256 if (scriptDependencyListener == null) {
258 "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
261 ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
262 scriptExtensionAccessor, lock);
264 // Wrap the "require" function to also allow loading modules from the ScriptExtensionModuleProvider
265 Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
266 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
267 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
268 delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
269 delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
271 // Injections into the JS runtime
272 jsRuntimeFeatures.getFeatures().forEach((key, obj) -> {
273 LOGGER.debug("Injecting {} into the JS runtime...", key);
274 delegate.put(key, obj);
280 LOGGER.debug("Evaluating cached global script...");
281 delegate.getPolyglotContext().eval(GLOBAL_SOURCE);
282 if (this.injectionEnabled) {
283 if (this.useIncludedLibrary) {
284 LOGGER.debug("Evaluating cached openhab-js injection...");
285 delegate.getPolyglotContext().eval(OPENHAB_JS_SOURCE);
287 LOGGER.debug("Evaluating openhab-js injection from the file system...");
288 eval(OPENHAB_JS_INJECTION_CODE);
291 LOGGER.debug("Successfully initialized GraalJS script engine.");
292 } catch (ScriptException e) {
293 LOGGER.error("Could not inject global script", e);
298 protected Object afterInvocation(Object obj) {
300 return super.afterInvocation(obj);
304 protected Exception afterThrowsInvocation(Exception e) {
306 return super.afterThrowsInvocation(e);
310 public void close() {
311 jsRuntimeFeatures.close();
315 * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc...
317 * @param path a root path
318 * @return whether the given path is a node root directory
320 private boolean isRootNodePath(Path path) {
321 return path.startsWith(path.getRoot().resolve(NODE_DIR));
325 * Converts a root node path to a class resource path for loading local modules
326 * Ex: C:\node_modules\foo.js -> /node_modules/foo.js
328 * @param path a root path, e.g. C:\node_modules\foo.js
329 * @return the class resource path for loading local modules
331 private String nodeFileToResource(Path path) {
332 return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');
336 * @param fileName filename relative to the resources folder
337 * @return file as {@link InputStreamReader}
339 private static Reader getFileAsReader(String fileName) throws IOException {
340 InputStream ioStream = OpenhabGraalJSScriptEngine.class.getClassLoader().getResourceAsStream(fileName);
342 if (ioStream == null) {
343 throw new IOException(fileName + " not found");
346 return new InputStreamReader(ioStream);