javascript.h
A small JavaScript interpreter, for scripting and for expressions in settings.
#include <sonore/javascript.h>- 2types
On this page
Design notes
JavascriptEngine runs a subset of JavaScript by walking a syntax tree: no JIT, no bytecode, no browser. What a plugin wants from it is small -- a user's macro, a modulation formula typed into a field, a preset that computes a curve, a config value written as 48000 / 4 -- and what a plugin cannot afford is for any of that to take the host down with it.
The subset
var / let / const (block scoping, the temporal dead zone, a fresh binding per loop iteration when the body makes a closure), function declarations and expressions, arrow functions, closures, default and rest parameters, spread in calls and literals, if / for / for-in / for-of / while / do / switch, break and continue, try / catch / finally and throw, new with constructor functions and prototypes, instanceof, in, delete, typeof, void, template literals, optional chaining, ?? and the logical assignments, and the whole operator table with ECMAScript's coercions.
Built in: Object, Array, String, Number, Boolean, Math, JSON, the Error family, parseInt / parseFloat / isNaN / isFinite, eval (at global scope), console. Plus the reference's own additions: Array.prototype.contains and remove, Math.sqr / range / randInt / toDegrees / toRadians, and trace().
Not here, deliberately: regular expressions, classes, getters and setters, destructuring, generators, async, Symbol, Map and Set, Date, labels, and sparse arrays. Each is a meaningful amount of engine for a feature a plugin's script does not need, and each would be one more place for a hostile script to find an edge.
Semantics: ECMAScript's, checked against a real engine
Where the subset says something, it says what ECMAScript says. Strings are sequences of UTF-16 code units -- "é".length is 1 and an emoji's is 2 -- stored as UTF-8 (WTF-8 for a lone surrogate, so one can be split and rejoined). Numbers are doubles and print as JavaScript prints them, the shortest digits that read back exactly. Property order is ECMAScript's: integer keys ascending, then the rest in insertion order. The tests run thousands of generated programs here and in node and compare the output.
Hostile scripts
A script may come from a preset somebody downloaded, so every way of making the interpreter consume the machine has a limit that ends in an error rather than a hang or a crash:
- time: maximumExecutionTime, checked on every loop iteration and call -- "Execution timed-out" once exceeded; and stop(), from any thread, "Interrupted";
- stack: maximumRecursionDepth for script calls, and a measured budget of native stack under every recursive path -- the parser, the evaluator, JSON, string conversion -- never more than the calling thread actually has left, so nesting a thousand brackets is an error, not a stack overflow, on a host thread with 512 KB as on one with 8 MB;
- memory: everything the engine allocates is charged to a meter and refunded when it is freed; past maximumMemoryBytes the engine collects its garbage cycles and, if that is not enough, stops the script. Strings and arrays also have a maximum length, checked BEFORE allocating.
Those three end the script outright: a try/catch inside the script cannot catch them, because a limit a script can catch is a limit it can retry.
Nothing escapes to the caller as a C++ exception. Every error -- syntax, an uncaught throw, a limit -- comes back in a Result, with a line number.
Memory without leaks
Values are reference counted (std::shared_ptr), so a JsVar stays valid in C++ after the engine that made it is gone. Reference counting alone leaks cycles, and closures make cycles constantly -- a function stored in the scope it captured is one. So the engine keeps a weak list of what it allocated and, now and then, finds the objects whose every reference comes from another object on the list and from nothing else (a trial deletion: count the references held inside the list, subtract from the use count, anything left over is held from outside). What no outside reference can reach is garbage, and its references are dropped. A collector that only ever empties what is provably unreachable cannot free anything in use.
Destruction is iterative for the same reason the parser has a stack budget: an array nested a million deep is a few lines of script, and freeing it by recursion would overflow the stack in a destructor.
Threads
One engine runs on one thread at a time. stop() is the exception, and is safe from any thread. Script values must not be shared across threads while a script runs.
Types 2
A JavaScript value: undefined, null, a boolean, a number, a string, or a reference to an array, an object or a function.
One numeric kind, because ECMAScript has one: 1 and 1.0 are the same value and compare equal. isInt() is a question about the value (integral, and in int range), not a second storage type -- the reference keeps int, int64 and double apart, and then has to decide what int == double means.
Arrays, objects and functions are references, as in JavaScript: copying a JsVar copies the reference, and clone() is the deep copy.
class DynamicObject : public detail::js::GcNode
An object: named properties in order, an optional prototype, and methods.
What a script sees as an object, and what a plugin builds to give a script something to call -- registerNativeObject() puts one in the global scope, setMethod() gives it functions implemented in C++.
The public interface is about OWN properties; a script's lookups also walk the prototype chain, which the engine does. Property names are UTF-8.