JavaScript's upcoming Explicit Resource Management is great!
Last updated
I've been using the quickjs-emscripten package a bit recently. It's a Web Assembly compiled wrapper around Fabrice Bellard's QuickJS, a small & modern JavaScript engine.
This allows me to execute sandboxed JavaScript in an environment that is completely controlled!
For example, here's some code showing how arbitrary JavaScript code can be evaluated safely. It isolates the code so it
doesn't have access to the outside environment with the exception of a special location.search
global variable, which
reflects the browser's query string as a readonly value.
It also tracks reads to that query string in an accessLog
:
safeEval.ts
import { getQuickJS } from 'quickjs-emscripten';
async function safeEval(code: string) {
const QuickJS = await getQuickJS();
const runtimeStart = Date.now();
const runtime = QuickJS.newRuntime({
interruptHandler: () => {
// โฐ Abort if execution lasts more than 100ms
return Date.now() - runtimeStart > 100;
},
});
const ctx = runtime.newContext();
// ๐ Keep track of any access to the location
const accessLog: string[] = [];
// ๐ค Simulate in the sandbox:
// var location = {}
// Object.defineProperty(location, 'search', { ... });
const locationHandle = ctx.newObject();
ctx.setProp(ctx.global, 'location', locationHandle);
ctx.defineProp(locationHandle, 'search', {
configurable: false,
enumerable: true,
get: () => {
// Sandbox code evaluated location.search!
accessLog.push('search');
// Return a string value that can be understood by the sandbox
return ctx.newString(window.location.search);
},
});
// ๐ฆพ Safely evaluate the untrusted code
const result = ctx.evalCode(code, 'untrusted.js');
if (result.error) {
// ๐ฑ Something bad happened, clean up
result.error.dispose();
locationHandle.dispose();
ctx.dispose();
runtime.dispose();
throw new Error('Error executing code');
}
// ๐ Convert the evaluated sandbox value to something we can read
const evaluationResult = ctx.dump(result.unwrap());
// ๐งน Clean up
result.dispose();
locationHandle.dispose();
ctx.dispose();
runtime.dispose();
// โจ And we're done!
return { evaluationResult, accessLog };
}
While this works great, it's a bit awkward to use because it requires explicit disposal of "handle" objects that reference values inside of the QuickJS engine.
See all those .dispose()
calls? If one is missed, it causes a memory leak.
And to make matters worse, sometimes you need to exit early, or catch exceptions, or do all sorts of things which make
it complicated to keep track of all of the objects that need to be .dispose()
d.
No need to fret, Explicit Resource Management makes this much easier!
Explicit Resource Management is an ECMAScript language
proposal that introduces a new keyword using
, which is similar to const
, but is used to define resources that need
to be disposed when they leave scope.
It means that the above code could be rewritten as:
safeEval.ts
import { getQuickJS } from 'quickjs-emscripten';
async function safeEval(code: string) {
const QuickJS = await getQuickJS();
const runtimeStart = Date.now();
// โจ New syntax: declare a auto-disposed resource
using runtime = QuickJS.newRuntime({
interruptHandler: () => {
// โฐ Abort if execution lasts more than 100ms
return Date.now() - runtimeStart > 100;
},
});
using ctx = runtime.newContext();
// ๐ Keep track of any access to the location
const accessLog: string[] = [];
// ๐ค Simulate in the sandbox:
// var location = {}
// Object.defineProperty(location, 'search', { ... });
using locationHandle = ctx.newObject();
ctx.setProp(ctx.global, 'location', locationHandle);
ctx.defineProp(locationHandle, 'search', {
configurable: false,
enumerable: true,
get: () => {
// Sandbox code evaluated location.search!
accessLog.push('search');
// Return a string value that can be understood by the sandbox
return ctx.newString(window.location.search);
},
});
// ๐ฆพ Safely evaluate the untrusted code
using result = ctx.evalCode(code, 'untrusted.js');
if (result.error) {
// ๐ฑ Something bad happened!
throw new Error('Error executing code');
}
// ๐ Convert the evaluated sandbox value to something we can read
const evaluationResult = ctx.dump(result.unwrap());
// โจ And we're done!
return { evaluationResult, accessLog };
}
See that? No more .dispose()
calls!
Regardless of how scope is left (here, variables may go out of scope when an exception is thrown or we return), the objects are automatically disposed when execution leaves the syntactic scope.
And because this feature has reached Stage 3, it's both very likely to be coming soon to the standard language and can be used today if you build your JavaScript.
Using it today
I use TypeScript
, prettier
, eslint
, esbuild
, vite
, and neovim
to develop and build my code. And all of them fully
support this proposal and esbuild can produce code that performs disposal correctly in JS engines that haven't yet
implemented the functionality.
Here's how to get them working:
TypeScript:
You must use TypeScript 5.2 or above.
Your tsconfig.json
must have:
compilerOptions.lib
containing"ES2019"
and"esnext.disposable"
compilerOptions.target
set to something lower than"esnext"
, I use"ES2019"
since 5 years old is a reasonable target for my projects.
My tsconfig.json
looks like this:
tsconfig.json
{
"compilerOptions": {
"lib": ["ES2019", "esnext.disposable", "DOM"],
"target": "ES2019",
},
// ...
}
prettier:
Supported out of the box in prettier version 3.0.3 or later.
eslint:
I use typescript-eslint/parser
, which got support in typescript-eslint version
7.13.1.
esbuild:
Implemented in version 0.18.17, bugfixes in version 0.18.19.
You must specify a target of something lower than esnext
. Same with
TypeScript, I use es2019
.
vite:
Since vite uses esbuild under the hood to bundle, you'll need to set a target of es2019
(or whatever works best for
you) when building:
vite.config.js
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
base: './',
build: {
assetsDir: './',
target: 'es2019',
},
esbuild: {
target: 'es2019',
},
});
vim/neovim:
To get the using
keyword working correctly, you need to tell vim/neovim that using
is a keyword.
There's probably a better way to do this, but what I ended up doing was adding two files (~/.config/nvim/after/syntax/javascript.vim
and ~/.config/nvim/after/syntax/typescript.vim
) with the same contents:
javascript.vim
syn keyword esNextKeyword using
hi def link esNextKeyword Keyword
This tells vim that after loading the syntax files for javascript/typescript (and javascriptreact/typescriptreact),
it should treat using
as a new keyword in a class named esNextKeyword
. And link that new class to the Keyword
definition.
Note: Stage 3 is not a guarantee that it will ship as-is in browsers and other JavaScript engines.
I personally think it will ship, since it will lead to standardizing a well-known pattern (RAII) found in other languages.
Resources holding resources
The nice thing about this proposal is that it's just enough of a change to allow for new patterns to emerge.
Say you've got a class which owns and manages a set of resources. The problem is now that class instances needs to dispose of its resources when it's no longer used.
Now you can just add a Symbol.dispose
method on your class, and it can be disposed in the same way as the resources it
manages.
This pattern looks like this:
class ResourceManager implements Disposable {
// New interface! ^^^^^^^^^^
resource1: SomeResource;
resource2: SomeOtherResource;
constructor() {
// "Own" some resources that should be disposed when this instance is disposed
this.resource1 = allocateSomeResource();
this.resource2 = allocateSomeOtherResource();
}
[Symbol.dispose]() {
this.resource1[Symbol.dispose]();
this.resource2[Symbol.dispose]();
}
}
function doWork() {
using manager = new ResourceManager();
// do stuff
return; // โจ Owned resources automatically disposed!
}
This lets us do RAII in JavaScript easily, which can help avoid resource leaks and better standardize the structure of code that manages resources.