@ohos-ports/quickjs-wasi 3.6.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,846 @@
1
+ # quickjs-wasi
2
+
3
+ A snapshotable JavaScript runtime via WebAssembly. Runs [QuickJS](https://github.com/quickjs-ng/quickjs) compiled to WASM, with the ability to **snapshot the entire VM state** (including pending promises) and **restore it in a fresh WASM instance**.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install quickjs-wasi
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Loading the WASM binary
14
+
15
+ The caller is responsible for providing the WASM bytes (or a pre-compiled `WebAssembly.Module`): quickjs-wasi does no implicit filesystem or network I/O. The package ships the binary at the `quickjs-wasi/quickjs.wasm` subpath, which can be resolved by your environment's preferred mechanism.
16
+
17
+ Node.js (read from disk):
18
+
19
+ ```typescript
20
+ import { readFile } from 'node:fs/promises';
21
+
22
+ const wasmBytes = await readFile(new URL(import.meta.resolve('quickjs-wasi/quickjs.wasm')));
23
+ // or with require.resolve in CJS:
24
+ // readFileSync(require.resolve('quickjs-wasi/quickjs.wasm'))
25
+ ```
26
+
27
+ Browser (`fetch` + streaming compile):
28
+
29
+ ```typescript
30
+ const wasmModule = await WebAssembly.compileStreaming(fetch('/quickjs.wasm'));
31
+ ```
32
+
33
+ Vite / bundlers using the `?url` import suffix:
34
+
35
+ ```typescript
36
+ import wasmUrl from 'quickjs-wasi/quickjs.wasm?url';
37
+
38
+ const wasmModule = await WebAssembly.compileStreaming(fetch(wasmUrl));
39
+ ```
40
+
41
+ Compile a `WebAssembly.Module` once and reuse it across VMs: instantiation from a compiled module skips recompiling the ~620 KB binary on every `QuickJS.create()` call.
42
+
43
+ ### Basic evaluation
44
+
45
+ Both `QuickJS` and `JSValueHandle` implement `Symbol.dispose`, so you can use `using` declarations for automatic cleanup:
46
+
47
+ ```typescript
48
+ import { QuickJS } from 'quickjs-wasi';
49
+
50
+ {
51
+ using vm = await QuickJS.create({ wasm: wasmBytes });
52
+
53
+ // Evaluate code. Handles are auto-disposed with `using`
54
+ using result = vm.evalCode('1 + 2');
55
+ console.log(result.toNumber()); // 3
56
+ } // vm and result are automatically disposed here
57
+ ```
58
+
59
+ ### Working with values
60
+
61
+ ```typescript
62
+ using vm = await QuickJS.create(wasmBytes);
63
+
64
+ // Create values. `using` ensures they're disposed at end of scope
65
+ {
66
+ using str = vm.newString('hello');
67
+ using num = vm.newNumber(42);
68
+ using big = vm.newBigInt(9007199254740993n);
69
+ vm.setProp(vm.global, 'message', str);
70
+ }
71
+
72
+ // Read back the value
73
+ using msg = vm.evalCode('message');
74
+ console.log(msg.toString()); // "hello"
75
+
76
+ // Convert host values to QuickJS handles (and back)
77
+ using handle = vm.hostToHandle({ x: 1, y: [2, 3] });
78
+ const dumped = vm.dump(handle); // { x: 1, y: [2, 3] }
79
+
80
+ // `consume()` is useful for inline one-liners
81
+ const value = vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3
82
+ ```
83
+
84
+ ### Host functions
85
+
86
+ Register JavaScript functions backed by host (Node.js) callbacks:
87
+
88
+ ```typescript
89
+ using vm = await QuickJS.create(wasmBytes);
90
+
91
+ // The callback receives the call arguments as handles. The guest `this`
92
+ // value is the callback's `this` binding, so use a regular `function`
93
+ // expression (not an arrow function) if you need to access it.
94
+ {
95
+ using add = vm.newFunction('add', (...args) => {
96
+ return vm.newNumber(args[0].toNumber() + args[1].toNumber());
97
+ });
98
+ vm.setProp(vm.global, 'add', add);
99
+ }
100
+
101
+ using result = vm.evalCode('add(3, 4)');
102
+ console.log(result.toNumber()); // 7
103
+ ```
104
+
105
+ ### Promises and async host functions
106
+
107
+ Bridge async host operations into the QuickJS sandbox:
108
+
109
+ ```typescript
110
+ using vm = await QuickJS.create(wasmBytes);
111
+
112
+ // Create an async host function that returns a promise to QuickJS
113
+ {
114
+ using dnsResolve = vm.newFunction('dnsResolve', (...args) => {
115
+ const hostname = args[0].toString();
116
+ const deferred = vm.newPromise();
117
+
118
+ // Do real async work on the host side
119
+ dns.resolve4(hostname).then(
120
+ (addresses) => {
121
+ deferred.resolve(vm.newString(addresses[0]));
122
+ vm.executePendingJobs(); // drain the QuickJS job queue
123
+ },
124
+ (err) => {
125
+ deferred.reject(vm.newError(err));
126
+ vm.executePendingJobs();
127
+ }
128
+ );
129
+
130
+ return deferred.handle; // return the QuickJS promise
131
+ });
132
+ vm.setProp(vm.global, 'dnsResolve', dnsResolve);
133
+ }
134
+ ```
135
+
136
+ ### ES modules
137
+
138
+ Evaluate code as an ES module with `EvalFlags.TYPE_MODULE`. Module evaluation
139
+ returns a handle to a Promise that resolves to the module's namespace object
140
+ (its exports):
141
+
142
+ ```typescript
143
+ import { QuickJS, EvalFlags } from 'quickjs-wasi';
144
+
145
+ using vm = await QuickJS.create(wasmBytes);
146
+
147
+ using promise = vm.evalCode(
148
+ 'export const x = 42; export default "hi";',
149
+ '<eval>',
150
+ EvalFlags.TYPE_MODULE
151
+ );
152
+ vm.executePendingJobs();
153
+
154
+ const result = await vm.resolvePromise(promise);
155
+ if ('error' in result) {
156
+ console.error(result.error.consume(h => h.toString()));
157
+ } else {
158
+ using ns = result.value;
159
+ ns.getProp('x').consume(h => h.toNumber()); // 42
160
+ ns.getProp('default').consume(h => h.toString()); // "hi"
161
+ }
162
+ ```
163
+
164
+ `import` statements are resolved through the `moduleLoader` option:
165
+
166
+ ```typescript
167
+ using vm = await QuickJS.create({
168
+ wasm: wasmBytes,
169
+ moduleLoader: {
170
+ // Optional: resolve relative specifiers against the importing module
171
+ normalize: (baseName, specifier) => specifier,
172
+ // Return the module source code for a given (normalized) specifier
173
+ load: (name) => {
174
+ if (name === 'math.js') return 'export const add = (a, b) => a + b;';
175
+ throw new Error(`Module not found: ${name}`);
176
+ },
177
+ },
178
+ });
179
+
180
+ using promise = vm.evalCode(
181
+ `import { add } from 'math.js'; export const sum = add(3, 4);`,
182
+ '<entry>',
183
+ EvalFlags.TYPE_MODULE
184
+ );
185
+ vm.executePendingJobs();
186
+ const result = await vm.resolvePromise(promise);
187
+ if ('value' in result) {
188
+ result.value.consume(ns => ns.getProp('sum').consume(h => h.toNumber())); // 7
189
+ }
190
+ ```
191
+
192
+ #### Async module loading
193
+
194
+ The `load` and `normalize` callbacks are **synchronous**: the engine calls
195
+ them from inside the WASM call stack, which cannot be suspended to await a
196
+ Promise (returning one throws a `TypeError`). To load module sources
197
+ asynchronously (e.g. over `https://`), use the **fetch-and-retry** pattern:
198
+ throw from `load` on a cache miss, fetch the missing module on the host, and
199
+ re-run the eval. Modules already loaded by the runtime are cached and not
200
+ re-requested, so each attempt makes progress one module deeper into the
201
+ dependency graph:
202
+
203
+ ```typescript
204
+ const cache = new Map<string, string>();
205
+ let missing: string | null = null;
206
+
207
+ using vm = await QuickJS.create({
208
+ wasm: wasmBytes,
209
+ moduleLoader: {
210
+ load: (name) => {
211
+ const src = cache.get(name);
212
+ if (src === undefined) {
213
+ missing = name;
214
+ throw new Error(`module not cached: ${name}`);
215
+ }
216
+ return src;
217
+ },
218
+ },
219
+ });
220
+
221
+ async function evalModule(code: string, filename: string) {
222
+ while (true) {
223
+ missing = null;
224
+ try {
225
+ return vm.evalCode(code, filename, EvalFlags.TYPE_MODULE);
226
+ } catch (err) {
227
+ if (missing === null) throw err; // a real error, not a cache miss
228
+ const response = await fetch(new URL(missing, 'https://example.com/'));
229
+ if (!response.ok) throw new Error(`failed to fetch ${missing}`);
230
+ cache.set(missing, await response.text());
231
+ }
232
+ }
233
+ }
234
+
235
+ using promise = await evalModule(`import { x } from 'mod.js'; export { x };`, '<entry>');
236
+ vm.executePendingJobs();
237
+ const result = await vm.resolvePromise(promise);
238
+ ```
239
+
240
+ Alternatively, pre-fetch all module sources up front and serve them from the
241
+ cache directly.
242
+
243
+ ### Error handling
244
+
245
+ ```typescript
246
+ using vm = await QuickJS.create(wasmBytes);
247
+
248
+ // evalCode() throws a JSException if the evaluated code throws
249
+ try {
250
+ vm.evalCode('throw new TypeError("bad")');
251
+ } catch (err) {
252
+ console.log(err.name); // "TypeError"
253
+ console.log(err.message); // "bad"
254
+ console.log(err.stack); // QuickJS stack trace
255
+ }
256
+
257
+ // Create errors from host Error objects (preserves name, message, stack)
258
+ {
259
+ using errHandle = vm.newError(new RangeError('out of bounds'));
260
+ vm.setProp(vm.global, 'hostError', errHandle);
261
+ }
262
+ ```
263
+
264
+ ### WASI overrides
265
+
266
+ The `wasi` option lets you override any WebAssembly System Interface (WASI) `wasi_snapshot_preview1` host function. It's a factory that receives the WASM linear memory and returns an object of override functions. Overrides apply to both the main module and all loaded extensions.
267
+
268
+ This is useful for deterministic execution: QuickJS uses a [xorshift64*](https://en.wikipedia.org/wiki/Xorshift) PRNG that is seeded once from the clock value during context creation. Override `clock_time_get` to control both `Date.now()` and the `Math.random()` seed:
269
+
270
+ ```typescript
271
+ const fixedClock = (memory: WebAssembly.Memory) => ({
272
+ clock_time_get(_clockId: number, _precision: bigint, resultPtr: number) {
273
+ new DataView(memory.buffer).setBigUint64(resultPtr, 1700000000000n * 1_000_000n, true);
274
+ return 0;
275
+ },
276
+ });
277
+
278
+ using vm1 = await QuickJS.create({ wasm: wasmBytes, wasi: fixedClock });
279
+ using vm2 = await QuickJS.create({ wasm: wasmBytes, wasi: fixedClock });
280
+
281
+ vm1.evalCode('Math.random()').consume(h => h.toNumber());
282
+ // => 0.8130834347906803
283
+
284
+ vm2.evalCode('Math.random()').consume(h => h.toNumber());
285
+ // => 0.8130834347906803 (identical)
286
+ ```
287
+
288
+ Override `random_get` to control the crypto extension's RNG:
289
+
290
+ ```typescript
291
+ using vm = await QuickJS.create({
292
+ wasm: wasmBytes,
293
+ wasi: (memory) => ({
294
+ random_get(bufPtr: number, bufLen: number) {
295
+ new Uint8Array(memory.buffer, bufPtr, bufLen).fill(0x42); // deterministic
296
+ return 0;
297
+ },
298
+ }),
299
+ extensions: [cryptoExtension],
300
+ });
301
+ ```
302
+
303
+ The time can also be advanced between calls for realistic behavior:
304
+
305
+ ```typescript
306
+ let currentTime = 1700000000000n;
307
+ using vm = await QuickJS.create({
308
+ wasm: wasmBytes,
309
+ wasi: (memory) => ({
310
+ clock_time_get(_clockId: number, _precision: bigint, resultPtr: number) {
311
+ new DataView(memory.buffer).setBigUint64(resultPtr, currentTime * 1_000_000n, true);
312
+ return 0;
313
+ },
314
+ }),
315
+ });
316
+
317
+ vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000000000
318
+ currentTime += 1000n; // advance 1 second
319
+ vm.evalCode('Date.now()').consume(h => h.toNumber()); // 1700000001000
320
+ ```
321
+
322
+ ### Memory limits
323
+
324
+ Restrict how much memory the QuickJS runtime can allocate. When exceeded, allocations fail and surface as JS exceptions:
325
+
326
+ ```typescript
327
+ using vm = await QuickJS.create({
328
+ wasm: wasmBytes,
329
+ memoryLimit: 4 * 1024 * 1024, // 4 MB
330
+ });
331
+
332
+ vm.evalCode(`
333
+ try {
334
+ const huge = new Array(10000000).fill("x".repeat(1000));
335
+ } catch (e) {
336
+ console.log(e.message); // allocation failure
337
+ }
338
+ `);
339
+ ```
340
+
341
+ The limit is re-applied after `QuickJS.restore()`, so you can use a different limit for restored VMs than the original.
342
+
343
+ ### Interrupt handler
344
+
345
+ Prevent infinite loops and enforce execution timeouts:
346
+
347
+ ```typescript
348
+ const start = Date.now();
349
+ using vm = await QuickJS.create({
350
+ wasm: wasmBytes,
351
+ interruptHandler: () => {
352
+ // Return true to interrupt. Called periodically during JS execution
353
+ return Date.now() - start > 5000; // 5 second timeout
354
+ },
355
+ });
356
+
357
+ try {
358
+ vm.evalCode('while (true) {}');
359
+ } catch (err) {
360
+ // JSException: interrupted
361
+ err.dispose();
362
+ }
363
+
364
+ // VM is still usable after an interrupt
365
+ vm.evalCode('1 + 2').consume(h => h.toNumber()); // 3
366
+ ```
367
+
368
+ The handler is called approximately once per JS bytecode instruction, so it should be fast. When it returns `true`, the current execution is interrupted and throws a `JSException`. The VM remains usable after an interrupt.
369
+
370
+ ### Timezone offset
371
+
372
+ By default, `Date` inside the sandbox mirrors the host environment's timezone. You can override this with a fixed offset or a dynamic callback:
373
+
374
+ ```typescript
375
+ // Fixed offset: UTC-8 (480 minutes west of UTC)
376
+ using vm = await QuickJS.create({
377
+ wasm: wasmBytes,
378
+ timezoneOffset: 480,
379
+ });
380
+ vm.evalCode('new Date().getTimezoneOffset()').consume(h => h.toNumber()); // 480
381
+ ```
382
+
383
+ ```typescript
384
+ // Force UTC (offset 0)
385
+ using vm = await QuickJS.create({
386
+ wasm: wasmBytes,
387
+ timezoneOffset: 0,
388
+ });
389
+ ```
390
+
391
+ ```typescript
392
+ // Dynamic callback for custom DST-aware logic
393
+ using vm = await QuickJS.create({
394
+ wasm: wasmBytes,
395
+ timezoneOffset: (timeSecs) => {
396
+ // Return offset in minutes (getTimezoneOffset convention: positive = west of UTC)
397
+ return new Date(timeSecs * 1000).getTimezoneOffset();
398
+ },
399
+ });
400
+ ```
401
+
402
+ The `timezoneOffset` option accepts:
403
+
404
+ - **`'host'`** (default): mirrors the host's timezone, including DST transitions.
405
+ - **A number**: fixed UTC offset in minutes using the `getTimezoneOffset()` sign convention (positive values are west of UTC, e.g. `480` for UTC-8).
406
+ - **A callback `(timeSecs: number) => number`**: called with seconds since epoch, must return the offset in minutes. Useful for custom timezone logic. The callback is invoked whenever QuickJS needs to convert between UTC and local time (e.g. `getHours()`, `toString()`, `new Date(year, month, ...)`, `getTimezoneOffset()`), so it may be called multiple times per Date operation.
407
+
408
+ ### Snapshot and restore
409
+
410
+ The key differentiator: snapshot the entire VM state and restore it later.
411
+
412
+ ```typescript
413
+ let snapshot: Snapshot;
414
+
415
+ {
416
+ using vm = await QuickJS.create(wasmBytes);
417
+
418
+ // Build up some state, including a pending promise
419
+ vm.evalCode(`
420
+ globalThis.counter = 0;
421
+
422
+ let __resolve;
423
+ globalThis.pendingWork = new Promise(r => { __resolve = r; });
424
+ globalThis.__resolve = __resolve;
425
+
426
+ globalThis.pendingWork.then(value => {
427
+ globalThis.counter = value;
428
+ });
429
+ `).dispose();
430
+ vm.executePendingJobs();
431
+
432
+ // Take a snapshot
433
+ snapshot = vm.snapshot();
434
+ }
435
+
436
+ // Serialize to a binary buffer for storage (apply gzip on top for best compression)
437
+ const bytes = QuickJS.serializeSnapshot(snapshot);
438
+ await storage.put('snapshots/run-123', bytes);
439
+
440
+ // ... time passes, maybe a different process entirely ...
441
+
442
+ // Deserialize and restore
443
+ const loaded = await storage.get('snapshots/run-123');
444
+ const restored = QuickJS.deserializeSnapshot(loaded);
445
+
446
+ {
447
+ using vm = await QuickJS.restore(restored, wasmBytes);
448
+
449
+ // The pending promise still exists; resolve it
450
+ using resolve = vm.global.getProp('__resolve');
451
+ using arg = vm.newNumber(42);
452
+ vm.callFunction(resolve, vm.undefined, arg).dispose();
453
+ vm.executePendingJobs();
454
+
455
+ // The .then handler ran in the restored VM
456
+ using counter = vm.global.getProp('counter');
457
+ console.log(counter.toNumber()); // 42
458
+ }
459
+ ```
460
+
461
+ ### Host callbacks after restore
462
+
463
+ Host functions registered with `newFunction()` are keyed by their name, which gets baked into the snapshot. After restoring, re-register the callbacks by name:
464
+
465
+ ```typescript
466
+ let snapshot: Snapshot;
467
+
468
+ {
469
+ using vm = await QuickJS.create(wasmBytes);
470
+ using fn = vm.newFunction('hostAdd', (...args) => {
471
+ return vm.newNumber(args[0].toNumber() + args[1].toNumber());
472
+ });
473
+ vm.setProp(vm.global, 'hostAdd', fn);
474
+ snapshot = vm.snapshot();
475
+ }
476
+
477
+ {
478
+ // After restore, re-register by name
479
+ using vm = await QuickJS.restore(snapshot, wasmBytes);
480
+ vm.registerHostCallback('hostAdd', (...args) => {
481
+ return vm.newNumber(args[0].toNumber() + args[1].toNumber());
482
+ });
483
+
484
+ // hostAdd() works again
485
+ using result = vm.evalCode('hostAdd(100, 200)');
486
+ console.log(result.toNumber()); // 300
487
+ }
488
+ ```
489
+
490
+ Note: each call to `newFunction()` must use a unique name. Attempting to register two host functions with the same name will throw an error.
491
+
492
+ ### Native WASM extensions
493
+
494
+ Load C-based extensions compiled as WASM shared libraries. Extensions link directly against the QuickJS C API with zero marshaling overhead: they share the same linear memory and can register custom classes, prototypes, and globals.
495
+
496
+ The package ships five pre-built extensions, each available as a subpath export. As with the main `quickjs.wasm` binary, the caller is responsible for loading the bytes:
497
+
498
+ | Extension | Subpath | Adds |
499
+ |-----------|---------|------|
500
+ | URL | `quickjs-wasi/url.so` | `URL`, `URLSearchParams` (ada-url) |
501
+ | Encoding | `quickjs-wasi/encoding.so` | `TextEncoder`, `TextDecoder` |
502
+ | Headers | `quickjs-wasi/headers.so` | `Headers` |
503
+ | Crypto | `quickjs-wasi/crypto.so` | `crypto.subtle`, `crypto.getRandomValues` |
504
+ | Structured Clone | `quickjs-wasi/structured-clone.so` | `structuredClone` |
505
+
506
+ Node.js:
507
+
508
+ ```typescript
509
+ import { readFile } from 'node:fs/promises';
510
+ import { QuickJS } from 'quickjs-wasi';
511
+
512
+ const urlExtBytes = await readFile(
513
+ new URL(import.meta.resolve('quickjs-wasi/url.so')),
514
+ );
515
+
516
+ using vm = await QuickJS.create({
517
+ wasm: wasmBytes,
518
+ extensions: [{ name: 'url', wasm: urlExtBytes }],
519
+ });
520
+
521
+ using result = vm.evalCode(`
522
+ const url = new URL('https://example.com:8080/api?key=value#section');
523
+ url.hostname // 'example.com'
524
+ `);
525
+ ```
526
+
527
+ Vite / bundlers:
528
+
529
+ ```typescript
530
+ import urlSoUrl from 'quickjs-wasi/url.so?url';
531
+
532
+ const urlExtBytes = await fetch(urlSoUrl).then((r) => r.arrayBuffer());
533
+ ```
534
+
535
+ Extensions survive snapshot/restore. Provide the same extensions when restoring:
536
+
537
+ ```typescript
538
+ const snapshot = vm.snapshot();
539
+
540
+ using vm2 = await QuickJS.restore(snapshot, {
541
+ wasm: wasmBytes,
542
+ extensions: [{ name: 'url', wasm: urlExtBytes }],
543
+ });
544
+ // URL objects created before the snapshot still work
545
+ ```
546
+
547
+ See [EXTENSIONS.md](./EXTENSIONS.md) for how to build your own extensions, how dynamic linking works, and known limitations.
548
+
549
+ ## API reference
550
+
551
+ ### `QuickJS` (VM instance)
552
+
553
+ | Method | Description |
554
+ |--------|-------------|
555
+ | `QuickJS.create(options?)` | Create a fresh VM instance |
556
+ | `QuickJS.restore(snapshot, options?)` | Restore a VM from a snapshot |
557
+ | `QuickJS.serializeSnapshot(snapshot)` | Serialize a snapshot to a versioned binary `Uint8Array` |
558
+ | `QuickJS.deserializeSnapshot(data)` | Deserialize a snapshot from a binary `Uint8Array` |
559
+ | `vm.evalCode(code, filename?)` | Evaluate JS code, returns `JSValueHandle` (throws `JSException` on error) |
560
+ | `vm.callFunction(fn, this, ...args)` | Call a QuickJS function (throws `JSException` on error) |
561
+ | `vm.executePendingJobs()` | Drain the promise microtask queue |
562
+ | `vm.newString(str)` | Create a string value |
563
+ | `vm.newNumber(num)` | Create a number value |
564
+ | `vm.newBigInt(val)` | Create a BigInt value |
565
+ | `vm.newObject()` | Create an empty object |
566
+ | `vm.newArray()` | Create an empty array |
567
+ | `vm.newSymbolFor(description)` | Create a global symbol (`Symbol.for(description)`) |
568
+ | `vm.newArrayBuffer(data)` | Create an ArrayBuffer from host `ArrayBuffer` or `Uint8Array` |
569
+ | `vm.newUint8Array(data)` | Create a Uint8Array from host `Uint8Array` |
570
+ | `vm.newFunction(name, callback)` | Create a function backed by a host callback |
571
+ | `vm.newPromise()` | Create a `Deferred` (promise + resolve/reject) |
572
+ | `vm.newError(messageOrError)` | Create an Error from a string or native `Error` |
573
+ | `vm.resolvePromise(handle)` | Await a QuickJS promise from the host side |
574
+ | `vm.setProp(obj, key, value)` | Set a property (key: string or handle, including symbols) |
575
+ | `vm.getProp(obj, key)` | Get a property using a handle key (including symbols) |
576
+ | `vm.typeof(handle)` | Get the `typeof` as a string |
577
+ | `vm.dump(handle)` | Convert a QuickJS value to a host value |
578
+ | `vm.hostToHandle(value)` | Convert a host value to a QuickJS handle |
579
+ | `vm.snapshot()` | Capture the entire VM state (including extension metadata) |
580
+ | `vm.registerHostCallback(name, fn)` | Re-register a host callback by name after restore |
581
+ | `vm.dispose()` | Free the VM |
582
+ | `vm[Symbol.dispose]()` | Same as `dispose()`, enables `using vm = ...` |
583
+
584
+ ### `QuickJSOptions`
585
+
586
+ | Option | Description |
587
+ |--------|-------------|
588
+ | `wasm` | WASM module bytes or pre-compiled `WebAssembly.Module` |
589
+ | `wasi` | WASI override factory: `(memory) => ({ random_get, clock_time_get, ... })`. Applies to main module and all extensions |
590
+ | `memoryLimit` | Maximum memory the QuickJS runtime can allocate (bytes) |
591
+ | `interruptHandler` | Callback to interrupt execution (return `true` to stop) |
592
+ | `extensions` | Array of `ExtensionDescriptor` objects (native WASM extensions to load) |
593
+ | `timezoneOffset` | Timezone for `Date` inside the VM: `'host'` (default), fixed offset in minutes, or `(timeSecs) => minutes` callback |
594
+
595
+ ### `ExtensionDescriptor`
596
+
597
+ | Property | Description |
598
+ |----------|-------------|
599
+ | `name` | Identifier string (used in snapshot metadata) |
600
+ | `wasm` | WASM bytes (`BufferSource`) or pre-compiled `WebAssembly.Module` |
601
+ | `initFn?` | Init function name (default: `qjs_ext_${name}_init`) |
602
+ | `wasi?` | Extension-provided WASI overrides: `(memory) => ({...})`. Layered between built-in defaults and user overrides |
603
+
604
+ ### Cached properties
605
+
606
+ These are singleton handles. Do **not** dispose them:
607
+
608
+ | Property | Value |
609
+ |----------|-------|
610
+ | `vm.global` | The global object |
611
+ | `vm.undefined` | `undefined` |
612
+ | `vm.null` | `null` |
613
+ | `vm.true` | `true` |
614
+ | `vm.false` | `false` |
615
+
616
+ ### `JSValueHandle`
617
+
618
+ | Method / Property | Description |
619
+ |-------------------|-------------|
620
+ | `handle.isUndefined` | `true` if this is `undefined` |
621
+ | `handle.isNull` | `true` if this is `null` |
622
+ | `handle.promiseState` | `0` pending, `1` fulfilled, `2` rejected |
623
+ | `handle.toNumber()` | Extract as a `number` |
624
+ | `handle.toBigInt()` | Extract as a `bigint` |
625
+ | `handle.toString()` | Extract as a `string` |
626
+ | `handle.toArrayBuffer()` | Extract as an `ArrayBuffer` (copy from WASM memory) |
627
+ | `handle.toUint8Array()` | Extract as a `Uint8Array` (copy from WASM memory) |
628
+ | `handle.getProp(name)` | Get a property by name |
629
+ | `handle.setProp(name, value)` | Set a property by name |
630
+ | `handle.consume(fn)` | Call `fn(handle)`, then dispose, return result |
631
+ | `handle.dup()` | Duplicate the handle (increment refcount) |
632
+ | `handle.dispose()` | Free the handle |
633
+ | `handle[Symbol.dispose]()` | Same as `dispose()`, enables `using handle = ...` |
634
+
635
+ #### Trap-free introspection
636
+
637
+ These use engine-level (internal class) checks and cannot be spoofed or
638
+ broken by guest-side prototype/constructor mutation. This makes them safe
639
+ to call on hostile or unknown values (e.g. when rendering an inspector UI,
640
+ or implementing side-effect-free serialization).
641
+
642
+ The brand checks, `classId`, and `getProxyTarget()`/`getProxyHandler()`
643
+ never execute guest code on **any** value: no proxy traps, no getters, no
644
+ `Symbol.hasInstance`. The two property-inspection helpers
645
+ (`getOwnPropertyKeys()` and `getOwnPropertyDescriptor()`) never invoke
646
+ getters and are guest-code free for ordinary objects, but on a Proxy they
647
+ necessarily fire its `ownKeys`/`getOwnPropertyDescriptor` traps. Check
648
+ `isProxy` first if that matters.
649
+
650
+ | Method / Property | Description |
651
+ |-------------------|-------------|
652
+ | `handle.isProxy` | `true` if this is a Proxy exotic object (undetectable from within JS) |
653
+ | `handle.isMap` / `handle.isSet` | Engine brand checks (a Proxy wrapping a Map is **not** a Map) |
654
+ | `handle.isDate` / `handle.isRegExp` | Engine brand checks |
655
+ | `handle.isWeakRef` / `handle.isWeakMap` / `handle.isWeakSet` | Engine brand checks |
656
+ | `handle.isDataView` | Engine brand check |
657
+ | `handle.classId` | Internal QuickJS class ID (`0` for non-objects) |
658
+ | `handle.getProxyTarget()` | The `[[ProxyTarget]]` of a Proxy, without firing traps |
659
+ | `handle.getProxyHandler()` | The `[[ProxyHandler]]` of a Proxy, without firing traps |
660
+ | `handle.getOwnPropertyKeys()` | All own keys (strings **and** symbols, incl. non-enumerable), à la `Reflect.ownKeys()` |
661
+ | `handle.getOwnPropertyDescriptor(key)` | Own property descriptor **without invoking getters**; accessor properties yield `get`/`set` handles |
662
+ | `handle.identity` | Numeric identity of the underlying heap value (`0` for non-heap values). Key a `Map` on this to deduplicate or detect cycles across handles |
663
+ | `handle.toBoolean()` | Extract as a `boolean`, applying JavaScript truthiness |
664
+ | `vm.construct(ctor, ...args)` | Invoke a constructor with `new`, the counterpart to `callFunction` for building values in the VM from the host |
665
+
666
+ Note that `handle.toString()` is **not** in this list: for values that are not
667
+ already strings it performs a JavaScript string conversion, which executes
668
+ guest code. Guard with `handle.isString`, or call a captured intrinsic such as
669
+ `URL.prototype.toString` through `vm.callFunction`.
670
+
671
+ ### Handle lifetime
672
+
673
+ | Method / Property | Description |
674
+ |-------------------|-------------|
675
+ | `vm.withScope(fn)` | Run `fn`, disposing every handle created during it; use `scope.escape(handle)` to keep one. Scopes nest, and `escape()` transfers to the enclosing scope |
676
+ | `vm.newEphemeralFunction(fn)` | Like `newFunction()`, but the host callback is unregistered when the handle is disposed. Use for short-lived callbacks instead of inventing unique names |
677
+ | `vm.unregisterHostCallback(name)` | Remove a callback registered by `newFunction()` / `registerHostCallback()` |
678
+ | `handle.disposed` | Whether `dispose()` has been called |
679
+
680
+ ```typescript
681
+ const name = vm.withScope((scope) => {
682
+ const user = root.getProp('user'); // freed automatically
683
+ const profile = user.getProp('profile'); // freed automatically
684
+ return scope.escape(profile.getProp('name'));
685
+ });
686
+ ```
687
+
688
+ Handle methods do not guard against use after disposal: reading from a
689
+ disposed handle reads freed memory. Check `handle.disposed` when a handle's
690
+ lifetime is managed elsewhere.
691
+
692
+ ### `Deferred` (from `vm.newPromise()`)
693
+
694
+ | Property / Method | Description |
695
+ |--------------------|-------------|
696
+ | `deferred.handle` | The QuickJS promise object |
697
+ | `deferred.settled` | Host `Promise<void>` that resolves on settlement |
698
+ | `deferred.resolve(handle)` | Resolve the promise with a QuickJS value |
699
+ | `deferred.reject(handle)` | Reject the promise with a QuickJS value |
700
+
701
+ ### Data marshaling
702
+
703
+ `dump()` and `hostToHandle()` automatically convert values between the host and the QuickJS VM. The following types are supported:
704
+
705
+ | Host Type | QuickJS Type | `dump()` returns | `hostToHandle()` accepts |
706
+ |-----------|-------------|-----------------|------------------------|
707
+ | `undefined` | undefined | `undefined` | `undefined` |
708
+ | `null` | null | `null` | `null` |
709
+ | `boolean` | boolean | `boolean` | `boolean` |
710
+ | `number` | number | `number` | `number` |
711
+ | `string` | string | `string` | `string` |
712
+ | `bigint` | BigInt | `bigint` | `bigint` |
713
+ | `Symbol.for()` | global Symbol | `Symbol.for(description)` | `Symbol.for(description)` |
714
+ | `Error` | Error | `Error` (with name, message, stack) | `Error` |
715
+ | `Array` | Array | `Array` (recursive) | `Array` (recursive) |
716
+ | `ArrayBuffer` | ArrayBuffer | `ArrayBuffer` (copy) | `ArrayBuffer` |
717
+ | `Uint8Array` | Uint8Array | `Uint8Array` (copy) | `Uint8Array` |
718
+ | Other typed arrays | typed array | Corresponding typed array (copy) | `ArrayBuffer` (via view) |
719
+ | `Promise` | Promise | — | QuickJS Promise (bridged via `Deferred`) |
720
+ | Plain object | Object | `Record<string, unknown>` (recursive, own enumerable keys) | Object (recursive) |
721
+
722
+ **Notes:**
723
+
724
+ - Global symbols (`Symbol.for()`) round-trip as real host `Symbol` values via `Symbol.for(description)`
725
+ - Local (anonymous) symbols dump as `undefined` and throw if passed to `hostToHandle()`
726
+ - Functions dump as `undefined` (cannot be meaningfully serialized)
727
+ - Circular and shared references are preserved: `dump()` returns the same host object for the same QuickJS object pointer
728
+ - Only own enumerable string properties are included when dumping objects
729
+ - Binary data is always **copied** between host and WASM memory; there is no zero-copy view API
730
+ - `dump()` for typed arrays determines the host constructor from bytes-per-element (1 → `Uint8Array`, 2 → `Uint16Array`, 4 → `Uint32Array`, 8 → `Float64Array`)
731
+
732
+ ## How it works
733
+
734
+ ### The core insight
735
+
736
+ WebAssembly linear memory is a flat byte array. Everything QuickJS allocates (the runtime struct, all contexts, all JS objects, the GC heap, the atom table, the promise job queue, pending promises) lives in this linear memory. There are no external pointers, file handles, or OS resources. When you copy the memory wholesale to a new WASM instance, all internal pointer relationships are preserved because they reference the same linear address space.
737
+
738
+ ### One VM = one WASM instance
739
+
740
+ Unlike quickjs-emscripten which has a two-level model (`QuickJSWASMModule` → `QuickJSContext`), quickjs-wasm uses a simpler one-level model: each `QuickJS.create()` call instantiates its own WASM module with its own linear memory, runtime, and context. This gives stronger isolation (no shared memory between VMs) and makes snapshotting clean: one instance, one context, one snapshot.
741
+
742
+ ### Architecture
743
+
744
+ ```
745
+ Host (Node.js / Deno / Bun / Browser)
746
+ |
747
+ +-- QuickJS class (ts/index.ts)
748
+ | |-- evalCode(), callFunction(), newFunction(), ...
749
+ | |-- snapshot() -> Snapshot { memory, stackPointer, runtimePtr, contextPtr }
750
+ | +-- restore(snapshot) -> QuickJS
751
+ |
752
+ +-- WASI Shim (ts/wasi-shim.ts)
753
+ | |-- clock_time_get, fd_write, random_get
754
+ | +-- fd_close, fd_fdstat_get, fd_seek (stubs)
755
+ |
756
+ +-- quickjs.wasm (1.4 MB)
757
+ |-- QuickJS-NG engine
758
+ +-- C interface layer (c/interface.c)
759
+ |-- Lifecycle, eval, value creation/extraction
760
+ |-- Host callback trampoline (imported host_call)
761
+ +-- Snapshot support (get/set runtime and context pointers)
762
+ ```
763
+
764
+ ### Host callback mechanism
765
+
766
+ When `vm.newFunction(name, fn)` is called, a QuickJS C function is created via `JS_NewCFunctionData2` with the function name stored as a JS string in `func_data[0]`. When QuickJS code calls the function, the C trampoline extracts the name and calls the imported `host_call(name_ptr, name_len, this_ptr, argc, argv_ptr)` function, which dispatches to the registered host callback by name.
767
+
768
+ This design survives snapshot/restore: the name string is stored in QuickJS's heap (part of the snapshot), and after restore, `registerHostCallback(name, fn)` re-maps the name to a new host function. Because callbacks are keyed by name rather than sequential integer IDs, the registration order doesn't matter and adding or removing host functions won't silently break restore.
769
+
770
+ ## Development
771
+
772
+ ### Prerequisites
773
+
774
+ - [wasi-sdk](https://github.com/WebAssembly/wasi-sdk) (tested with v30). Set the `WASI_SDK` env var or it defaults to `/tmp/wasi-sdk`
775
+ - Node.js >= 22
776
+ - pnpm
777
+
778
+ ### Building locally
779
+
780
+ ```sh
781
+ # Clone with submodules
782
+ git clone --recursive https://github.com/vercel-labs/quickjs-wasm.git
783
+ cd quickjs-wasm
784
+
785
+ # Install wasi-sdk (macOS arm64; adjust URL for your platform)
786
+ curl -sL "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-30/wasi-sdk-30.0-arm64-macos.tar.gz" \
787
+ | tar xz -C /tmp --strip-components=1 --one-top-level=wasi-sdk
788
+
789
+ # Install dependencies
790
+ pnpm install
791
+
792
+ # Build WASM binary + TypeScript
793
+ pnpm run build
794
+
795
+ # Run tests
796
+ pnpm test
797
+ ```
798
+
799
+ ## Technical details
800
+
801
+ ### WASM binary
802
+
803
+ - Built from [quickjs-ng](https://github.com/quickjs-ng/quickjs) (MIT license)
804
+ - Compiled with wasi-sdk targeting `wasm32-wasip1` in reactor mode
805
+ - 1.4 MB uncompressed
806
+ - 7 WASM imports: 6 WASI functions + 1 `env.host_call` for host callbacks
807
+ - Exports `memory` and `__stack_pointer` for snapshot support
808
+
809
+ ### What gets snapshotted
810
+
811
+ The snapshot captures the entire WASM linear memory, which contains:
812
+
813
+ - The `JSRuntime` struct (GC state, job queue, module loader state)
814
+ - The `JSContext` struct (global object, intrinsics, atom table)
815
+ - All JS objects (via QuickJS's GC heap)
816
+ - The promise job queue (pending `.then` callbacks)
817
+ - The string intern table (atoms)
818
+ - The `dlmalloc` heap metadata
819
+ - The C interface's `static JSRuntime *rt` and `static JSContext *ctx` globals
820
+ - Host callback IDs stored in function data
821
+
822
+ Plus the `__stack_pointer` WASM global (a single i32).
823
+
824
+ ### Limitations and future work
825
+
826
+ - **Snapshot size**: Snapshots capture the entire WASM linear memory (~256 KB baseline, grows with heap). Use `serializeSnapshot()` to get a binary buffer, then apply your own compression (gzip/zstd). The memory compresses well due to its large zero regions.
827
+ - **Stack size limit**: QuickJS-ng disables `JS_SetMaxStackSize` on WASI, so deep recursion causes a WASM trap (not a catchable exception).
828
+ - **ES modules**: Supported via `EvalFlags.TYPE_MODULE` and the `moduleLoader` option (see [ES modules](#es-modules)). Dynamic `import()` resolves through the same loader.
829
+ - **Extension ABI**: Native WASM extensions use an experimental dynamic linking ABI that is [not yet stabilized](https://github.com/WebAssembly/tool-conventions/blob/main/DynamicLinking.md). All extensions must be compiled with the same wasi-sdk version as the main module. See [EXTENSIONS.md](./EXTENSIONS.md) for details.
830
+
831
+ ### Browser usage
832
+
833
+ quickjs-wasi works in browsers: the TypeScript API uses only the standard `WebAssembly` API and the WASI shim is environment-agnostic. The package does no implicit I/O, so loading the WASM binary is up to you. See [Loading the WASM binary](#loading-the-wasm-binary) above for `fetch()` and bundler-based examples.
834
+
835
+ ```typescript
836
+ import { QuickJS } from 'quickjs-wasi';
837
+ import wasmUrl from 'quickjs-wasi/quickjs.wasm?url'; // Vite
838
+
839
+ // Fetch the .wasm file and compile it once
840
+ const wasmModule = await WebAssembly.compileStreaming(fetch(wasmUrl));
841
+
842
+ // Create VMs from the pre-compiled module (fast, no re-compilation)
843
+ using vm = await QuickJS.create({ wasm: wasmModule });
844
+ ```
845
+
846
+ See [`examples/browser/`](./examples/browser/) for a complete Vite demo app.