UNPKG

23.2 kBMarkdownView Raw
1# quickjs-emscripten
2
3Javascript/Typescript bindings for QuickJS, a modern Javascript interpreter,
4compiled to WebAssembly.
5
6- Safely evaluate untrusted Javascript (up to ES2020).
7- Create and manipulate values inside the QuickJS runtime ([more][values]).
8- Expose host functions to the QuickJS runtime ([more][functions]).
9- Execute synchronous code that uses asynchronous functions, with [asyncify][asyncify].
10
11[Github] | [NPM] | [API Documentation][api] | [Examples][tests]
12
13```typescript
14import { getQuickJS } from "quickjs-emscripten"
15
16async function main() {
17 const QuickJS = await getQuickJS()
18 const vm = QuickJS.newContext()
19
20 const world = vm.newString("world")
21 vm.setProp(vm.global, "NAME", world)
22 world.dispose()
23
24 const result = vm.evalCode(`"Hello " + NAME + "!"`)
25 if (result.error) {
26 console.log("Execution failed:", vm.dump(result.error))
27 result.error.dispose()
28 } else {
29 console.log("Success:", vm.dump(result.value))
30 result.value.dispose()
31 }
32
33 vm.dispose()
34}
35
36main()
37```
38
39[github]: https://github.com/justjake/quickjs-emscripten
40[npm]: https://www.npmjs.com/package/quickjs-emscripten
41[api]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/modules.md
42[tests]: https://github.com/justjake/quickjs-emscripten/blob/main/ts/quickjs.test.ts
43[values]: #interfacing-with-the-interpreter
44[asyncify]: #asyncify
45[functions]: #exposing-apis
46
47## Usage
48
49Install from `npm`: `npm install --save quickjs-emscripten` or `yarn add quickjs-emscripten`.
50
51The root entrypoint of this library is the `getQuickJS` function, which returns
52a promise that resolves to a [QuickJS singleton](./doc/classes/quickjs.md) when
53the QuickJS WASM module is ready.
54
55Once `getQuickJS` has been awaited at least once, you also can use the `getQuickJSSync`
56function to directly access the singleton engine in your synchronous code.
57
58### Safely evaluate Javascript code
59
60See [QuickJS.evalCode](https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/quickjs.md#evalcode)
61
62```typescript
63import { getQuickJS, shouldInterruptAfterDeadline } from "quickjs-emscripten"
64
65getQuickJS().then((QuickJS) => {
66 const result = QuickJS.evalCode("1 + 1", {
67 shouldInterrupt: shouldInterruptAfterDeadline(Date.now() + 1000),
68 memoryLimitBytes: 1024 * 1024,
69 })
70 console.log(result)
71})
72```
73
74### Interfacing with the interpreter
75
76You can use [QuickJSContext](https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/QuickJSContext.md)
77to build a scripting environment by modifying globals and exposing functions
78into the QuickJS interpreter.
79
80Each `QuickJSContext` instance has its own environment -- globals, built-in
81classes -- and actions from one context won't leak into other contexts or
82runtimes (with one exception, see [Asyncify][asyncify]).
83
84Every context is created inside a
85[QuickJSRuntime](https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/QuickJSRuntime.md).
86A runtime represents a Javascript heap, and you can even share values between
87contexts in the same runtime.
88
89```typescript
90const vm = QuickJS.newContext()
91let state = 0
92
93const fnHandle = vm.newFunction("nextId", () => {
94 return vm.newNumber(++state)
95})
96
97vm.setProp(vm.global, "nextId", fnHandle)
98fnHandle.dispose()
99
100const nextId = vm.unwrapResult(vm.evalCode(`nextId(); nextId(); nextId()`))
101console.log("vm result:", vm.getNumber(nextId), "native state:", state)
102
103nextId.dispose()
104vm.dispose()
105```
106
107When you create a context from a top-level API like in the example above,
108instead of by calling `runtime.newContext()`, a runtime is automatically created
109for the lifetime of the context, and disposed of when you dispose the context.
110
111#### Runtime
112
113The runtime has APIs for CPU and memory limits that apply to all contexts within
114the runtime in aggregate. You can also use the runtime to configure EcmaScript
115module loading.
116
117```typescript
118const runtime = QuickJS.newRuntime()
119// "Should be enough for everyone" -- attributed to B. Gates
120runtime.setMemoryLimit(1024 * 640)
121// Limit stack size
122runtime.setMaxStackSize(1024 * 320)
123// Interrupt computation after 1024 calls to the interrupt handler
124let interruptCycles = 0
125runtime.setInterruptHandler(() => ++interruptCycles > 1024)
126// Toy module system that always returns the module name
127// as the default export
128runtime.setModuleLoader((moduleName) => `export default '${moduleName}'`)
129const context = runtime.newContext()
130const ok = context.evalCode(`
131import fooName from './foo.js'
132globalThis.result = fooName
133`)
134context.unwrapResult(ok).dispose()
135// logs "foo.js"
136console.log(context.getProp(context.global, "result").consume(context.dump))
137context.dispose()
138runtime.dispose()
139```
140
141### Memory Management
142
143Many methods in this library return handles to memory allocated inside the
144WebAssembly heap. These types cannot be garbage-collected as usual in
145Javascript. Instead, you must manually manage their memory by calling a
146`.dispose()` method to free the underlying resources. Once a handle has been
147disposed, it cannot be used anymore. Note that in the example above, we call
148`.dispose()` on each handle once it is no longer needed.
149
150Calling `QuickJSContext.dispose()` will throw a RuntimeError if you've forgotten to
151dispose any handles associated with that VM, so it's good practice to create a
152new VM instance for each of your tests, and to call `vm.dispose()` at the end
153of every test.
154
155```typescript
156const vm = QuickJS.newContext()
157const numberHandle = vm.newNumber(42)
158// Note: numberHandle not disposed, so it leaks memory.
159vm.dispose()
160// throws RuntimeError: abort(Assertion failed: list_empty(&rt->gc_obj_list), at: quickjs/quickjs.c,1963,JS_FreeRuntime)
161```
162
163Here are some strategies to reduce the toil of calling `.dispose()` on each
164handle you create:
165
166#### Scope
167
168A
169[`Scope`](https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/scope.md#class-scope)
170instance manages a set of disposables and calls their `.dispose()`
171method in the reverse order in which they're added to the scope. Here's the
172"Interfacing with the interpreter" example re-written using `Scope`:
173
174```typescript
175Scope.withScope((scope) => {
176 const vm = scope.manage(QuickJS.newContext())
177 let state = 0
178
179 const fnHandle = scope.manage(
180 vm.newFunction("nextId", () => {
181 return vm.newNumber(++state)
182 })
183 )
184
185 vm.setProp(vm.global, "nextId", fnHandle)
186
187 const nextId = scope.manage(vm.unwrapResult(vm.evalCode(`nextId(); nextId(); nextId()`)))
188 console.log("vm result:", vm.getNumber(nextId), "native state:", state)
189
190 // When the withScope block exits, it calls scope.dispose(), which in turn calls
191 // the .dispose() methods of all the disposables managed by the scope.
192})
193```
194
195You can also create `Scope` instances with `new Scope()` if you want to manage
196calling `scope.dispose()` yourself.
197
198#### `Lifetime.consume(fn)`
199
200[`Lifetime.consume`](https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/lifetime.md#consume)
201is sugar for the common pattern of using a handle and then
202immediately disposing of it. `Lifetime.consume` takes a `map` function that
203produces a result of any type. The `map` fuction is called with the handle,
204then the handle is disposed, then the result is returned.
205
206Here's the "Interfacing with interpreter" example re-written using `.consume()`:
207
208```typescript
209const vm = QuickJS.newContext()
210let state = 0
211
212vm.newFunction("nextId", () => {
213 return vm.newNumber(++state)
214}).consume((fnHandle) => vm.setProp(vm.global, "nextId", fnHandle))
215
216vm.unwrapResult(vm.evalCode(`nextId(); nextId(); nextId()`)).consume((nextId) =>
217 console.log("vm result:", vm.getNumber(nextId), "native state:", state)
218)
219
220vm.dispose()
221```
222
223Generally working with `Scope` leads to more straight-forward code, but
224`Lifetime.consume` can be handy sugar as part of a method call chain.
225
226### Exposing APIs
227
228To add APIs inside the QuickJS environment, you'll need to create objects to
229define the shape of your API, and add properties and functions to those objects
230to allow code inside QuickJS to call code on the host.
231
232By default, no host functionality is exposed to code running inside QuickJS.
233
234```typescript
235const vm = QuickJS.newContext()
236// `console.log`
237const logHandle = vm.newFunction("log", (...args) => {
238 const nativeArgs = args.map(vm.dump)
239 console.log("QuickJS:", ...nativeArgs)
240})
241// Partially implement `console` object
242const consoleHandle = vm.newObject()
243vm.setProp(consoleHandle, "log", logHandle)
244vm.setProp(vm.global, "console", consoleHandle)
245consoleHandle.dispose()
246logHandle.dispose()
247
248vm.unwrapResult(vm.evalCode(`console.log("Hello from QuickJS!")`)).dispose()
249```
250
251#### Promises
252
253To expose an asynchronous function that _returns a promise_ to callers within
254QuickJS, your function can return the handle of a `QuickJSDeferredPromise`
255created via `context.newPromise()`.
256
257When you resolve a `QuickJSDeferredPromise` -- and generally whenever async
258behavior completes for the VM -- pending listeners inside QuickJS may not
259execute immediately. Your code needs to explicitly call
260`runtime.executePendingJobs()` to resume execution inside QuickJS. This API
261gives your code maximum control to _schedule_ when QuickJS will block the host's
262event loop by resuming execution.
263
264To work with QuickJS handles that contain a promise inside the environment, you
265can convert the QuickJSHandle into a native promise using
266`context.resolvePromise()`. Take care with this API to avoid 'deadlocks' where
267the host awaits a guest promise, but the guest cannot make progress until the
268host calls `runtime.executePendingJobs()`. The simplest way to avoid this kind
269of deadlock is to always schedule `executePendingJobs` after any promise is
270settled.
271
272```typescript
273const vm = QuickJS.newContext()
274const fakeFileSystem = new Map([["example.txt", "Example file content"]])
275
276// Function that simulates reading data asynchronously
277const readFileHandle = vm.newFunction("readFile", (pathHandle) => {
278 const path = vm.getString(pathHandle)
279 const promise = vm.newPromise()
280 setTimeout(() => {
281 const content = fakeFileSystem.get(path)
282 promise.resolve(vm.newString(content || ""))
283 }, 100)
284 // IMPORTANT: Once you resolve an async action inside QuickJS,
285 // call runtime.executePendingJobs() to run any code that was
286 // waiting on the promise or callback.
287 promise.settled.then(vm.runtime.executePendingJobs)
288 return promise.handle
289})
290readFileHandle.consume((handle) => vm.setProp(vm.global, "readFile", handle))
291
292// Evaluate code that uses `readFile`, which returns a promise
293const result = vm.evalCode(`(async () => {
294 const content = await readFile('example.txt')
295 return content.toUpperCase()
296})()`)
297const promiseHandle = vm.unwrapResult(result)
298
299// Convert the promise handle into a native promise and await it.
300// If code like this deadlocks, make sure you are calling
301// runtime.executePendingJobs appropriately.
302const resolvedResult = await vm.resolvePromise(promiseHandle)
303promiseHandle.dispose()
304const resolvedHandle = vm.unwrapResult(resolvedResult)
305console.log("Result:", vm.getString(resolvedHandle))
306resolvedHandle.dispose()
307```
308
309#### Asyncify
310
311Sometimes, we want to create a function that's synchronous from the perspective
312of QuickJS, but prefer to implement that function _asynchronously_ in your host
313code. The most obvious use-case is for EcmaScript module loading. The underlying
314QuickJS C library expects the module loader function to return synchronously,
315but loading data synchronously in the browser or server is somewhere between "a
316bad idea" and "impossible". QuickJS also doesn't expose an API to "pause" the
317execution of a runtime, and adding such an API is tricky due to the VM's
318implementation.
319
320As a work-around, we provide an alternate build of QuickJS processed by
321Emscripten/Binaryen's [ASYNCIFY](https://emscripten.org/docs/porting/asyncify.html)
322compiler transform. Here's how Emscripten's documentation describes Asyncify:
323
324> Asyncify lets synchronous C or C++ code interact with asynchronous \[host] JavaScript. This allows things like:
325>
326> - A synchronous call in C that yields to the event loop, which allows browser events to be handled.
327>
328> - A synchronous call in C that waits for an asynchronous operation in \[host] JS to complete.
329>
330> Asyncify automatically transforms ... code into a form that can be paused and
331> resumed ..., so that it is asynchronous (hence the name “Asyncify”) even though
332> \[it is written] in a normal synchronous way.
333
334This means we can suspend an _entire WebAssembly module_ (which could contain
335multiple runtimes and contexts) while our host Javascript loads data
336asynchronously, and then resume execution once the data load completes. This is
337a very handy superpower, but it comes with a couple of major limitations:
338
3391. _An asyncified WebAssembly module can only suspend to wait for a single
340 asynchronous call at a time_. You may call back into a suspended WebAssembly
341 module eg. to create a QuickJS value to return a result, but the system will
342 crash if this call tries to suspend again. Take a look at Emscripten's documentation
343 on [reentrancy](https://emscripten.org/docs/porting/asyncify.html#reentrancy).
344
3452. _Asyncified code is bigger and runs slower_. The asyncified build of
346 Quickjs-emscripten library is 1M, 2x larger than the 500K of the default
347 version. There may be room for further
348 [optimization](https://emscripten.org/docs/porting/asyncify.html#optimizing)
349 Of our build in the future.
350
351To use asyncify features, use the following functions:
352
353- [newAsyncRuntime][]: create a runtime inside a new WebAssembly module.
354- [newAsyncContext][]: create runtime and context together inside a new
355 WebAssembly module.
356- [newQuickJSAsyncWASMModule][]: create an empty WebAssembly module.
357
358[newasyncruntime]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/modules.md#newasyncruntime
359[newasynccontext]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/modules.md#newasynccontext
360[newquickjsasyncwasmmodule]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/modules.md#newquickjsasyncwasmmodule
361
362These functions are asynchronous because they always create a new underlying
363WebAssembly module so that each instance can suspend and resume independently,
364and instantiating a WebAssembly module is an async operation. This also adds
365substantial overhead compared to creating a runtime or context inside an
366existing module; if you only need to wait for a single async action at a time,
367you can create a single top-level module and create runtimes or contexts inside
368of it.
369
370##### Async module loader
371
372Here's an example of valuating a script that loads React asynchronously as an ES
373module. In our example, we're loading from the filesystem for reproducibility,
374but you can use this technique to load using `fetch`.
375
376```typescript
377const module = await newQuickJSAsyncWASMModule()
378const runtime = module.newRuntime()
379const path = await import("path")
380const { promises: fs } = await import("fs")
381
382const importsPath = path.join(__dirname, "../examples/imports") + "/"
383// Module loaders can return promises.
384// Execution will suspend until the promise resolves.
385runtime.setModuleLoader((moduleName) => {
386 const modulePath = path.join(importsPath, moduleName)
387 if (!modulePath.startsWith(importsPath)) {
388 throw new Error("out of bounds")
389 }
390 console.log("loading", moduleName, "from", modulePath)
391 return fs.readFile(modulePath, "utf-8")
392})
393
394// evalCodeAsync is required when execution may suspend.
395const context = runtime.newContext()
396const result = await context.evalCodeAsync(`
397import * as React from 'esm.sh/react@17'
398import * as ReactDOMServer from 'esm.sh/react-dom@17/server'
399const e = React.createElement
400globalThis.html = ReactDOMServer.renderToStaticMarkup(
401 e('div', null, e('strong', null, 'Hello world!'))
402)
403`)
404context.unwrapResult(result).dispose()
405const html = context.getProp(context.global, "html").consume(context.getString)
406console.log(html) // <div><strong>Hello world!</strong></div>
407```
408
409##### Async on host, sync in QuickJS
410
411Here's an example of turning an async function into a sync function inside the
412VM.
413
414```typescript
415const context = await newAsyncContext()
416const path = await import("path")
417const { promises: fs } = await import("fs")
418
419const importsPath = path.join(__dirname, "../examples/imports") + "/"
420const readFileHandle = context.newAsyncifiedFunction("readFile", async (pathHandle) => {
421 const pathString = path.join(importsPath, context.getString(pathHandle))
422 if (!pathString.startsWith(importsPath)) {
423 throw new Error("out of bounds")
424 }
425 const data = await fs.readFile(pathString, "utf-8")
426 return context.newString(data)
427})
428readFileHandle.consume((fn) => context.setProp(context.global, "readFile", fn))
429
430// evalCodeAsync is required when execution may suspend.
431const result = await context.evalCodeAsync(`
432// Not a promise! Sync! vvvvvvvvvvvvvvvvvvvv
433const data = JSON.parse(readFile('data.json'))
434data.map(x => x.toUpperCase()).join(' ')
435`)
436const upperCaseData = context.unwrapResult(result).consume(context.getString)
437console.log(upperCaseData) // 'VERY USEFUL DATA'
438```
439
440### Testing your code
441
442This library is complicated to use, so please consider automated testing your
443implementation. We highly writing your test suite to run with both the "release"
444build variant of quickjs-emscripten, and also the [DEBUG_SYNC] build variant.
445The debug sync build variant has extra instrumentation code for detecting memory
446leaks.
447
448The class [TestQuickJSWASMModule] exposes the memory leak detection API, although
449this API is only accurate when using `DEBUG_SYNC` variant.
450
451```typescript
452// Define your test suite in a function, so that you can test against
453// different module loaders.
454function myTests(moduleLoader: () => Promise<QuickJSWASMModule>) {
455 let QuickJS: TestQuickJSWASMModule
456 beforeEach(async () => {
457 // Get a unique TestQuickJSWASMModule instance for each test.
458 const wasmModule = await moduleLoader()
459 QuickJS = new TestQuickJSWASMModule(wasmModule)
460 })
461 afterEach(() => {
462 // Assert that the test disposed all handles. The DEBUG_SYNC build
463 // variant will show detailed traces for each leak.
464 QuickJS.assertNoMemoryAllocated()
465 })
466
467 it("works well", () => {
468 // TODO: write a test using QuickJS
469 const context = QuickJS.newContext()
470 context.unwrapResult(context.evalCode("1 + 1")).dispose()
471 context.dispose()
472 })
473}
474
475// Run the test suite against a matrix of module loaders.
476describe("Check for memory leaks with QuickJS DEBUG build", () => {
477 const moduleLoader = memoizePromiseFactory(() => newQuickJSWASMModule(DEBUG_SYNC))
478 myTests(moduleLoader)
479})
480
481describe("Realistic test with QuickJS RELEASE build", () => {
482 myTests(getQuickJS)
483})
484```
485
486For more testing examples, please explore the typescript source of [quickjs-emscripten][ts] repository.
487
488[ts]: https://github.com/justjake/quickjs-emscripten/blob/main/ts
489[debug_sync]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/modules.md#debug_sync
490[testquickjswasmmodule]: https://github.com/justjake/quickjs-emscripten/blob/main/doc/classes/TestQuickJSWASMModule.md
491
492### Debugging
493
494- Switch to a DEBUG build variant of the WebAssembly module to see debug log messages from the C part of this library.
495- Set `process.env.QTS_DEBUG` to see debug log messages from the Javascript part of this library.
496
497### More Documentation
498
499[Github] | [NPM] | [API Documentation][api] | [Examples][tests]
500
501## Background
502
503This was inspired by seeing https://github.com/maple3142/duktape-eval
504[on Hacker News](https://news.ycombinator.com/item?id=21946565) and Figma's
505blogposts about using building a Javascript plugin runtime:
506
507- [How Figma built the Figma plugin system](https://www.figma.com/blog/how-we-built-the-figma-plugin-system/): Describes the LowLevelJavascriptVm interface.
508- [An update on plugin security](https://www.figma.com/blog/an-update-on-plugin-security/): Figma switches to QuickJS.
509
510## Status & Roadmap
511
512**Stability**: Because the version number of this project is below `1.0.0`,
513\*expect occasional breaking API changes.
514
515**Security**: This project makes every effort to be secure, but has not been
516audited. Please use with care in production settings.
517
518**Roadmap**: I work on this project in my free time, for fun. Here's I'm
519thinking comes next. Last updated 2022-03-18.
520
5211. Further work on module loading APIs:
522
523 - Create modules via Javascript, instead of source text.
524 - Scan source text for imports, for ahead of time or concurrent loading.
525 (This is possible with third-party tools, so lower priority.)
526
5272. Higher-level tools for reading QuickJS values:
528
529 - Type guard functions: `context.isArray(handle)`, `context.isPromise(handle)`, etc.
530 - Iteration utilities: `context.getIterable(handle)`, `context.iterateObjectEntries(handle)`.
531 This better supports user-level code to deserialize complex handle objects.
532
5333. Higher-level tools for creating QuickJS values:
534
535 - Devise a way to avoid needing to mess around with handles when setting up
536 the environment.
537 - Consider integrating
538 [quickjs-emscripten-sync](https://github.com/reearth/quickjs-emscripten-sync)
539 for automatic translation.
540 - Consider class-based or interface-type-based marshalling.
541
5424. EcmaScript Modules / WebAssembly files / Deno support. This requires me to
543 learn a lot of new things, but should be interesting for modern browser usage.
544
5455. SQLite integration.
546
547## Related
548
549- Duktape wrapped in Wasm: https://github.com/maple3142/duktape-eval/blob/main/src/Makefile
550- QuickJS wrapped in C++: https://github.com/ftk/quickjspp
551
552## Developing
553
554This library is implemented in two languages: C (compiled to WASM with
555Emscripten), and Typescript.
556
557### The C parts
558
559The ./c directory contains C code that wraps the QuickJS C library (in ./quickjs).
560Public functions (those starting with `QTS_`) in ./c/interface.c are
561automatically exported to native code (via a generated header) and to
562Typescript (via a generated FFI class). See ./generate.ts for how this works.
563
564The C code builds as both with `emscripten` (using `emcc`), to produce WASM (or
565ASM.js) and with `clang`. Build outputs are checked in, so you can iterate on
566the Javascript parts of the library without setting up the Emscripten toolchain.
567
568Intermediate object files from QuickJS end up in ./build/quickjs/.
569
570This project uses `emscripten 3.1.7` via Docker. You will need a working `docker`
571install to build the Emscripten artifacts.
572
573Related NPM scripts:
574
575- `yarn update-quickjs` will sync the ./quickjs folder with a
576 github repo tracking the upstream QuickJS.
577- `yarn make-debug` will rebuild C outputs into ./build/wrapper
578- `yarn make-release` will rebuild C outputs in release mode, which is the mode
579 that should be checked into the repo.
580
581### The Typescript parts
582
583The ./ts directory contains Typescript types and wraps the generated Emscripten
584FFI in a more usable interface.
585
586You'll need `node` and `npm` or `yarn`. Install dependencies with `npm install`
587or `yarn install`.
588
589- `yarn build` produces ./dist.
590- `yarn test` runs the tests.
591- `yarn test --watch` watches for changes and re-runs the tests.
592
593### Yarn updates
594
595Just run `yarn set version from sources` to upgrade the Yarn release.