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