]> git.basschouten.com Git - openhab-addons.git/blob
7c1c8704408fc35fc3793f487d900b39ed3dc709
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.automation.jsscripting.internal;
14
15 import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;
16
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;
31 import java.util.Map;
32 import java.util.Set;
33 import java.util.concurrent.locks.Lock;
34 import java.util.concurrent.locks.ReentrantLock;
35 import java.util.function.Consumer;
36 import java.util.function.Function;
37
38 import javax.script.ScriptContext;
39 import javax.script.ScriptException;
40
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.graalvm.polyglot.Context;
43 import org.graalvm.polyglot.Engine;
44 import org.graalvm.polyglot.HostAccess;
45 import org.graalvm.polyglot.Value;
46 import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
47 import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
48 import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
49 import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
50 import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable;
51 import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
54
55 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
56
57 /**
58  * GraalJS ScriptEngine implementation
59  *
60  * @author Jonathan Gilbert - Initial contribution
61  * @author Dan Cunningham - Script injections
62  * @author Florian Hotze - Create lock object for multi-thread synchronization; Inject the {@link JSRuntimeFeatures}
63  *         into the JS context; Fix memory leak caused by HostObject by making HostAccess reference static; Switch to
64  *         {@link Lock} for multi-thread synchronization
65  */
66 public class OpenhabGraalJSScriptEngine
67         extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
68
69     private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
70     private static final String GLOBAL_REQUIRE = "require(\"@jsscripting-globals\");";
71     private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
72     /** Final CommonJS search path for our library */
73     private static final Path NODE_DIR = Paths.get("node_modules");
74     /** Provides unlimited host access as well as custom translations from JS to Java Objects */
75     private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL)
76             // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
77             .targetTypeMapping(Value.class, ZonedDateTime.class, (v) -> v.hasMember("withFixedOffsetZone"), v -> {
78                 return ZonedDateTime.parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString());
79             }, HostAccess.TargetMappingPrecedence.LOW)
80
81             // Translate JS-Joda Duration to java.time.Duration
82             .targetTypeMapping(Value.class, Duration.class,
83                     // picking two members to check as Duration has many common function names
84                     (v) -> v.hasMember("minusDuration") && v.hasMember("toNanos"), v -> {
85                         return Duration.ofNanos(v.invokeMember("toNanos").asLong());
86                     }, HostAccess.TargetMappingPrecedence.LOW)
87             .build();
88
89     /** {@link Lock} synchronization of multi-thread access */
90     private final Lock lock = new ReentrantLock();
91     private final JSRuntimeFeatures jsRuntimeFeatures;
92
93     // these fields start as null because they are populated on first use
94     private String engineIdentifier;
95     private @Nullable Consumer<String> scriptDependencyListener;
96
97     private boolean initialized = false;
98     private final String globalScript;
99
100     /**
101      * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
102      * lifecycle and provides hooks for scripts to do so too.
103      */
104     public OpenhabGraalJSScriptEngine(@Nullable String injectionCode, JSScriptServiceUtil jsScriptServiceUtil) {
105         super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
106         this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
107         this.jsRuntimeFeatures = jsScriptServiceUtil.getJSRuntimeFeatures(lock);
108
109         LOGGER.debug("Initializing GraalJS script engine...");
110
111         delegate = GraalJSScriptEngine.create(
112                 Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
113                         .build(),
114                 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
115                         .allowHostAccess(HOST_ACCESS).option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
116                         .option("js.nashorn-compat", "true") // Enable Nashorn compat mode as openhab-js relies on
117                                                              // accessors, see
118                                                              // https://github.com/oracle/graaljs/blob/master/docs/user/NashornMigrationGuide.md#accessors
119                         .option("js.ecmascript-version", "2022") // If Nashorn compat is enabled, it will enforce ES5
120                                                                  // compatibility, we want ECMA2022
121                         .option("js.commonjs-require", "true") // Enable CommonJS module support
122                         .hostClassLoader(getClass().getClassLoader())
123                         .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
124                             @Override
125                             public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
126                                     FileAttribute<?>... attrs) throws IOException {
127                                 Consumer<String> localScriptDependencyListener = scriptDependencyListener;
128                                 if (localScriptDependencyListener != null) {
129                                     localScriptDependencyListener.accept(path.toString());
130                                 }
131
132                                 if (path.toString().endsWith(".js")) {
133                                     SeekableByteChannel sbc = null;
134                                     if (isRootNodePath(path)) {
135                                         InputStream is = getClass().getResourceAsStream(nodeFileToResource(path));
136                                         if (is == null) {
137                                             throw new IOException("Could not read " + path.toString());
138                                         }
139                                         sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
140                                     } else {
141                                         sbc = super.newByteChannel(path, options, attrs);
142                                     }
143                                     return new PrefixedSeekableByteChannel(
144                                             ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
145                                 } else {
146                                     return super.newByteChannel(path, options, attrs);
147                                 }
148                             }
149
150                             @Override
151                             public void checkAccess(Path path, Set<? extends AccessMode> modes,
152                                     LinkOption... linkOptions) throws IOException {
153                                 if (isRootNodePath(path)) {
154                                     if (getClass().getResource(nodeFileToResource(path)) == null) {
155                                         throw new NoSuchFileException(path.toString());
156                                     }
157                                 } else {
158                                     super.checkAccess(path, modes, linkOptions);
159                                 }
160                             }
161
162                             @Override
163                             public Map<String, Object> readAttributes(Path path, String attributes,
164                                     LinkOption... options) throws IOException {
165                                 if (isRootNodePath(path)) {
166                                     return Collections.singletonMap("isRegularFile", true);
167                                 }
168                                 return super.readAttributes(path, attributes, options);
169                             }
170
171                             @Override
172                             public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
173                                 if (isRootNodePath(path)) {
174                                     return path;
175                                 }
176                                 return super.toRealPath(path, linkOptions);
177                             }
178                         }));
179     }
180
181     @Override
182     protected void beforeInvocation() {
183         super.beforeInvocation();
184
185         lock.lock();
186
187         if (initialized) {
188             return;
189         }
190
191         ScriptContext ctx = delegate.getContext();
192         if (ctx == null) {
193             throw new IllegalStateException("Failed to retrieve script context");
194         }
195
196         // these are added post-construction, so we need to fetch them late
197         this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
198         if (this.engineIdentifier == null) {
199             throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
200         }
201
202         ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
203                 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
204         if (scriptExtensionAccessor == null) {
205             throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
206         }
207
208         scriptDependencyListener = (Consumer<String>) ctx
209                 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
210         if (scriptDependencyListener == null) {
211             LOGGER.warn(
212                     "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
213         }
214
215         ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
216                 scriptExtensionAccessor, lock);
217
218         Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
219                 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
220                 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
221
222         delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
223         // Injections into the JS runtime
224         delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
225         jsRuntimeFeatures.getFeatures().forEach((key, obj) -> {
226             LOGGER.debug("Injecting {} into the JS runtime...", key);
227             delegate.put(key, obj);
228         });
229
230         initialized = true;
231
232         try {
233             eval(globalScript);
234         } catch (ScriptException e) {
235             LOGGER.error("Could not inject global script", e);
236         }
237     }
238
239     @Override
240     protected Object afterInvocation(Object obj) {
241         lock.unlock();
242         return super.afterInvocation(obj);
243     }
244
245     @Override
246     protected Exception afterThrowsInvocation(Exception e) {
247         lock.unlock();
248         return super.afterThrowsInvocation(e);
249     }
250
251     @Override
252     public void close() {
253         jsRuntimeFeatures.close();
254     }
255
256     /**
257      * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc...
258      *
259      * @param path a root path
260      * @return whether the given path is a node root directory
261      */
262     private boolean isRootNodePath(Path path) {
263         return path.startsWith(path.getRoot().resolve(NODE_DIR));
264     }
265
266     /**
267      * Converts a root node path to a class resource path for loading local modules
268      * Ex: C:\node_modules\foo.js -> /node_modules/foo.js
269      *
270      * @param path a root path, e.g. C:\node_modules\foo.js
271      * @return the class resource path for loading local modules
272      */
273     private String nodeFileToResource(Path path) {
274         return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');
275     }
276 }