]> git.basschouten.com Git - openhab-addons.git/blob
f810e2375e7b2b28a92132214bd41a062a56fe73
[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.function.Consumer;
34 import java.util.function.Function;
35
36 import javax.script.ScriptContext;
37 import javax.script.ScriptException;
38
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;
52
53 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
54
55 /**
56  * GraalJS Script Engine implementation
57  *
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
62  */
63 public class OpenhabGraalJSScriptEngine
64         extends InvocationInterceptingScriptEngineWithInvocableAndAutoCloseable<GraalJSScriptEngine> {
65
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
72     // shared lock object for synchronization of multi-thread access
73     private final Object lock = new Object();
74     private final JSRuntimeFeatures jsRuntimeFeatures = new JSRuntimeFeatures(lock);
75
76     // these fields start as null because they are populated on first use
77     private String engineIdentifier;
78     private Consumer<String> scriptDependencyListener;
79
80     private boolean initialized = false;
81     private String globalScript;
82
83     /**
84      * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
85      * lifecycle and provides hooks for scripts to do so too.
86      */
87     public OpenhabGraalJSScriptEngine(@Nullable String injectionCode) {
88         super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
89         this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
90
91         LOGGER.debug("Initializing GraalJS script engine...");
92
93         // Custom translate JS Objects - > Java Objects
94         HostAccess hostAccess = HostAccess.newBuilder(HostAccess.ALL)
95                 // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
96                 .targetTypeMapping(Value.class, ZonedDateTime.class, (v) -> v.hasMember("withFixedOffsetZone"), v -> {
97                     return ZonedDateTime
98                             .parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString());
99                 }, HostAccess.TargetMappingPrecedence.LOW)
100
101                 // Translate JS-Joda Duration to java.time.Duration
102                 .targetTypeMapping(Value.class, Duration.class,
103                         // picking two members to check as Duration has many common function names
104                         (v) -> v.hasMember("minusDuration") && v.hasMember("toNanos"), v -> {
105                             return Duration.ofNanos(v.invokeMember("toNanos").asLong());
106                         }, HostAccess.TargetMappingPrecedence.LOW)
107                 .build();
108
109         delegate = GraalJSScriptEngine.create(
110                 Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
111                         .build(),
112                 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true).allowHostAccess(hostAccess)
113                         .option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
114                         .option("js.nashorn-compat", "true") // to ease migration
115                         .option("js.ecmascript-version", "2021") // nashorn compat will enforce es5 compatibility, we
116                                                                  // want ecma2021
117                         .option("js.commonjs-require", "true") // enable CommonJS module support
118                         .hostClassLoader(getClass().getClassLoader())
119                         .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
120                             @Override
121                             public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
122                                     FileAttribute<?>... attrs) throws IOException {
123                                 if (scriptDependencyListener != null) {
124                                     scriptDependencyListener.accept(path.toString());
125                                 }
126
127                                 if (path.toString().endsWith(".js")) {
128                                     SeekableByteChannel sbc = null;
129                                     if (isRootNodePath(path)) {
130                                         InputStream is = getClass().getResourceAsStream(nodeFileToResource(path));
131                                         if (is == null) {
132                                             throw new IOException("Could not read " + path.toString());
133                                         }
134                                         sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
135                                     } else {
136                                         sbc = super.newByteChannel(path, options, attrs);
137                                     }
138                                     return new PrefixedSeekableByteChannel(
139                                             ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
140                                 } else {
141                                     return super.newByteChannel(path, options, attrs);
142                                 }
143                             }
144
145                             @Override
146                             public void checkAccess(Path path, Set<? extends AccessMode> modes,
147                                     LinkOption... linkOptions) throws IOException {
148                                 if (isRootNodePath(path)) {
149                                     if (getClass().getResource(nodeFileToResource(path)) == null) {
150                                         throw new NoSuchFileException(path.toString());
151                                     }
152                                 } else {
153                                     super.checkAccess(path, modes, linkOptions);
154                                 }
155                             }
156
157                             @Override
158                             public Map<String, Object> readAttributes(Path path, String attributes,
159                                     LinkOption... options) throws IOException {
160                                 if (isRootNodePath(path)) {
161                                     return Collections.singletonMap("isRegularFile", true);
162                                 }
163                                 return super.readAttributes(path, attributes, options);
164                             }
165
166                             @Override
167                             public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
168                                 if (isRootNodePath(path)) {
169                                     return path;
170                                 }
171                                 return super.toRealPath(path, linkOptions);
172                             }
173                         }));
174     }
175
176     @Override
177     protected void beforeInvocation() {
178         if (initialized) {
179             return;
180         }
181
182         ScriptContext ctx = delegate.getContext();
183
184         // these are added post-construction, so we need to fetch them late
185         this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
186         if (this.engineIdentifier == null) {
187             throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
188         }
189
190         ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
191                 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
192         if (scriptExtensionAccessor == null) {
193             throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
194         }
195
196         scriptDependencyListener = (Consumer<String>) ctx
197                 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
198         if (scriptDependencyListener == null) {
199             LOGGER.warn(
200                     "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
201         }
202
203         ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
204                 scriptExtensionAccessor, lock);
205
206         Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
207                 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
208                 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
209
210         delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
211         // Injections into the JS runtime
212         delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
213         jsRuntimeFeatures.getFeatures().forEach((key, obj) -> delegate.put(key, obj));
214
215         initialized = true;
216
217         try {
218             eval(globalScript);
219         } catch (ScriptException e) {
220             LOGGER.error("Could not inject global script", e);
221         }
222     }
223
224     @Override
225     public Object invokeFunction(String s, Object... objects) throws ScriptException, NoSuchMethodException {
226         // Synchronize multi-thread access to avoid exceptions when reloading a script file while the script is running
227         synchronized (lock) {
228             return super.invokeFunction(s, objects);
229         }
230     }
231
232     @Override
233     public void close() {
234         jsRuntimeFeatures.close();
235     }
236
237     /**
238      * Tests if this is a root node directory, `/node_modules`, `C:\node_modules`, etc...
239      *
240      * @param path a root path
241      * @return whether the given path is a node root directory
242      */
243     private boolean isRootNodePath(Path path) {
244         return path.startsWith(path.getRoot().resolve(NODE_DIR));
245     }
246
247     /**
248      * Converts a root node path to a class resource path for loading local modules
249      * Ex: C:\node_modules\foo.js -> /node_modules/foo.js
250      *
251      * @param path a root path, e.g. C:\node_modules\foo.js
252      * @return the class resource path for loading local modules
253      */
254     private String nodeFileToResource(Path path) {
255         return "/" + path.subpath(0, path.getNameCount()).toString().replace('\\', '/');
256     }
257 }