package com.oracle.truffle.js.scriptengine;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.util.function.Predicate;
import javax.script.AbstractScriptEngine;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
import org.graalvm.collections.EconomicMap;
import org.graalvm.collections.EconomicSet;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Context.Builder;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.HostAccess.TargetMappingPrecedence;
import org.graalvm.polyglot.PolyglotException;
import org.graalvm.polyglot.Source;
import org.graalvm.polyglot.Value;
import org.graalvm.polyglot.proxy.Proxy;
public final class GraalJSScriptEngine extends AbstractScriptEngine implements Compilable, Invocable, AutoCloseable {
private static final String ID = "js";
private static final String POLYGLOT_CONTEXT = "polyglot.context";
private static final String OUT_SYMBOL = "$$internal.out$$";
private static final String IN_SYMBOL = "$$internal.in$$";
private static final String ERR_SYMBOL = "$$internal.err$$";
private static final String JS_SYNTAX_EXTENSIONS_OPTION = "js.syntax-extensions";
private static final String JS_SCRIPT_ENGINE_GLOBAL_SCOPE_IMPORT_OPTION = "js.script-engine-global-scope-import";
private static final String JS_LOAD_OPTION = "js.load";
private static final String JS_PRINT_OPTION = "js.print";
private static final String JS_GLOBAL_ARGUMENTS_OPTION = "js.global-arguments";
private static final String NASHORN_COMPATIBILITY_MODE_SYSTEM_PROPERTY = "polyglot.js.nashorn-compat";
private static final String INSECURE_SCRIPTENGINE_ACCESS_SYSTEM_PROPERTY = "graaljs.insecure-scriptengine-access";
static final String MAGIC_OPTION_PREFIX = "polyglot.js.";
private static final HostAccess NASHORN_HOST_ACCESS = createNashornHostAccess();
private static HostAccess createNashornHostAccess() {
HostAccess.Builder b = HostAccess.newBuilder(HostAccess.ALL);
b.targetTypeMapping(Value.class, String.class, v -> !v.isNull(), Value::toString, TargetMappingPrecedence.LOWEST);
b.targetTypeMapping(Number.class, Integer.class, n -> true, n -> n.intValue(), TargetMappingPrecedence.LOWEST);
b.targetTypeMapping(Number.class, Double.class, n -> true, n -> n.doubleValue(), TargetMappingPrecedence.LOWEST);
b.targetTypeMapping(Number.class, Long.class, n -> true, n -> n.longValue(), TargetMappingPrecedence.LOWEST);
b.targetTypeMapping(Number.class, Boolean.class, n -> true, n -> toBoolean(n.doubleValue()), TargetMappingPrecedence.LOWEST);
b.targetTypeMapping(String.class, Boolean.class, n -> true, n -> !n.isEmpty(), TargetMappingPrecedence.LOWEST);
return b.build();
}
private static boolean toBoolean(double d) {
return d != 0.0 && !Double.isNaN(d);
}
interface MagicBindingsOptionSetter {
String getOptionKey();
Context.Builder setOption(Builder builder, Object value);
}
private static boolean toBoolean(MagicBindingsOptionSetter optionSetter, Object value) {
if (!(value instanceof Boolean)) {
throw magicOptionValueErrorBool(optionSetter.getOptionKey(), value);
}
return (Boolean) value;
}
private static final MagicBindingsOptionSetter[] MAGIC_OPTION_SETTERS = new MagicBindingsOptionSetter[]{
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowHostAccess";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowHostAccess(toBoolean(this, value) ? HostAccess.ALL : HostAccess.NONE);
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowNativeAccess";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowNativeAccess(toBoolean(this, value));
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowCreateThread";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowCreateThread(toBoolean(this, value));
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowIO";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowIO(toBoolean(this, value));
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowHostClassLookup";
}
@SuppressWarnings("unchecked")
@Override
public Builder setOption(Builder builder, Object value) {
if (value instanceof Boolean) {
boolean enabled = (Boolean) value;
return builder.allowHostClassLookup(enabled ? s -> true : null);
} else {
try {
return builder.allowHostClassLookup((Predicate<String>) value);
} catch (ClassCastException e) {
throw new IllegalArgumentException(
String.format("failed to set graal-js option \"%s\": expected a boolean or Predicate<String> value, got \"%s\"", getOptionKey(), value));
}
}
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowHostClassLoading";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowHostClassLoading(toBoolean(this, value));
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "allowAllAccess";
}
@Override
public Builder setOption(Builder builder, Object value) {
return builder.allowAllAccess(toBoolean(this, value));
}
},
new MagicBindingsOptionSetter() {
@Override
public String getOptionKey() {
return MAGIC_OPTION_PREFIX + "nashorn-compat";
}
@Override
public Builder setOption(Builder builder, Object value) {
boolean val = toBoolean(this, value);
if (val) {
updateForNashornCompatibilityMode(builder);
}
return builder.option("js.nashorn-compat", String.valueOf(val));
}
}
};
private static final EconomicSet<String> MAGIC_BINDINGS_OPTION_KEYS = EconomicSet.create();
static final EconomicMap<String, MagicBindingsOptionSetter> MAGIC_BINDINGS_OPTION_MAP = EconomicMap.create();
private static final boolean NASHORN_COMPATIBILITY_MODE = Boolean.getBoolean(NASHORN_COMPATIBILITY_MODE_SYSTEM_PROPERTY);
static {
for (MagicBindingsOptionSetter setter : MAGIC_OPTION_SETTERS) {
MAGIC_BINDINGS_OPTION_KEYS.add(setter.getOptionKey());
MAGIC_BINDINGS_OPTION_MAP.put(setter.getOptionKey(), setter);
}
}
private final GraalJSEngineFactory factory;
private final Context.Builder contextConfig;
private boolean evalCalled;
GraalJSScriptEngine(GraalJSEngineFactory factory) {
this(factory, factory.getPolyglotEngine(), null);
}
GraalJSScriptEngine(GraalJSEngineFactory factory, Engine engine, Context.Builder contextConfig) {
Engine engineToUse = engine;
if (engineToUse == null) {
engineToUse = Engine.newBuilder().allowExperimentalOptions(true).build();
}
Context.Builder contextConfigToUse = contextConfig;
if (contextConfigToUse == null) {
contextConfigToUse = Context.newBuilder(ID).allowExperimentalOptions(true);
contextConfigToUse.option(JS_SYNTAX_EXTENSIONS_OPTION, "true");
contextConfigToUse.option(JS_LOAD_OPTION, "true");
contextConfigToUse.option(JS_PRINT_OPTION, "true");
contextConfigToUse.option(JS_GLOBAL_ARGUMENTS_OPTION, "true");
if (NASHORN_COMPATIBILITY_MODE) {
updateForNashornCompatibilityMode(contextConfigToUse);
} else if (Boolean.getBoolean(INSECURE_SCRIPTENGINE_ACCESS_SYSTEM_PROPERTY)) {
updateForScriptEngineAccessibility(contextConfigToUse);
}
}
this.factory = (factory == null) ? new GraalJSEngineFactory(engineToUse) : factory;
this.contextConfig = contextConfigToUse.option(JS_SCRIPT_ENGINE_GLOBAL_SCOPE_IMPORT_OPTION, "true").engine(engineToUse);
this.context.setBindings(new GraalJSBindings(this.contextConfig, this.context), ScriptContext.ENGINE_SCOPE);
}
private static void updateForNashornCompatibilityMode(Context.Builder builder) {
builder.allowAllAccess(true);
builder.allowHostAccess(NASHORN_HOST_ACCESS);
}
private static void updateForScriptEngineAccessibility(Context.Builder builder) {
builder.allowHostAccess(HostAccess.ALL);
}
static Context createDefaultContext(Context.Builder builder) {
DelegatingInputStream in = new DelegatingInputStream();
DelegatingOutputStream out = new DelegatingOutputStream();
DelegatingOutputStream err = new DelegatingOutputStream();
builder.in(in).out(out).err(err);
Context ctx = builder.build();
ctx.getPolyglotBindings().putMember(OUT_SYMBOL, out);
ctx.getPolyglotBindings().putMember(ERR_SYMBOL, err);
ctx.getPolyglotBindings().putMember(IN_SYMBOL, in);
return ctx;
}
@Override
public void close() {
getPolyglotContext().close();
}
public Engine getPolyglotEngine() {
return factory.getPolyglotEngine();
}
public Context getPolyglotContext() {
return getPolyglotContext(context);
}
public Context getPolyglotContext(ScriptContext ctxt) {
return getOrCreateGraalJSBindings(ctxt).getContext();
}
static Value evalInternal(Context context, String script) {
return context.eval(Source.newBuilder(ID, script, "internal-script").internal(true).buildLiteral());
}
@Override
public Bindings createBindings() {
return new GraalJSBindings(contextConfig, null);
}
@Override
public void setBindings(Bindings bindings, int scope) {
if (scope == ScriptContext.ENGINE_SCOPE) {
Bindings oldBindings = getBindings(scope);
if (oldBindings instanceof GraalJSBindings) {
((GraalJSBindings) oldBindings).updateEngineScriptContext(null);
}
}
super.setBindings(bindings, scope);
if (scope == ScriptContext.ENGINE_SCOPE && (bindings instanceof GraalJSBindings)) {
((GraalJSBindings) bindings).updateEngineScriptContext(getContext());
}
}
@Override
public Object eval(Reader reader, ScriptContext ctxt) throws ScriptException {
return eval(createSource(read(reader), ctxt), ctxt);
}
static String read(Reader reader) throws ScriptException {
final StringBuilder builder = new StringBuilder();
final char[] buffer = new char[1024];
try {
try {
while (true) {
final int count = reader.read(buffer);
if (count == -1) {
break;
}
builder.append(buffer, 0, count);
}
} finally {
reader.close();
}
return builder.toString();
} catch (IOException ioex) {
throw new ScriptException(ioex);
}
}
@Override
public Object eval(String script, ScriptContext ctxt) throws ScriptException {
return eval(createSource(script, ctxt), ctxt);
}
private static Source createSource(String script, ScriptContext ctxt) throws ScriptException {
final Object val = ctxt.getAttribute(ScriptEngine.FILENAME);
if (val == null) {
return Source.newBuilder(ID, script, "<eval>").buildLiteral();
} else {
try {
return Source.newBuilder(ID, new File(val.toString())).content(script).build();
} catch (IOException ioex) {
throw new ScriptException(ioex);
}
}
}
private static void updateDelegatingIOStreams(Context polyglotContext, ScriptContext scriptContext) {
Value polyglotBindings = polyglotContext.getPolyglotBindings();
((DelegatingOutputStream) polyglotBindings.getMember(OUT_SYMBOL).asProxyObject()).setWriter(scriptContext.getWriter());
((DelegatingOutputStream) polyglotBindings.getMember(ERR_SYMBOL).asProxyObject()).setWriter(scriptContext.getErrorWriter());
((DelegatingInputStream) polyglotBindings.getMember(IN_SYMBOL).asProxyObject()).setReader(scriptContext.getReader());
}
private Object eval(Source source, ScriptContext scriptContext) throws ScriptException {
GraalJSBindings engineBindings = getOrCreateGraalJSBindings(scriptContext);
Context polyglotContext = engineBindings.getContext();
updateDelegatingIOStreams(polyglotContext, scriptContext);
try {
if (!evalCalled) {
jrunscriptInitWorkaround(source, polyglotContext);
}
engineBindings.importGlobalBindings(scriptContext);
return polyglotContext.eval(source).as(Object.class);
} catch (PolyglotException e) {
throw toScriptException(e);
} finally {
evalCalled = true;
}
}
private static ScriptException toScriptException(PolyglotException ex) {
ScriptException sex;
if (ex.isHostException()) {
Throwable hostException = ex.asHostException();
Exception cause;
if (hostException instanceof Exception) {
cause = (Exception) hostException;
} else {
cause = new Exception(hostException);
}
sex = new ScriptException(cause);
sex.setStackTrace(ex.getStackTrace());
} else {
sex = new ScriptException(ex);
}
return sex;
}
private GraalJSBindings getOrCreateGraalJSBindings(ScriptContext scriptContext) {
Bindings engineB = scriptContext.getBindings(ScriptContext.ENGINE_SCOPE);
if (engineB instanceof GraalJSBindings) {
return ((GraalJSBindings) engineB);
} else {
GraalJSBindings bindings = new GraalJSBindings(createContext(engineB), scriptContext);
bindings.putAll(engineB);
return bindings;
}
}
private Context createContext(Bindings engineB) {
Object ctx = engineB.get(POLYGLOT_CONTEXT);
if (!(ctx instanceof Context)) {
Context.Builder builder = contextConfig;
for (MagicBindingsOptionSetter optionSetter : MAGIC_OPTION_SETTERS) {
Object value = engineB.get(optionSetter.getOptionKey());
if (value != null) {
builder = optionSetter.setOption(builder, value);
engineB.remove(optionSetter.getOptionKey());
}
}
ctx = createDefaultContext(builder);
engineB.put(POLYGLOT_CONTEXT, ctx);
}
return (Context) ctx;
}
@Override
public GraalJSEngineFactory getFactory() {
return factory;
}
@Override
public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptException, NoSuchMethodException {
if (thiz == null) {
throw new IllegalArgumentException("thiz is not a valid object.");
}
GraalJSBindings engineBindings = getOrCreateGraalJSBindings(context);
engineBindings.importGlobalBindings(context);
Value thisValue = engineBindings.getContext().asValue(thiz);
if (!thisValue.canInvokeMember(name)) {
if (!thisValue.hasMember(name)) {
throw noSuchMethod(name);
} else {
throw notCallable(name);
}
}
try {
return thisValue.invokeMember(name, args).as(Object.class);
} catch (PolyglotException e) {
throw toScriptException(e);
}
}
@Override
public Object invokeFunction(String name, Object... args) throws ScriptException, NoSuchMethodException {
GraalJSBindings engineBindings = getOrCreateGraalJSBindings(context);
engineBindings.importGlobalBindings(context);
Value function = engineBindings.getContext().getBindings(ID).getMember(name);
if (function == null) {
throw noSuchMethod(name);
} else if (!function.canExecute()) {
throw notCallable(name);
}
try {
return function.execute(args).as(Object.class);
} catch (PolyglotException e) {
throw toScriptException(e);
}
}
private static NoSuchMethodException noSuchMethod(String name) throws NoSuchMethodException {
throw new NoSuchMethodException(name);
}
private static NoSuchMethodException notCallable(String name) throws NoSuchMethodException {
throw new NoSuchMethodException(name + " is not a function");
}
@Override
public <T> T getInterface(Class<T> clasz) {
checkInterface(clasz);
return getInterfaceInner(evalInternal(getPolyglotContext(), "this"), clasz);
}
@Override
public <T> T getInterface(Object thiz, Class<T> clasz) {
if (thiz == null) {
throw new IllegalArgumentException("this cannot be null");
}
checkInterface(clasz);
Value thisValue = getPolyglotContext().asValue(thiz);
checkThis(thisValue);
return getInterfaceInner(thisValue, clasz);
}
private static void checkInterface(Class<?> clasz) {
if (clasz == null || !clasz.isInterface()) {
throw new IllegalArgumentException("interface Class expected in getInterface");
}
}
private static void checkThis(Value thiz) {
if (thiz.isHostObject() || !thiz.hasMembers()) {
throw new IllegalArgumentException("getInterface cannot be called on non-script object");
}
}
private static <T> T getInterfaceInner(Value thiz, Class<T> iface) {
if (!isInterfaceImplemented(iface, thiz)) {
return null;
}
return thiz.as(iface);
}
@Override
public CompiledScript compile(String script) throws ScriptException {
Source source = createSource(script, getContext());
return compile(source);
}
@Override
public CompiledScript compile(Reader reader) throws ScriptException {
Source source = createSource(read(reader), getContext());
return compile(source);
}
private CompiledScript compile(Source source) throws ScriptException {
checkSyntax(source);
return new CompiledScript() {
@Override
public ScriptEngine getEngine() {
return GraalJSScriptEngine.this;
}
@Override
public Object eval(ScriptContext ctx) throws ScriptException {
return GraalJSScriptEngine.this.eval(source, ctx);
}
};
}
private void checkSyntax(Source source) throws ScriptException {
try {
getPolyglotContext().parse(source);
} catch (PolyglotException pex) {
throw toScriptException(pex);
}
}
private static class DelegatingInputStream extends InputStream implements Proxy {
private Reader reader;
private CharsetEncoder encoder = Charset.defaultCharset().newEncoder();
private CharBuffer charBuffer = CharBuffer.allocate(2);
private ByteBuffer byteBuffer = (ByteBuffer) ByteBuffer.allocate((int) encoder.maxBytesPerChar() * 2).flip();
@Override
public int read() throws IOException {
if (reader != null) {
while (!byteBuffer.hasRemaining()) {
int c = reader.read();
if (c == -1) {
return -1;
}
byteBuffer.clear();
charBuffer.put((char) c);
charBuffer.flip();
encoder.encode(charBuffer, byteBuffer, false);
charBuffer.compact();
byteBuffer.flip();
}
return byteBuffer.get();
}
return 0;
}
void setReader(Reader reader) {
this.reader = reader;
}
}
private static class DelegatingOutputStream extends OutputStream implements Proxy {
private Writer writer;
private CharsetDecoder decoder = Charset.defaultCharset().newDecoder();
private ByteBuffer byteBuffer = ByteBuffer.allocate((int) Charset.defaultCharset().newEncoder().maxBytesPerChar() * 2);
private CharBuffer charBuffer = CharBuffer.allocate(byteBuffer.capacity() * (int) decoder.maxCharsPerByte());
@Override
public void write(int b) throws IOException {
if (writer != null) {
byteBuffer.put((byte) b);
byteBuffer.flip();
decoder.decode(byteBuffer, charBuffer, false);
byteBuffer.compact();
charBuffer.flip();
while (charBuffer.hasRemaining()) {
char c = charBuffer.get();
writer.write(c);
}
charBuffer.clear();
}
}
@Override
public void flush() throws IOException {
if (writer != null) {
writer.flush();
}
}
void setWriter(Writer writer) {
this.writer = writer;
}
}
public static GraalJSScriptEngine create() {
return create(null, null);
}
public static GraalJSScriptEngine create(Engine engine, Context.Builder newContextConfig) {
return new GraalJSScriptEngine(null, engine, newContextConfig);
}
private static boolean isInterfaceImplemented(final Class<?> iface, final Value obj) {
for (final Method method : iface.getMethods()) {
if (method.getDeclaringClass() == Object.class) {
continue;
}
if (!Modifier.isAbstract(method.getModifiers())) {
continue;
}
if (!obj.canInvokeMember(method.getName())) {
return false;
}
}
return true;
}
private static void jrunscriptInitWorkaround(Source source, Context polyglotContext) {
if (source.getName().equals(JRUNSCRIPT_INIT_NAME)) {
String initCode = source.getCharacters().toString();
if (initCode.contains("jrunscript") && initCode.contains("JSAdapter") && !polyglotContext.getBindings(ID).hasMember("JSAdapter")) {
polyglotContext.eval(ID, JSADAPTER_POLYFILL);
}
}
}
private static final String JRUNSCRIPT_INIT_NAME = "<system-init>";
private static final String JSADAPTER_POLYFILL = "this.JSAdapter || " +
"Object.defineProperty(this, \"JSAdapter\", {configurable:true, writable:true, enumerable: false, value: function(t) {\n" +
" var target = {};\n" +
" var handler = {\n" +
" get: function(target, name) {return typeof t.__get__ == 'function' ? t.__get__.call(target, name) : undefined;},\n" +
" has: function(target, name) {return typeof t.__has__ == 'function' ? t.__has__.call(target, name) : false;},\n" +
" deleteProperty: function(target, name) {return typeof t.__delete__ == 'function' ? t.__delete__.call(target, name) : true;},\n" +
" set: function(target, name, value) {return typeof t.__put__ == 'function' ? t.__put__.call(target, name, value) : undefined;},\n" +
" ownKeys: function(target) {return typeof t.__getIds__ == 'function' ? t.__getIds__.call(target) : [];},\n" +
" }\n" +
" return new Proxy(target, handler);\n" +
"}});\n";
private static IllegalArgumentException magicOptionValueErrorBool(String name, Object v) {
return new IllegalArgumentException(String.format("failed to set graal-js option \"%s\": expected a boolean value, got \"%s\"", name, v));
}
}