Skip to content

Commit

Permalink
[jsscripting] openhab-js integration (openhab#11656)
Browse files Browse the repository at this point in the history
Fixes openhab#11222

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Nick Waterton <n.waterton@outlook.com>
  • Loading branch information
digitaldan authored and NickWaterton committed Dec 30, 2021
1 parent f05ec01 commit 6578a58
Show file tree
Hide file tree
Showing 12 changed files with 811 additions and 10 deletions.
57 changes: 57 additions & 0 deletions bundles/org.openhab.automation.jsscripting/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<graal.version>21.3.0</graal.version>
<asm.version>6.2.1</asm.version>
<oh.version>${project.version}</oh.version>
<ohjs.version>openhab@0.0.1-beta.3</ohjs.version>
</properties>

<build>
Expand All @@ -44,6 +45,62 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.12.0</version>
<configuration>
<nodeVersion>v12.16.1</nodeVersion>
<workingDirectory>target/js</workingDirectory>
</configuration>
<executions>
<execution>
<id>Install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>generate-sources</phase>
</execution>
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install ${ohjs.version} webpack webpack-cli</arguments>
</configuration>
</execution>
<execution>
<id>npx webpack</id>
<goals>
<goal>npx</goal>
</goals>
<configuration>
<arguments>webpack -c ./node_modules/openhab/webpack.config.js --entry ./node_modules/openhab/ -o ./dist</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>add-resource</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<resources>
<resource>
<directory>target/js/dist</directory>
<targetPath>node_modules</targetPath>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,26 @@
import javax.script.ScriptEngine;

import org.openhab.core.automation.module.script.ScriptEngineFactory;
import org.openhab.core.config.core.ConfigurableService;
import org.osgi.framework.BundleContext;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;

/**
* An implementation of {@link ScriptEngineFactory} with customizations for GraalJS ScriptEngines.
*
* @author Jonathan Gilbert - Initial contribution
* @author Dan Cunningham - Script injections
*/
@Component(service = ScriptEngineFactory.class)
@Component(service = ScriptEngineFactory.class, configurationPid = "org.openhab.automation.jsscripting", property = Constants.SERVICE_PID
+ "=org.openhab.automation.jsscripting")
@ConfigurableService(category = "automation", label = "JS Scripting", description_uri = "automation:jsscripting")
public final class GraalJSScriptEngineFactory implements ScriptEngineFactory {
private static final String CFG_INJECTION_ENABLED = "injectionEnabled";
private static final String INJECTION_CODE = "Object.assign(this, require('openhab'));";
private boolean injectionEnabled;

public static final String MIME_TYPE = "application/javascript;version=ECMAScript-2021";

Expand Down Expand Up @@ -59,7 +70,18 @@ public void scopeValues(ScriptEngine scriptEngine, Map<String, Object> scopeValu

@Override
public ScriptEngine createScriptEngine(String scriptType) {
OpenhabGraalJSScriptEngine engine = new OpenhabGraalJSScriptEngine();
return new DebuggingGraalScriptEngine<>(engine);
return new DebuggingGraalScriptEngine<>(
new OpenhabGraalJSScriptEngine(injectionEnabled ? INJECTION_CODE : null));
}

@Activate
protected void activate(BundleContext context, Map<String, ?> config) {
modified(config);
}

@Modified
protected void modified(Map<String, ?> config) {
Object injectionEnabled = config.get(CFG_INJECTION_ENABLED);
this.injectionEnabled = injectionEnabled == null || (Boolean) injectionEnabled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,32 @@
import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;

import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.AccessMode;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.FileAttribute;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;

import javax.script.ScriptContext;
import javax.script.ScriptException;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.openhab.automation.jsscripting.internal.fs.DelegatingFileSystem;
import org.openhab.automation.jsscripting.internal.fs.PrefixedSeekableByteChannel;
import org.openhab.automation.jsscripting.internal.fs.ReadOnlySeekableByteArrayChannel;
import org.openhab.automation.jsscripting.internal.fs.watch.JSDependencyTracker;
import org.openhab.automation.jsscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocable;
import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
Expand All @@ -43,32 +53,36 @@
* GraalJS Script Engine implementation
*
* @author Jonathan Gilbert - Initial contribution
* @author Dan Cunningham - Script injections
*/
public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngineWithInvocable<GraalJSScriptEngine> {

private static final Logger LOGGER = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class);

private static final String GLOBAL_REQUIRE = "require(\"@jsscripting-globals\");";
private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__";
// final CommonJS search path for our library
private static final Path LOCAL_NODE_PATH = Paths.get("/node_modules");

// these fields start as null because they are populated on first use
private @NonNullByDefault({}) String engineIdentifier;
private @NonNullByDefault({}) Consumer<String> scriptDependencyListener;

private boolean initialized = false;
private String globalScript;

/**
* Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script
* lifecycle and provides hooks for scripts to do so too.
*/
public OpenhabGraalJSScriptEngine() {
public OpenhabGraalJSScriptEngine(@Nullable String injectionCode) {
super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately
this.globalScript = GLOBAL_REQUIRE + (injectionCode != null ? injectionCode : "");
delegate = GraalJSScriptEngine.create(
Engine.newBuilder().allowExperimentalOptions(true).option("engine.WarnInterpreterOnly", "false")
.build(),
Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true)
.option("js.commonjs-require-cwd", JSDependencyTracker.LIB_PATH)
.option("js.nashorn-compat", "true") // to ease
// migration
.option("js.nashorn-compat", "true") // to ease migration
.option("js.ecmascript-version", "2021") // nashorn compat will enforce es5 compatibility, we
// want ecma2021
.option("js.commonjs-require", "true") // enable CommonJS module support
Expand All @@ -80,15 +94,52 @@ public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> o
if (scriptDependencyListener != null) {
scriptDependencyListener.accept(path.toString());
}

if (path.toString().endsWith(".js")) {
SeekableByteChannel sbc = null;
if (path.startsWith(LOCAL_NODE_PATH)) {
InputStream is = getClass().getResourceAsStream(path.toString());
if (is == null) {
throw new IOException("Could not read " + path.toString());
}
sbc = new ReadOnlySeekableByteArrayChannel(is.readAllBytes());
} else {
sbc = super.newByteChannel(path, options, attrs);
}
return new PrefixedSeekableByteChannel(
("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(),
super.newByteChannel(path, options, attrs));
("require=" + REQUIRE_WRAPPER_NAME + "(require);").getBytes(), sbc);
} else {
return super.newByteChannel(path, options, attrs);
}
}

@Override
public void checkAccess(Path path, Set<? extends AccessMode> modes,
LinkOption... linkOptions) throws IOException {
if (path.startsWith(LOCAL_NODE_PATH)) {
if (getClass().getResource(path.toString()) == null) {
throw new NoSuchFileException(path.toString());
}
} else {
super.checkAccess(path, modes, linkOptions);
}
}

@Override
public Map<String, Object> readAttributes(Path path, String attributes,
LinkOption... options) throws IOException {
if (path.startsWith(LOCAL_NODE_PATH)) {
return Collections.singletonMap("isRegularFile", true);
}
return super.readAttributes(path, attributes, options);
}

@Override
public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
if (path.startsWith(LOCAL_NODE_PATH)) {
return path;
}
return super.toRealPath(path, linkOptions);
}
}));
}

Expand Down Expand Up @@ -130,5 +181,11 @@ protected void beforeInvocation() {
delegate.put("require", wrapRequireFn.apply((Function<Object[], Object>) delegate.get("require")));

initialized = true;

try {
eval(globalScript);
} catch (ScriptException e) {
LOGGER.error("Could not inject global script", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/

package org.openhab.automation.jsscripting.internal.fs;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.SeekableByteChannel;

/**
* Simple wrapper around a byte array to provide a SeekableByteChannel for consumption
*
* @author Dan Cunningham - Initial contribution
*/
public class ReadOnlySeekableByteArrayChannel implements SeekableByteChannel {
private byte[] data;
private int position;
private boolean closed;

public ReadOnlySeekableByteArrayChannel(byte[] data) {
this.data = data;
}

@Override
public long position() {
return position;
}

@Override
public SeekableByteChannel position(long newPosition) throws IOException {
ensureOpen();
position = (int) Math.max(0, Math.min(newPosition, size()));
return this;
}

@Override
public long size() {
return data.length;
}

@Override
public int read(ByteBuffer buf) throws IOException {
ensureOpen();
int remaining = (int) size() - position;
if (remaining <= 0) {
return -1;
}
int readBytes = buf.remaining();
if (readBytes > remaining) {
readBytes = remaining;
}
buf.put(data, position, readBytes);
position += readBytes;
return readBytes;
}

@Override
public void close() {
closed = true;
}

@Override
public boolean isOpen() {
return !closed;
}

@Override
public int write(ByteBuffer b) throws IOException {
throw new UnsupportedOperationException();
}

@Override
public SeekableByteChannel truncate(long newSize) {
throw new UnsupportedOperationException();
}

private void ensureOpen() throws ClosedChannelException {
if (!isOpen()) {
throw new ClosedChannelException();
}
}
}
Loading

0 comments on commit 6578a58

Please sign in to comment.