2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.automation.jsscripting.internal;
15 import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;
18 import java.io.IOException;
19 import java.io.InputStream;
20 import java.nio.channels.SeekableByteChannel;
21 import java.nio.file.AccessMode;
22 import java.nio.file.FileSystems;
23 import java.nio.file.LinkOption;
24 import java.nio.file.NoSuchFileException;
25 import java.nio.file.OpenOption;
26 import java.nio.file.Path;
27 import java.nio.file.Paths;
28 import java.nio.file.attribute.FileAttribute;
29 import java.time.Duration;
30 import java.time.ZonedDateTime;
31 import java.util.Collections;
34 import java.util.function.Consumer;
35 import java.util.function.Function;
37 import javax.script.ScriptContext;
38 import javax.script.ScriptException;
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.graalvm.polyglot.Context;
43 import org.graalvm.polyglot.Engine;
44 import org.graalvm.polyglot.HostAccess;
45 import org.graalvm.polyglot.Value;
46 import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
47 import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
48 import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
49 import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
50 import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocable;
51 import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
58 * GraalJS Script Engine implementation
60 * @author Jonathan Gilbert - Initial contribution
61 * @author Dan Cunningham - Script injections
63 public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngineWithInvocable<GraalJSScriptEngine> {
65 private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);
66 private static final String GLOBAL_REQUIRE = "require(\"@jsscripting-globals\");";
67 private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
68 // final CommonJS search path for our library
69 private static final Path LOCAL_NODE_PATH = Paths.get(File.separator + "node_modules");
71 // these fields start as null because they are populated on first use
72 private @NonNullByDefault({}) String engineIdentifier;
73 private @NonNullByDefault({}) Consumer<String> scriptDependencyListener;
75 private boolean initialized = false;
76 private String globalScript;
79 * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
80 * lifecycle and provides hooks for scripts to do so too.
82 public OpenhabGraalJSScriptEngine(@Nullable String injectionCode) {
83 super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
84 this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
86 // Custom translate JS Objects - > Java Objects
87 HostAccess hostAccess = HostAccess.newBuilder(HostAccess.ALL)
88 // Translate JS-Joda ZonedDateTime to java.time.ZonedDateTime
89 .targetTypeMapping(Value.class, ZonedDateTime.class, (v) -> v.hasMember("withFixedOffsetZone"), v -> {
91 .parse(v.invokeMember("withFixedOffsetZone").invokeMember("toString").asString());
92 }, HostAccess.TargetMappingPrecedence.LOW)
94 // Translate JS-Joda Duration to java.time.Duration
95 .targetTypeMapping(Value.class, Duration.class,
96 // picking two members to check as Duration has many common function names
97 (v) -> v.hasMember("minusDuration") && v.hasMember("toNanos"), v -> {
98 return Duration.ofNanos(v.invokeMember("toNanos").asLong());
99 }, HostAccess.TargetMappingPrecedence.LOW)
102 delegate = GraalJSScriptEngine.create(
103 Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
105 Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true).allowHostAccess(hostAccess)
106 .option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
107 .option("js.nashorn-compat", "true") // to ease migration
108 .option("js.ecmascript-version", "2021") // nashorn compat will enforce es5 compatibility, we
110 .option("js.commonjs-require", "true") // enable CommonJS module support
111 .hostClassLoader(getClass().getClassLoader())
112 .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
114 public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options,
115 FileAttribute<?>... attrs) throws IOException {
116 if (scriptDependencyListener != null) {
117 scriptDependencyListener.accept(path.toString());
119 if (path.toString().endsWith(".js")) {
120 SeekableByteChannel sbc = null;
121 if (path.startsWith(LOCAL_NODE_PATH)) {
122 InputStream is = getClass().getResourceAsStream(path.toString());
124 throw new IOException("Could not read " + path.toString());
126 sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
128 sbc = super.newByteChannel(path, options, attrs);
130 return new PrefixedSeekableByteChannel(
131 ("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
133 return super.newByteChannel(path, options, attrs);
138 public void checkAccess(Path path, Set<? extends AccessMode> modes,
139 LinkOption... linkOptions) throws IOException {
140 if (path.startsWith(LOCAL_NODE_PATH)) {
141 if (getClass().getResource(path.toString()) == null) {
142 throw new NoSuchFileException(path.toString());
145 super.checkAccess(path, modes, linkOptions);
150 public Map<String, Object> readAttributes(Path path, String attributes,
151 LinkOption... options) throws IOException {
152 if (path.startsWith(LOCAL_NODE_PATH)) {
153 return Collections.singletonMap("isRegularFile", true);
155 return super.readAttributes(path, attributes, options);
159 public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
160 if (path.startsWith(LOCAL_NODE_PATH)) {
163 return super.toRealPath(path, linkOptions);
169 protected void beforeInvocation() {
174 ScriptContext ctx = delegate.getContext();
176 // these are added post-construction, so we need to fetch them late
177 this.engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
178 if (this.engineIdentifier == null) {
179 throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
182 ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
183 .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
184 if (scriptExtensionAccessor == null) {
185 throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
188 scriptDependencyListener = (Consumer<String>) ctx
189 .getAttribute("oh.dependency-listener"/* CONTEXT_KEY_DEPENDENCY_LISTENER */);
190 if (scriptDependencyListener == null) {
192 "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
195 ScriptExtensionModuleProvider scriptExtensionModuleProvider = new ScriptExtensionModuleProvider(
196 scriptExtensionAccessor);
198 Function<Function<Object[], Object>, Function<String, Object>> wrapRequireFn = originalRequireFn -> moduleName -> scriptExtensionModuleProvider
199 .locatorFor(delegate.getPolyglotContext(), engineIdentifier).locateModule(moduleName)
200 .map(m -> (Object) m).orElseGet(() -> originalRequireFn.apply(new Object[] { moduleName }));
202 delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(REQUIRE_WRAPPER_NAME, wrapRequireFn);
203 delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));
209 } catch (ScriptException e) {
210 LOGGER.error("Could not inject global script", e);