A library for accessing the Chakra and ChakraCore JavaScript Runtime hosting interface from the .NET Framework. It is inspired by jsrt-winrt but is not directly compatible.
Why do I care? If you want to extend your .NET application, or allow your users to do so, by writing some JavaScript at runtime, this allows you to do so without paying the cost of loading an HTML engine with it. And, it's far more convenient than the programming model supported by the HTML engine. (Unless you're using HTML rendering within your app, in which case using the HTML engine is pretty efficient for that).
This project aims to create as seamless a bridge between your JavaScript and .NET code as
possible. .NET objects can be directly exposed to JavaScript, and JavaScript objects can
be accessed from C# using dynamic
or via normal early binding. It also aims to be an
accurate representation of the JavaScript type system from .NET.
As an example, let's create a simple host function called Echo:
static JavaScriptValue Echo(JavaScriptEngine engine, bool construct, JavaScriptValue thisValue, IEnumerable<JavaScriptValue> arguments)
{
string fmt = arguments.First().ToString();
object[] args = (object[])arguments.Skip(1).ToArray();
Console.WriteLine(fmt, args);
return engine.UndefinedValue;
}
The function must return a JavaScriptValue
(because all JavaScript functions return
something - even if that something is undefined
). The parameters to the function
represent the things that the JavaScript code is calling:
engine
is an isolated collection of globals and codeconstruct
indicates whether the function is being called with thenew
operatorthisValue
is the ambient JavaScriptthis
value (if one is available)arguments
are the remaining parameters actually passed in
The function expects at least a single parameter to be passed, and will accept multiple
other parameters. It then uses the Console.WriteLine(string, params object[])
overload
to write a formatted string. We then add this function to the global object:
using (var runtime = new JavaScriptRuntime())
using (var engine = runtime.CreateEngine())
using (var context = engine.AcquireContext())
{
engine.SetGlobalFunction("echo", Echo);
// TODO: Call echo
}
So what we do here are:
- Create a
JavaScriptRuntime
, which has global settings and a shared memory allocator - Create a
JavaScriptEngine
, which is that isolated collection of globals and code - Acquire an execution context from the engine, which means that the script engine is bound to the current thread while the context is held. The engine is released from the thread when the context is disposed.
- We then create a global function,
echo
, corresponding to theEcho
method earlier
All that's left is to run some script that will call it:
using (var runtime = new JavaScriptRuntime())
using (var engine = runtime.CreateEngine())
using (var context = engine.AcquireContext())
{
engine.SetGlobalFunction("echo", Echo);
var fn = engine.EvaluateScriptText(@"(function() {
echo('{0}, {1}!', 'Hello', 'World');
})();");
fn.Invoke(Enumerable.Empty<JavaScriptValue>());
}
If you run this from a console, you'll see
Hello, World!
output on the screen.
Let's start getting crazy.
using (var runtime = new JavaScriptRuntime())
using (var engine = runtime.CreateEngine())
using (var context = engine.AcquireContext())
{
engine.SetGlobalFunction("echo", Echo);
dynamic global = engine.GlobalObject;
global.hello = "Hello";
global.world = "world";
var fn = engine.EvaluateScriptText(@"(function() {
echo('{0}, {1}!', hello, world);
})();");
fn.Invoke(Enumerable.Empty<JavaScriptValue>());
}
What happened here?
The dynamic
keyword instructs the C# compiler to perform runtime late binding
on things that are performed against the dynamic thing. The engine's
GlobalObject
property is a JavaScript Object
(it's the thing that provides
all of those handy things like ArrayBuffer
and Math
). That Object, like
any other JavaScript object, has properties, and those properties are accessible
via the normal name resolution rules per normal
JavaScript semantics.
Because JavaScript Objects are property bags, we can assign anything to them. What happens under the covers is:
JavaScriptObject
derives fromJavaScriptValue
, which in turn derives fromDynamicObject
, provided in the .NET base class library- The C# language bindings call
JavaScriptObject.TrySetMember
, passing information about the operation, namely, that the name ishello
and that the set-member operation is case-sensitive. (Because JavaScript is case-sensitive, we ignore this flag, and always treat it as case-sensitive). - That operation converts the right-hand side value (in this case, a C#
string
of"Hello"
) to its JavaScript equivalent, aJavaScriptValue
, and callsJavaScriptObject.SetPropertyByName(string, JavaScriptValue)
method. - When the script text is executed, the environment record has bindings for
hello
andworld
as properties of the global, so they're passed back out to C# asJavaScriptValue
s in the arguments. They get converted back to strings, without changing the code.
I can blow your mind even more. Without even calling script, I can call into the script engine:
using (var runtime = new JavaScriptRuntime())
using (var engine = runtime.CreateEngine())
using (var context = engine.AcquireContext())
{
engine.SetGlobalFunction("echo", Echo);
dynamic global = engine.GlobalObject;
global.echo("{0}, {1}, from dynamic.", "Hello", "world");
}
Here, the C# dynamic binder calls into JavaScriptObject.TryInvokeMember
,
which resolves the property, casts it to a JavaScriptFunction
, and then
calls it. That function happens to be a host function, so it calls back
into C#, using the same round-trip behaviors as shown previously.
CLR objects can be added to and accessed via script. Wherever possible, we try to preserve object behavioral semantics across the script-host boundary. That is, if you have a C# object that you add to the script engine, and then mutate that object from script, those changes will be reflected in the C# object.
** Important: ** .NET objects to JavaScript are still an incomplete and experimental feature. The following outlines how this feature is intended to function, but may not be implemented as described.
To convert a host object to a JavaScript representation, call
myJavaScriptEngine.Converter.FromObject
, which will return a
JavaScriptValue
.
var pt = new Point3D { X = 18, Y = 27, Z = -1 };
engine.SetGlobalVariable("pt", engine.Converter.FromObject(pt));
For any type that isn't just represented by a JavaScript primitive, we attempt to follow this algorithm:
- If the Type of the value is a
struct
(System.ValueType
), anArgumentException
is thrown. Because struct types can define methods and properties, but object identity isn't preserved, they are invalid types for projection across the boundary. Instead, use a JSON serializer to serialize the value, and then deserialize the value on the JavaScript side. - If the Type of the value is a
delegate
(derived fromSystem.Delegate
), anArgumentException
is thrown. Instead, use an overload ofengine.CreateFunction
. - If the Type of the value is a
Task
, aPromise
is created. The Promise will be resolved or rejected once the Task has completed. - Otherwise, an object will be projected as follows:
- Get the Type of the object being converted
- Create the constructor function:
- If the Type defines one or more public constructors, create a function
named the full name of the Type. Only one overload of each arity can
be supported. The function, when called with or without the
new
operator, will call the constructor. - If the Type does not define any public constructors, the function will
still be created, but it will only return
undefined
. - Regardless of whether there are any constructors, the function will
not be added to the global namespace. It will only be accessible via
a
constructor
property. - Create the prototype object:
- If the prototype object's base Type is not
null
(in other words, if the Type isn'tSystem.Object
), create a prototype chain and recycle this algorithm. - For each public method defined by this type, create a function that trampolines from JavaScript into .NET based on number of arguments passed from JavaScript, and add it to the prototype object.
- For each public property defined by this type, create an accessor property on the prototype object, with get and/or set methods, which trampolines from JavaScript into .NET. Indexer properties are not supported.
- If the type defines any events, create an
addEventListener
andremoveEventListener
method. If there are events in the type's inheritance chain, the first thing that these methods should do is invoke the parent prototype's corresponding add/remove method. TheaddEventListener
method should add an event handler which, when called, converts the .NET parameters into a single object. The object passed into the JavaScript callback has one property for each named argument in the event delegate signature. These arguments are converted by .NET-to-JavaScript semantics. - For each event, an
on{eventname}
property will also be created. When the property is set, it will unregister any registered listeners, and set a new listener (or not if set to a non-Function). - Public static properties, methods, and events will be projected in the same way as instance properties, methods, and events; except that events will not call via the prototype chain (type-bound events belong to the type, and do not work against a prototype chain).
In addition to providing these via objects sent into the engine directly, the
developer can add a constructor to the global namespace via the
AddTypeToGlobal
function:
engine.AddTypeToGlobal<Point3D>();
Instead of providing an instance of an object to the engine, this example
creates a function named Point3D
on the global object, representing the
Point3D
public constructors.
Given the following type definition:
public class Point
{
public double X
{
get;
set;
}
public double Y
{
get;
set;
}
public override string ToString()
{
return $"({X}, {Y})";
}
}
public class Point3D : Point
{
public double Z
{
get;
set;
}
public override string ToString()
{
return $"({X}, {Y}, {Z})";
}
}
If the Point3D
type is added to the global object, the equivalent code
is executed:
(function(global, createPoint, getX, setX, getY, setY, pointToString, createPoint3D, getZ, setZ, point3DToString) {
function Point() {
if (!(this instanceof Point))
return new Point(arguments);
createPoint.call(this);
}
Object.defineProperty(Point.prototype, 'X', {
get: getX,
set: setX,
enumerable: true
});
Object.defineProperty(Point.prototype, 'Y', {
get: getY,
set: setY,
enumerable: true
});
Point.prototype.toString = pointToString;
function Point3D() {
if (!(this instanceof Point3D))
return new Point3D(arguments);
createPoint3D.call(this);
}
Point3D.prototype = Object.create(Point.prototype);
Point3D.prototype.constructor = Point3D;
Object.defineProperty(Point3D.prototype, 'Z', {
get: getZ,
set: setZ,
enumerable: true
});
Point3D.prototype.toString = point3DToString;
global.Point3D = Point3D;
})([native method representations]);
The work to project Point
in this example is preserved, and reused, so
if Point3D
is added to the global before Point
, adding Point
will
only need to add the identifier Point
to the global object; the
initialization of the prototype properties doesn't need to occur.