Skip to content

Instantly share code, notes, and snippets.

@dherman
Last active July 10, 2023 19:15
Show Gist options
  • Save dherman/9146568 to your computer and use it in GitHub Desktop.
Save dherman/9146568 to your computer and use it in GitHub Desktop.
How the ES6 Realm API makes it possible to create robust JS dialects

JS dialects with ES6 Realms

This essay explains how the ES6 Realm API makes it possible to create robust language abstractions that allow hooking into the behavior of eval, and how this can be used to implement different dialects of JavaScript.

Example scenario: Crock's "default operator"

Imagine we want to add Doug Crockford's ?? operator, which acts like a short-circuiting logical OR operator, except instead of checking truthiness, it returns the first argument's value if the first argument is any value other than undefined.

Since it makes everything simpler and cleaner, I'm going to assume I can use do-expressions for the implementation. (They're looking good for ES7!) So with that said, when we "crockpile" EXPR1 ?? EXPR2 we should get:

do {
  let tmp = EXPR1;
  (typeof tmp !== 'undefined') ? tmp : EXPR2
}

except that tmp has to be a fresh variable every time an instance of ?? is crockpiled, to avoid accidental name collisions.

Problem: Leaking variables to direct eval

The challenge is that even if you choose a fresh variable that isn't in scope, you can't be sure that an instance of direct eval won't accidentally discover the variable name you chose. For example, if a particular instance of ?? crockpiles to a generated variable name tmp$1, a naive crockpilation would still allow this to be discovered with direct eval. For example, this code:

undefined ?? eval("tmp$1")

would naively crockpile to:

do {
  let tmp$1 = undefined;
  (typeof tmp$1 !== 'undefined') ? tmp$1 : eval("tmp$1")
}

but the real program should actually produce a reference to a global variable called tmp$1, since the user's program does not actually create a local variable called tmp$1. So the correct crockpilation needs some way of communicating to the direct eval that it should ignore references to tmp$1 and treat them as global references.

Solution: Pass crockpiler info to direct eval

The solution is for the crockpiler to inject a representation of the crockpiler environment at all direct eval sites, so that the direct eval can use that environment for its own crockpilation. So the above example crockpiles instead to:

do {
  let tmp$1 = undefined;
  (typeof tmp$1 !== 'undefined') ? tmp$1 : eval([{"tmp$1":"GENSYM"}], "tmp$1")
}

Now when the direct eval is performed, it has a reified representation of the crockpilation environment to start in, so it knows when it comes across a reference to tmp$1 to replace it with something like window.tmp$1.

Node.prototype.isPossibleDirectEval = function() {
return this.type === 'CALL' &&
this.callee.type === 'IDENTIFIER' &&
this.callee.name === 'eval';
};
// define all the different JS AST node types
...
// behold... the Great Crockpiler: a compiler from CrockScript -> ES7
export default function crockpile(env, node) {
if (node.isPossibleDirectEval()) {
return {
type: 'CALL',
callee: { type: 'IDENTIFIER', name: 'eval' },
// inject a runtime representation of the crockpiler env as an extra initial argument
args: [env.reify()].concat(node.args.map(arg => crockpile(env, arg)))
};
}
switch (node.type) {
case 'CROCK_EXPRESSION': {
...
// pick a fresh name not in scope
let tmp = env.gensym();
// create a child environment with tmp added to scope
let env2 = env.push(tmp, 'GENSYM');
// crockpile the child nodes in env2
...
break;
}
case 'IDENTIFIER':
// if we see a reference to a name we gensym'ed, then replace
// it with an explicit global dereference (it must be global
// since we make sure never to gensym variable names that are
// already in use)
if (env.lookup(node.name) === 'GENSYM') {
return {
type: 'DOT',
object: env.globalObject(),
property: node.name
};
}
// rest of IDENTIFIER compilation logic
...
break;
// rest of compilation logic
...
}
}
// import the modules of the Crockpiler
import Env from "./env";
import parse from "./parse";
import crockpile from "./crockpile";
// Stash away a high-fidelity functional version of apply, so we don't
// ever have to worry about calling the .apply method on a function object,
// which may have overridden or mutated the method.
var apply = Function.prototype.call.bind(Function.prototype.apply);
export default class CrockRealm extends Realm {
// indirect eval is global, so crockpile in an empty env
indirectEval(arg) {
return crockpile(Env.EMPTY, parse(arg));
},
// direct eval is local, and the crockpiled code always had a reified crockpiler env
directEval(self, crockpilerEnv, arg) {
return crockpile(Env.fromReified(crockpilerEnv), parse(arg));
},
// non-eval is when crockpiler *thought* it was a direct eval but it wasn't,
// so we need to ignore the injected crockpiler env and just call the callee
nonEval(self, callee, bogusCrockpileEnv, ...rest) {
return apply(callee, self, rest);
}
}
import CrockRealm from "realm";
var realm = new CrockRealm();
realm.global.console = ...; // create a global console binding
realm.eval('(function(eval) { var foo = "inner"; eval("foo") })(console.log)'); // prints "foo" on console
realm.eval('(function() { var foo = "inner"; eval("console.log(foo)"); })()'); // prints "inner" on console
// crockpiles to:
// do { let tmp$0 = 42; (typeof tmp$0 !== 'undefined') ? tmp$0 : Infinity }
realm.eval('42 ?? Infinity'); // 42
// crockpiles to:
// do { let tmp$1 = undefined; (typeof tmp$1 !== 'undefined') ? tmp$1 : eval([{"tmp$1":"GENSYM"}], "tmp$1") }
// which in turn evaluates eval([{"tmp$1":"GENSYM"}], "tmp$1")
// which in turn crockpiles to:
// window.tmp$1
realm.eval('undefined ?? eval("tmp$1")')
@littledan
Copy link

Does the realm API used here correspond to what's proposed in https://gist.github.com/caridy/311ad2c17a0cd875ae17ac11ffb597a9 ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment