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