eval()
This blog post describes the ECMAScript proposal “ShadowRealm API” by Dave Herman, Caridy Patiño, Mark S. Miller, Leo Balter, and Rick Waldron.
Class ShadowRealm
provides a new way of evaluating code at runtime – think eval()
but better:
A realm is an instance of the JavaScript platform: its global environment with all built-in data set up correctly.
For example, each iframe has an associated realm:
<body>
<iframe>
</iframe>
<script>
const win = frames[0].window;
console.assert(win.globalThis !== globalThis); // (A)
console.assert(win.Array !== Array); // (B)
</script>
</body>
The global object of the document is different from the global object of the iframe (line A). Global variables such as Array
are different, too (line B).
Quoting the proposal:
The primary goal of this proposal is to provide a proper mechanism to control the execution of a program, providing a new global object, a new set of intrinsics, no access to objects cross-realms, a separate module graph and synchronous communication between both realms.
ShadowRealms execute code with the same JavaScript heap as the surrounding context where the ShadowRealm is created. Code runs synchronously in the same thread.
ShadowRealm
is a class with the following type signature:
declare class ShadowRealm {
constructor();
evaluate(sourceText: string): PrimitiveValueOrCallable;
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
}
Each instance of ShadowRealm
has its own realm. Two methods allow us to evaluate code inside that realm:
.evaluate()
synchronously evaluates a string sourceText
inside a ShadowRealm. This method is loosely similar to eval()
..importValue()
: asynchronously imports inside a ShadowRealm and returns the result via a Promise. That means we get non-blocking evaluation (for third-party scripts etc.).The realm in which we instantiate ShadowRealm
is called the incubator realm. The instance is called the child realm.
shadowRealm.evaluate()
Method .evaluate()
has the following type signature:
evaluate(sourceText: string): PrimitiveValueOrCallable;
.evaluate()
works much like eval()
:
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`'ab' + 'cd'`) === 'abcd'
);
In contrast to eval()
, the code is evaluated inside the realm of .evaluate()
:
globalThis.realm = 'incubator realm';
const sr = new ShadowRealm();
sr.evaluate(`globalThis.realm = 'child realm'`);
console.assert(
sr.evaluate(`globalThis.realm`) === 'child realm'
);
If .evaluate()
returns a function, that function is wrapped so that invoking it from the outside, runs it inside the ShadowRealm:
globalThis.realm = 'incubator realm';
const sr = new ShadowRealm();
sr.evaluate(`globalThis.realm = 'child realm'`);
const wrappedFunc = sr.evaluate(`() => globalThis.realm`);
console.assert(wrappedFunc() === 'child realm');
Whenever a value is passed to or from a ShadowRealm, it must be primitive or callable. If not, an exception is thrown:
> new ShadowRealm().evaluate('[]')
TypeError: value passing between realms must be callable or primitive
More on passing values between realms later.
shadowRealm.importValue()
Method .importValue()
has the following type signature:
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
Inside its ShadowRealm, it imports the named import whose name is bindingName
from a module whose specifier is specifier
and returns its value asynchronously, via a Promise. As with .evaluate()
functions are wrapped so that invoking them from outside the ShadowRealm runs them inside the ShadowRealm:
// main.js
const sr = new ShadowRealm();
const wrappedSum = await sr.importValue('./my-module.js', 'sum');
console.assert(wrappedSum('hi', ' ', 'folks', '!') === 'hi folks!');
// my-module.js
export function sum(...values) {
return values.reduce((prev, value) => prev + value);
}
For now, the API requires the parameter bindingName
. In the future, omitting it may return (a Promise for) a module namespace object.
Therefore, if we only want to load a module without importing anything (e.g. a polyfill that changes global data), we have to use a workaround – for example:
// main.js
const sr = new ShadowRealm();
await sr.importValue('./my-module.js', 'default');
// my-module.js
export default true;
// ...
As with .evaluate()
, values passed to or from ShadowRealms (incl. arguments and results of cross-realm function calls) must be primitive or callable. More on that soon.
Syntax errors in the code lead to a SyntaxError
being thrown:
> new ShadowRealm().evaluate('someFunc(')
SyntaxError: Unexpected end of script
An uncaught error inside a ShadowRealm is delivered via a TypeError
:
> new ShadowRealm().evaluate(`throw new RangeError('The message')`)
TypeError: Error encountered during evaluation
Alas, at the moment we don’t get either name, message or stack trace of the original error. Maybe that will change in the future.
ShadowRealms contain the following global data:
The global data of ECMAScript
A subset of the platform-specific global data (such as document
in browsers or process
on Node.js). One key requirement is that all platform-specific properties of the global object must be configurable (which is one of the attributes of properties) so that they can be deleted. That gives us the option to hide those features from the code that we evaluate.
unsafe-eval
for a page prevents synchronous evaluation via .evaluate()
in ShadowRealms.default-src
affect what modules can be loaded via .importValue()
.There are several ways in which values can be passed between an incubator realm and a child realm:
.evaluate()
and .importValue()
Whenever a value crosses realms, the internal specification operation GetWrappedValue()
is used to wrap it:
A primitive value is returned without any changes.
A callable object is wrapped in a wrapped function. More on wrapped functions later.
Any other kind of object causes an exception:
> const sr = new ShadowRealm();
> sr.evaluate('globalThis')
TypeError: value passing between realms must be callable or primitive
> sr.evaluate('({prop: 123})')
TypeError: value passing between realms must be callable or primitive
> sr.evaluate('Object.prototype')
TypeError: value passing between realms must be callable or primitive
> typeof sr.evaluate('() => {}') // OK
'function'
> typeof sr.evaluate('123') // OK
'number'
Why aren’t non-callable object allowed to cross realms? The goal is complete separation between realms: Code executed in a ShadowRealm should never be able to access its incubator realm.
Objects have prototype chains. When transferring a prototype chain to another realm, we have two options:
We could only copy the first object in the prototype chain and let the chain continue in another realm. Alas, almost all prototype chains give us access to Function
which lets us execute arbitrary code in the source realm (see code below).
We could copy the complete prototype chain. However, there is no simple way to transfer objects such as Array.prototype
and Object.prototype
.
Almost all objects have the standard property .constructor
that refers to the class of the object:
> ({}).constructor === Object
true
> [].constructor === Array
true
Via this property, we can get access to globalThis
:
function handleObject(someObject) {
// Bad: the constructor of an object lets us create more instances
const constructor = someObject.constructor;
// Worse: the constructor of a constructor is `Function`
// which lets us execute arbitrary code in the realm of `someObject`
const _Function = constructor.constructor;
const _globalThis = (new _Function('return globalThis'))();
// We now have access to `globalThis` and can change global data
}
The ShadowRealms repository has an issue where this topic is discussed in more detail. The code above is inspired by a blog post by Joseph Griego.
We’ll see later how we can still transfer objects between realms (spoiler: it might be added as a feature in the future but can also be implemented via a library.)
Whenever a function crosses realms, it is wrapped:
This is how a wrapped function keeps realms separate:
Function call arguments enter the wrappee’s realm and are wrapped for that realm (via GetWrappedValue()
). The implicit argument this
is treated the same way.
Values returned by the wrappee enter the wrapper’s realm and are wrapped for that realm.
The wrappee is executed in a new scope whose parent scope is the birth scope of the wrappee. As a consequence, the global scope is the global scope of the wrappee’s realm.
new
-invoked.Note that wrapped functions are never unwrapped: If a wrapper is passed back into the wrappee’s realm, it is simply wrapped again. For details, see this issue.
The following code demonstrates what happens if a sloppy-mode function from a ShadowRealm is wrapped and called: Its this
is the globalThis
of the ShadowRealm, not the globalThis
of the incubator realm:
const sr = new ShadowRealm();
// .evaluate() executes code in sloppy mode
const sloppyFunc = sr.evaluate(`
(function () {
switch (this) {
case undefined:
return 'this is undefined';
case globalThis:
return 'this is globalThis';
default:
throw new Error();
}
})
`);
console.assert(sloppyFunc() === 'this is globalThis');
This is what happens if the wrappee is a strict-mode function:
const sr = new ShadowRealm();
const strictFunc = sr.evaluate(`
(function () {
'use strict';
switch (this) {
case undefined:
return 'this is undefined';
case globalThis:
return 'this is globalThis';
default:
throw new Error();
}
})
`);
console.assert(strictFunc() === 'this is undefined');
.evaluate()
evaluate its code? In code parsed by .evaluate()
, we can’t use static imports. Why? Due to top-level await
, static imports can be asynchronous – and .evaluate()
is supposed to work synchronously.
A workaround is to import dynamically via import()
:
// Not allowed:
import someFunc from 'some-module';
someFunc();
// Workaround 1:
import('some-module')
.then(({someFunc}) => {
someFunc();
});
// Workaround 2:
async function main() {
const {someFunc} = await import('some-module');
someFunc();
}
The grammar rule used by .evaluate()
is Script
– the same as it is for non-module <script>
elements. That means, it consists of a sequence of statements. The following subsections explore what that means for the code.
.evaluate()
returns a value. How does it do that given that its input is a statement?
In normal JavaScript code, statements don’t evaluate to values. However, the language specification defines so-called completions for statements. Those are used by .evaluate()
, eval()
, browser consoles, REPLs, etc. We can use eval()
to explore how completions work.
The completion of an expression statement is the value of the expression:
> eval(`'abc';`)
'abc'
> eval(`1 + 3;`)
4
The completion of a code block is the completion of the last statement in that block:
> eval(`{'first'; 'second';}`)
'second'
The completion of an if
statement is the completion of either the then-branch or the else-branch – depending on the condition:
> eval(`if (true) 'then'; else 'else';`)
'then'
> eval(`if (false) 'then'; else 'else';`)
'else'
> eval(`if (true) {'then'} else {'else'}`)
'then'
> eval(`if (false) {'then'} else {'else'}`)
'else'
There is a syntactic ambiguity between function declarations and function expressions.
Ordinary function definitions are parsed as function declarations. That is, .evaluate()
does not consider the following code to be an anonymous function expression:
> new ShadowRealm().evaluate(`function () {}`)
SyntaxError: Function statements must have a name.
The completion of a function declaration is undefined
:
> new ShadowRealm().evaluate(`function myFunc() {}`)
undefined
If we want .evaluate()
to return a function, we must ensure that the function definition is parsed as an expression, which we can do by wrapping it in parentheses:
> typeof new ShadowRealm().evaluate(`(function () {})`)
'function'
Note that arrow functions don’t have this issue – they are always parsed as expressions:
> typeof new ShadowRealm().evaluate(`() => {}`)
'function'
Another syntactic ambiguity is between code blocks and object literals. Normally, .evaluate()
interprets curly braces as code blocks:
> new ShadowRealm().evaluate(`{label: 'statement'}`)
'statement'
If we want to create an object, we neeed to put parentheses around the curly braces:
> new ShadowRealm().evaluate(`({label: 'statement'})`)
{ label: 'statement' }
By default, .evaluate()
uses sloppy (non-strict) mode and the so-called var
scope is the global scope. That means that var
declarations and function declarations create properties of the global object:
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`var varDecl = 1; 'varDecl' in globalThis`) === true
);
console.assert(
sr.evaluate(`function funcDecl() {} 'funcDecl' in globalThis`) === true
);
In contrast, a new lexical environment is created for each invocation of .evaluate()
. That environment is used by let
and const
declarations:
const sr = new ShadowRealm();
sr.evaluate(`let letDecl = 1`);
console.assert(
sr.evaluate(`let letDecl = 1; 'letDecl' in globalThis`) === false
);
// Global lexically scoped declarations are not remembered
// between invocations of .evaluate()
console.assert(
sr.evaluate('typeof letDecl') === 'undefined'
);
In strict mode, the lexical environment (that is created freshly for each invocation) is also used as the var
environment. Therefore, var
declarations and function declarations don’t create properties of the global object:
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`'use strict'; var varDecl = 1; 'varDecl' in globalThis`) === false
);
console.assert(
sr.evaluate(`'use strict'; function funcDecl() {} 'funcDecl' in globalThis`) === false
);
.importValue()
evaluate its code? The code used by .importValue()
is an ECMAScript module and we only import from the code. Therefore, none of the pitfalls of .evaluate()
exist:
There currently is no built-in support for transferring objects across realms. It might be added in the future. One possibility (but this is just my guess) is for it to work like transferrable objects (which is used for moving objects between from and to web workers).
However, transferring objects can also be implemented via a library. Caridy Patiño’s IRealm is a complete experimental solution. It is based on membranes.
Let’s explore what a very simply solution could look like. (Warning: don’t use this code in production. Among other things, it does not protect against code execution via Function
.)
The approach is as follows:
trapName
and the arguments args
to the wrapped object.One issue remains: When we evaluate code, we must post-process the result. It must first be local-wrapped in the ShadowRealm and then be foreign-wrapped in the incubator realm. Foreign-wrapping could be done by wrapping the ShadowRealm method .evaluate()
. But how do we local-wrap the evaluation result inside the ShadowRealm?
As it turns out, an elegant solution is to transfer function globalThis.eval()
of the ShadowRealm:
If we call the double-wrapped eval()
, the argument is evaluated inside the ShadowRealm. The result will be properly wrapped (twice), as will be any data that we extract from it (by accessing properties etc.).
// main.js
import {createShadowEval} from './membrane-lib.js';
const sr = new ShadowRealm();
const shadowEval = await createShadowEval(sr);
const obj = shadowEval(`
({
brand: 'Ford',
owner: {
first: 'Jane',
last: 'Doe',
},
})
`);
console.log(obj.brand); // 'Ford'
console.log(obj.owner.first); // 'Jane'
// membrane-lib.js
function isObject(value) {
return (
(value !== null && typeof value === 'object')
|| typeof value === 'function'
);
}
/**
* We can’t transfer a (non-callable) object across realms.
* But we can transfer a function that applies proxy-trapped operations
* to the object.
*/
export function wrapLocalValue(value) {
if (isObject(value)) {
return (trapName, ...args) => {
const wrappedArgs = args.map(arg => wrapForeignValue(arg));
const result = Reflect[trapName](value, ...wrappedArgs);
return wrapLocalValue(result);
};
} else {
return value;
}
}
/**
* To use a wrapped object from another realm in the current realm,
* we create a Proxy that feeds the operations it traps to the function.
*/
export function wrapForeignValue(value) {
if (!isObject(value)) {
return value;
}
// All handler methods follow the same pattern.
// To avoid repetitive code, we create it via a loop.
const handler = {};
for (const trapName of Object.getOwnPropertyNames(Reflect)) {
handler[trapName] = (_target, ...args) => {
const wrappedArgs = args.map(arg => wrapLocalValue(arg));
const result = value(trapName, ...wrappedArgs);
return wrapForeignValue(result);
};
}
const target = function () {};
return new Proxy(target, handler);
}
export async function createShadowEval(shadowRealm) {
const _getLocallyWrappedEval = await shadowRealm.importValue(import.meta.url, '_getLocallyWrappedEval');
return wrapForeignValue(_getLocallyWrappedEval());
}
export async function createShadowImport(shadowRealm) {
const _getLocallyWrappedImport = await shadowRealm.importValue(import.meta.url, '_getLocallyWrappedImport');
return wrapForeignValue(_getLocallyWrappedImport());
}
export function _getLocallyWrappedEval() {
return wrapLocalValue(globalThis.eval);
}
export function _getLocallyWrappedImport() {
return wrapLocalValue((moduleSpecifier) => import(moduleSpecifier));
}
For more information on JavaScript Proxies, see the chapter on them in “Deep JavaScript”.
Missing features of this library:
It doesn’t preserve identity: If two values are equal in one realm, they should also be equal after they are transferred to another realm. That could be implemented by caching the results of wrapping in a WeakMap.
It doesn’t prevent code executed in a ShadowRealm from accessing the eval()
and Function
of the incubator realm. That can be achieved by pre-populating the cache WeakMap with entries that map the incubator eval()
and Function
to functions that throw exceptions.
What can ShadowRealms be used for?
Web apps such as IDEs or paint apps can run third-party code such as plugins or filters.
Programming environments can run user code in ShadowRealms.
Servers can run third-party code in ShadowRealms.
Test runners can run tests in ShadowRealms so that the incubator realm isn’t affected and each suite can start in a fresh realm (which helps with reproducibility).
For web scraping (extracting data from web pages) and web app testing, web apps can be run in ShadowRealms.
The next two sections explain the last two items in more detail.
In this section we examine a very simple proof of concept of how we could run tests in ShadowRealms.
The test library collects tests that are specified via test()
and lets us run them via runTests()
.
// test-lib.js
const testDescs = [];
export function test(description, callback) {
testDescs.push({description, callback});
}
export function runTests() {
const testResults = [];
for (const testDesc of testDescs) {
try {
testDesc.callback();
testResults.push(`${testDesc.description}: OK\n`);
} catch (err) {
testResults.push(`${testDesc.description}: ${err}\n`);
}
}
return testResults.join('');
}
Test code uses the library to specify tests:
// my-test.js
import {test} from './test-lib.js';
import * as assert from './assertions.js';
test('succeeds', () => {
assert.equal(3, 3);
});
test('fails', () => {
assert.equal(1, 3);
});
export default true;
In the next example, we dynamically load module my-test.js
to collect the tests and then run them. Alas, there is currently no way to load a module without importing anything.
That’s why there is a default export in the last line of the previous example. We use the ShadowRealm method .importValue()
to import that default export.
// test-runner.js
async function runTestModule(moduleSpecifier) {
const sr = new ShadowRealm();
await sr.importValue(moduleSpecifier, 'default');
const runTests = await sr.importValue('./test-lib.js', 'runTests');
const result = runTests();
console.log(result);
}
await runTestModule('./my-test.js');
The library jsdom creates an encapsulated browser environment which can be used to test web apps, extract data out of HTML, etc. It currently uses the Node.js module vm
and could probably be updated to instead use ShadowRealms (with the benefit of the latter being cross-platform).
eval()
and Function
ShadowRealms are similar to eval()
and Function
but improve on them: We can create new realms and evaluate code in them. That protects the realm that triggers the evaluation from the actions performed by the code.
Web Workers are an even stronger isolation mechanism than ShadowRealms. Code in them runs in separate processes and communication is asynchronous. Therefore, ShadowRealms are a good choice whenever we want something more lightweight. We get the convenience of synchronous evaluation and have more freedom w.r.t. setting up global data.
As we have seen, each iframe has its own realm. We can synchronously execute code inside it:
<body>
<iframe>
</iframe>
<script>
globalThis.realm = 'incubator';
const iframeRealm = frames[0].window;
iframeRealm.globalThis.realm = 'child';
console.log(iframeRealm.eval('globalThis.realm')); // 'child'
</script>
</body>
Compared to ShadowRealms, there are the following downsides:
import()
won’t work anymore.vm
on Node.js Node’s vm
module is similar to the ShadowRealm API but has more features: caching JavaScript engine data for faster startup, intercepting import()
, setting up the global object externally (via vm.createContext()
), etc.
The proposal for compartments is currently at stage 1. It could build on ShadowRealms and allows the us to further control evaluation:
The proposal for module blocks is currently at stage 2 enables us to nest modules: We can create module blocks inside modules and each one of them defines a module.
Module blocks can be used in many locations where we currently use module specifiers. That makes them convenient for shadowRealm.importValue()
:
module insideCode {
export { runTests } from 'test-framework';
import './my-tests.js';
}
const sr = new ShadowRealm();
const runTests = await sr.importValue(insideCode, 'runTests');
The following people provided important feedback to this blog post:
Sources of this blog post:
ECMAScript proposal “ShadowRealm API” by Dave Herman, Caridy Patiño, Mark S. Miller, Leo Balter, and Rick Waldron.
The proposal has a section that lists presentations whose slides I found helpful.
Related topics:
Section “Strict mode vs. sloppy mode” in “JavaScript for impatient programmers”
Section “Global variables and the global object” in “JavaScript for impatient programmers”
Chapter “Evaluating code dynamically: eval()
, new Function()
” in “JavaScript for impatient programmers”