]> git.basschouten.com Git - openhab-addons.git/blob
f4939baa0f08b6aa93ac588d50a5b765f59b094c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.util.Collections;
29 import java.util.Map;
30 import java.util.Set;
31 import java.util.function.Consumer;
32 import java.util.function.Function;
33
34 import javax.script.ScriptContext;
35 import javax.script.ScriptException;
36
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.graalvm.polyglot.Context;
40 import org.graalvm.polyglot.Engine;
41 import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
42 import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
43 import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
44 import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
45 import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocable;
46 import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
51
52 /**
53  * GraalJS Script Engine implementation
54  *
55  * @author Jonathan Gilbert - Initial contribution
56  * @author Dan Cunningham - Script injections
57  */
58 public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngineWithInvocable<GraalJSScriptEngine> {
59
60     private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
61     private static final String GLOBAL_REQUIRE = "require(\"@jsscripting-globals\");";
62     private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
63     // final CommonJS search path for our library
64     private static final Path LOCAL_NODE_PATH = Paths.get("/node_modules");
65
66     // these fields start as null because they are populated on first use
67     private @NonNullByDefault({}) String engineIdentifier;
68     private @NonNullByDefault({}) Consumer<String> scriptDependencyListener;
69
70     private boolean initialized = false;
71     private String globalScript;
72
73     /**
74      * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
75      * lifecycle and provides hooks for scripts to do so too.
76      */
77     public OpenhabGraalJSScriptEngine(@Nullable String injectionCode) {
78         super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
79         this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
80         delegate = GraalJSScriptEngine.create(
81                 Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
82                         .build(),
83                 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
84                         .option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
85                         .option("js.nashorn-compat", "true") // to ease migration
86                         .option("js.ecmascript-version", "2021") // nashorn compat will enforce es5 compatibility, we
87                                                                  // want ecma2021
88                         .option("js.commonjs-require", "true") // enable CommonJS module support
89                         .hostClassLoader(getClass().getClassLoader())
90                         .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
91                             @Override
92                             public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
93                                     FileAttribute<?>... attrs) throws IOException {
94                                 if (scriptDependencyListener != null) {
95                                     scriptDependencyListener.accept(path.toString());
96                                 }
97                                 if (path.toString().endsWith(".js")) {
98                                     SeekableByteChannel sbc = null;
99                                     if (path.startsWith(LOCAL_NODE_PATH)) {
100                                         InputStream is = getClass().getResourceAsStream(path.toString());
101                                         if (is == null) {
102                                             throw new IOException("Could not read " + path.toString());
103                                         }
104                                         sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
105                                     } else {
106                                         sbc = super.newByteChannel(path, options, attrs);
107                                     }
108                                     return new PrefixedSeekableByteChannel(
109                                             ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
110                                 } else {
111                                     return super.newByteChannel(path, options, attrs);
112                                 }
113                             }
114
115                             @Override
116                             public void checkAccess(Path path, Set<? extends AccessMode> modes,
117                                     LinkOption... linkOptions) throws IOException {
118                                 if (path.startsWith(LOCAL_NODE_PATH)) {
119                                     if (getClass().getResource(path.toString()) == null) {
120                                         throw new NoSuchFileException(path.toString());
121                                     }
122                                 } else {
123                                     super.checkAccess(path, modes, linkOptions);
124                                 }
125                             }
126
127                             @Override
128                             public Map<String, Object> readAttributes(Path path, String attributes,
129                                     LinkOption... options) throws IOException {
130                                 if (path.startsWith(LOCAL_NODE_PATH)) {
131                                     return Collections.singletonMap("isRegularFile", true);
132                                 }
133                                 return super.readAttributes(path, attributes, options);
134                             }
135
136                             @Override
137                             public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
138                                 if (path.startsWith(LOCAL_NODE_PATH)) {
139                                     return path;
140                                 }
141                                 return super.toRealPath(path, linkOptions);
142                             }
143                         }));
144     }
145
146     @Override
147     protected void beforeInvocation() {
148         if (initialized) {
149             return;
150         }
151
152         ScriptContext ctx = delegate.getContext();
153
154         // these are added post-construction, so we need to fetch them late
155         this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
156         if (this.engineIdentifier == null) {
157             throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
158         }
159
160         ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
161                 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
162         if (scriptExtensionAccessor == null) {
163             throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
164         }
165
166         scriptDependencyListener = (Consumer<String>) ctx
167                 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
168         if (scriptDependencyListener == null) {
169             LOGGER.warn(
170                     "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
171         }
172
173         ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
174                 scriptExtensionAccessor);
175
176         Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
177                 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
178                 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
179
180         delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
181         delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
182
183         initialized = true;
184
185         try {
186             eval(globalScript);
187         } catch (ScriptException e) {
188             LOGGER.error("Could not inject global script", e);
189         }
190     }
191 }