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