├── .eslintignore ├── .eslintrc.yml ├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ ├── ci.yml │ └── pr_title.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── contextex.test.ts ├── contextex.ts ├── default.ts ├── edge.test.ts ├── index.test.ts ├── index.ts ├── marshal │ ├── custom.test.ts │ ├── custom.ts │ ├── function.test.ts │ ├── function.ts │ ├── index.test.ts │ ├── index.ts │ ├── json.test.ts │ ├── json.ts │ ├── object.test.ts │ ├── object.ts │ ├── primitive.test.ts │ ├── primitive.ts │ ├── promise.test.ts │ ├── promise.ts │ ├── properties.test.ts │ └── properties.ts ├── unmarshal │ ├── custom.test.ts │ ├── custom.ts │ ├── function.test.ts │ ├── function.ts │ ├── index.test.ts │ ├── index.ts │ ├── object.test.ts │ ├── object.ts │ ├── primitive.test.ts │ ├── primitive.ts │ ├── promise.test.ts │ ├── promise.ts │ ├── properties.test.ts │ └── properties.ts ├── util.test.ts ├── util.ts ├── vmmap.test.ts ├── vmmap.ts ├── vmutil.test.ts ├── vmutil.ts ├── wrapper.test.ts └── wrapper.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: 3 | - reearth 4 | rules: 5 | '@typescript-eslint/ban-types': ['off'] 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @rot1024 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>reearth/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v3 12 | 13 | - name: Use Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: lts/* 17 | 18 | - name: Install deps 19 | uses: bahmutov/npm-install@v1 20 | 21 | - name: Lint 22 | run: yarn run lint 23 | 24 | - name: Test 25 | run: yarn test --coverage 26 | 27 | - name: codecov 28 | uses: codecov/codecov-action@v2 29 | 30 | - name: Build 31 | run: yarn run build 32 | 33 | - name: Upload artifacts 34 | uses: actions/upload-artifact@v3 35 | with: 36 | name: dist 37 | path: dist/**/* 38 | -------------------------------------------------------------------------------- /.github/workflows/pr_title.yml: -------------------------------------------------------------------------------- 1 | name: PR Title Checker 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | - labeled 9 | - unlabeled 10 | jobs: 11 | pr_title: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: amannn/action-semantic-pull-request@v4 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | ignoreLabels: meta 18 | subjectPattern: ^(?![A-Z]).+$ 19 | subjectPatternError: | 20 | The subject "{subject}" found in the pull request title "{title}" 21 | didn't match the configured pattern. Please ensure that the subject 22 | doesn't start with an uppercase character. 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | /dist 5 | /coverage 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 rot1024 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quickjs-emscripten-sync 2 | 3 | [![CI](https://github.com/reearth/quickjs-emscripten-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/reearth/quickjs-emscripten-sync/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/reearth/quickjs-emscripten-sync/branch/main/graph/badge.svg)](https://codecov.io/gh/reearth/quickjs-emscripten-sync) 4 | 5 | Build a secure plugin system in web browsers 6 | 7 | This library wraps [quickjs-emscripten](https://github.com/justjake/quickjs-emscripten) and provides a way to sync object state between the browser and sandboxed QuickJS. 8 | 9 | - Exchange and sync values between the browser (host) and QuickJS seamlessly 10 | - Primitives (number, boolean, string, symbol) 11 | - Arrays 12 | - Functions 13 | - Classes and instances 14 | - Objects with prototypes and any property descriptors 15 | - Promises 16 | - Expose objects as a global object in QuickJS 17 | - Marshaling limitation for specific objects 18 | - Register a pair of objects that will be considered the same between the browser and QuickJS 19 | 20 | ``` 21 | npm install quickjs-emscripten quickjs-emscripten-sync 22 | ``` 23 | 24 | ```js 25 | import { getQuickJS } from "quickjs-emscripten"; 26 | import { Arena } from "quickjs-emscripten-sync"; 27 | 28 | class Cls { 29 | field = 0; 30 | 31 | method() { 32 | return ++this.field; 33 | } 34 | } 35 | 36 | const ctx = (await getQuickJS()).newContext(); 37 | const arena = new Arena(ctx, { isMarshalable: true }); 38 | 39 | // We can pass objects to the context and run code safely 40 | const exposed = { 41 | Cls, 42 | cls: new Cls(), 43 | syncedCls: arena.sync(new Cls()), 44 | }; 45 | arena.expose(exposed); 46 | 47 | arena.evalCode(`cls instanceof Cls`); // returns true 48 | arena.evalCode(`cls.field`); // returns 0 49 | arena.evalCode(`cls.method()`); // returns 1 50 | arena.evalCode(`cls.field`); // returns 1 51 | 52 | arena.evalCode(`syncedCls.field`); // returns 0 53 | exposed.syncedCls.method(); // returns 1 54 | arena.evalCode(`syncedCls.field`); // returns 1 55 | 56 | arena.dispose(); 57 | ctx.dispose(); 58 | ``` 59 | 60 | [Example code](src/index.test.ts) is available as the unit test code. 61 | 62 | ## Operating Environment 63 | 64 | - Web browsers that support WebAssembly 65 | - Node.js 66 | 67 | If you want to run quickjs-emscripten and quickjs-emscripten-sync in a web browser, they have to be bundled with a bundler tool such as webpack, because quickjs-emscripten is now written in CommonJS format and web browsers cannot load it directly. 68 | 69 | ## Usage 70 | 71 | ```js 72 | import { getQuickJS } from "quickjs-emscripten"; 73 | import { Arena } from "quickjs-emscripten-sync"; 74 | 75 | (async function() { 76 | const ctx = (await getQuickJS()).newContext(); 77 | 78 | // init Arena 79 | // ⚠️ Marshaling is opt-in for security reasons. 80 | // ⚠️ Be careful when activating marshalling. 81 | const arena = new Arena(ctx, { isMarshalable: true }); 82 | 83 | // expose objects as global objects in QuickJS context 84 | arena.expose({ 85 | console: { 86 | log: console.log 87 | } 88 | }); 89 | arena.evalCode(`console.log("hello, world");`); // run console.log 90 | arena.evalCode(`1 + 1`); // 2 91 | 92 | // expose objects but also enable sync 93 | const data = arena.sync({ hoge: "foo" }); 94 | arena.expose({ data }); 95 | 96 | arena.evalCode(`data.hoge = "bar"`); 97 | // eval code and operations to exposed objects are automatically synced 98 | console.log(data.hoge); // "bar" 99 | data.hoge = "changed!"; 100 | console.log(arena.evalCode(`data.hoge`)); // "changed!" 101 | 102 | // Don't forget calling arena.dispose() before disposing QuickJS context! 103 | arena.dispose(); 104 | ctx.dispose(); 105 | })(); 106 | ``` 107 | 108 | ## Marshaling Limitations 109 | 110 | Objects are automatically converted when they cross between the host and the QuickJS context. The conversion of a host's object to a handle is called marshaling, and the conversion of a handle to a host's object is called unmarshaling. 111 | 112 | And for marshalling, it is possible to control whether the conversion is performed or not. 113 | 114 | For example, exposing the host's global object to QuickJS is very heavy and dangerous. This exposure can be limited and controlled with the `isMarshalable` option. If `false` is returned, just `undefined` is passed to QuickJS. 115 | 116 | ```js 117 | import { Arena, complexity } from "quickjs-emscripten-sync"; 118 | 119 | const arena = new Arena(ctx, { 120 | isMarshalable: (target: any) => { 121 | // prevent passing globalThis to QuickJS 122 | if (target === window) return false; 123 | // complexity is a helper function to detect whether the object is heavy 124 | if (complexity(target, 30) >= 30) return false; 125 | return true; // other objects are OK 126 | } 127 | }); 128 | 129 | arena.evalCode(`a => a === undefined`)({}); // false 130 | arena.evalCode(`a => a === undefined`)(window); // true 131 | arena.evalCode(`a => a === undefined`)(document); // true 132 | ``` 133 | 134 | The `complexity` function is useful to detect whether the object is too heavy to be passed to QuickJS. 135 | 136 | ## Security Warning 137 | 138 | QuickJS has an environment isolated from the browser, so any code can be executed safely, but there are edge cases where some exposed objects by quickjs-emscripten-sync may break security. 139 | 140 | quickjs-emscripten-sync cannot prevent such dangerous case, so **PLEASE be very careful and deliberate about what you expose to QuickJS!** 141 | 142 | ### Case 1: Prototype pollution 143 | 144 | ```js 145 | import { set } from "lodash-es"; 146 | 147 | arena.expose({ 148 | danger: (keys, value) => { 149 | // This function may cause prototype pollution in the browser by QuickJS 150 | set({}, keys, value) 151 | } 152 | }); 153 | 154 | arena.evalCode(`danger("__proto__.a", () => { /* injected */ })`); 155 | ``` 156 | 157 | ### Case 2: Unintended HTTP request 158 | 159 | It is very dangerous to expose or use directly or indirectly the `window` object, `localStorage`, `fetch`, `XMLHttpRequest` ... 160 | 161 | This is because it enables the execution of unintended code such as XSS attacks, such as reading local storage, sending unintended HTTP requests, and manipulating DOM objects. 162 | 163 | ```js 164 | arena.expose({ 165 | // This function may cause unintended HTTP requests 166 | danger: (url, body) => { 167 | fetch(url, { 168 | method: "POST", 169 | headers: { 170 | "Content-Type": "application/json" 171 | }, 172 | body: JSON.stringify(body) 173 | }); 174 | } 175 | }); 176 | 177 | arena.evalCode(`danger("/api", { dangerous: true })`); 178 | ``` 179 | 180 | By default, quickjs-emscripten-sync doesn't prevent any marshaling, even in such cases. And there are many built-in objects in the host, so please note that it's hard to prevent all dangerous cases with the `isMarshalable` option alone. 181 | 182 | ## API 183 | 184 | ### `Arena` 185 | 186 | The Arena class manages all generated handles at once by quickjs-emscripten and automatically converts objects between the host and the QuickJS context. 187 | 188 | #### `new Arena(ctx: QuickJSContext, options?: Options)` 189 | 190 | Constructs a new Arena instance. It requires a quickjs-emscripten context initialized with `quickjs.newContext()`. 191 | 192 | Options accepted: 193 | 194 | ```ts 195 | type Options = { 196 | /** A callback that returns a boolean value that determines whether an object is marshalled or not. If false, no marshaling will be done and undefined will be passed to the QuickJS VM, otherwise marshaling will be done. By default, all objects will be marshalled. */ 197 | isMarshalable?: boolean | "json" | ((target: any) => boolean | "json"); 198 | /** Pre-registered pairs of objects that will be considered the same between the host and the QuickJS VM. This will be used automatically during the conversion. By default, it will be registered automatically with `defaultRegisteredObjects`. 199 | * 200 | * Instead of a string, you can also pass a QuickJSHandle directly. In that case, however, you have to dispose of them manually when destroying the VM. 201 | */ 202 | registeredObjects?: Iterable<[any, QuickJSHandle | string]>; 203 | /** Register functions to convert an object to a QuickJS handle. */ 204 | customMarshaller?: Iterable< 205 | (target: unknown, ctx: QuickJSContext) => QuickJSHandle | undefined 206 | >; 207 | /** Register functions to convert a QuickJS handle to an object. */ 208 | customUnmarshaller?: Iterable< 209 | (target: QuickJSHandle, ctx: QuickJSContext) => any 210 | >; 211 | /** A callback that returns a boolean value that determines whether an object is wrappable by proxies. If returns false, note that the object cannot be synchronized between the host and the QuickJS even if arena.sync is used. */ 212 | isWrappable?: (target: any) => boolean; 213 | /** A callback that returns a boolean value that determines whether an QuickJS handle is wrappable by proxies. If returns false, note that the handle cannot be synchronized between the host and the QuickJS even if arena.sync is used. */ 214 | isHandleWrappable?: (handle: QuickJSHandle, ctx: QuickJSContext) => boolean; 215 | /** Compatibility with quickjs-emscripten prior to v0.15. Inject code for compatibility into context at Arena class initialization time. */ 216 | compat?: boolean; 217 | } 218 | ``` 219 | 220 | Notes: 221 | 222 | **`isMarshalable`**: Determines how marshalling will be done when sending objects from the host to the context. **Make sure to set the marshalling to be the minimum necessary as it may reduce the security of your application.** [Please read the section on security above.](#security-warning) 223 | 224 | - `"json"` (**default**, safety): Target object will be serialized as JSON in host and then parsed in context. Functions and classes will be lost in the process. 225 | - `false` (safety): Target object will not be always marshalled as `undefined`. 226 | - `(target: any) => boolean | "json"` (recoomended): You can control marshalling mode for each objects. If you want to do marshalling, usually use this method. Allow partial marshalling by returning `true` only for some objects. 227 | - `true` (**risky and not recommended**): Target object will be always marshaled. This setting may reduce security. 228 | 229 | **`registeredObjects`**: You can pre-register a pair of objects that will be considered the same between the host and the QuickJS context. This will be used automatically during the conversion. By default, it will be registered automatically with [`defaultRegisteredObjects`](src/default.ts). If you want to add a new pair to this, please do the following: 230 | 231 | ```js 232 | import { defaultRegisteredObjects } from "quickjs-emscripten-sync"; 233 | 234 | const arena = new Arena(ctx, { 235 | registeredObjects: [ 236 | ...defaultRegisteredObjects, 237 | [Math, "Math"] 238 | ] 239 | }); 240 | ``` 241 | 242 | Instead of a string, you can also pass a QuickJSHandle directly. In that case, however, you have to dispose of them manually when destroying the context. 243 | 244 | #### `dispose()` 245 | 246 | Dispose of the arena and managed handles. This method won't dispose the context itself, so the context has to be disposed of manually. 247 | 248 | #### `evalCode(code: string): T | undefined` 249 | 250 | Evaluate JS code in the context and get the result as an object on the host side. It also converts and re-throws error objects when an error is thrown during evaluation. 251 | 252 | #### `executePendingJobs(): number` 253 | 254 | Almost same as `ctx.runtime.executePendingJobs()`, but it converts and re-throws error objects when an error is thrown during evaluation. 255 | 256 | #### `expose(obj: { [k: string]: any })` 257 | 258 | Expose objects as global objects in the context. 259 | 260 | By default, exposed objects are not synchronized between the host and the context. 261 | If you want to sync an objects, first wrap the object with `sync` method, and then expose the wrapped object. 262 | 263 | #### `sync(target: T): T` 264 | 265 | Enables sync for the object between the host and the context and returns objects wrapped with proxies. 266 | 267 | The return value is necessary in order to reflect changes to the object from the host to the context. Please note that setting a value in the field or deleting a field in the original object will not synchronize it. 268 | 269 | #### `register(target: any, code: string | QuickJSHandle)` 270 | 271 | Register a pair of objects that will be considered the same between the host and the QuickJS context. 272 | 273 | #### `unregisterAll(targets: Iterable<[any, string | QuickJSHandle]>)` 274 | 275 | Execute `register` methods for each pair. 276 | 277 | #### `unregister(target: any)` 278 | 279 | Unregister a pair of objects that were registered with `registeredObjects` option and `register` method. 280 | 281 | #### `unregisterAll(targets: Iterable)` 282 | 283 | Execute `unregister` methods for each target. 284 | 285 | ### `defaultRegisteredObjects: [any, string][]` 286 | 287 | Default value of registeredObjects option of the Arena class constructor. 288 | 289 | ### `complexity(target: any, max?: number): number` 290 | 291 | Measure the complexity of an object as you traverse the field and prototype chain. If max is specified, when the complexity reaches max, the traversal is terminated and it returns the max. In this function, one object and function are counted as a complexity of 1, and primitives are not counted as a complexity. 292 | 293 | ## Advanced 294 | 295 | ### How to work 296 | 297 | quickjs-emscripten can execute JS code safely, but it requires to deal with a lot of handles and lifetimes. Also, when destroying the context, any un-destroyed handle will result in an error. 298 | 299 | quickjs-emscripten-sync will automatically manage all handles once generated by QuickJS context in an Arena class. 300 | And it will automatically "marshal" objects as handles and "unmarshal" handles as objects to enable seamless data exchange between the browser and QuickJS. It recursively traverses the object properties and prototype chain to transform objects. A function is called after its arguments and this arg are automatically converted for the environment in which the function is defined. The return value will be automatically converted to match the environment of the caller. 301 | Most objects are wrapped by proxies during conversion, allowing "set" and "delete" operations on objects to be synchronized between the browser and QuickJS. 302 | 303 | ### Limitations 304 | 305 | #### Class constructor 306 | 307 | When initializing a new instance, it is not possible to fully proxy this arg (a.k.a. `new.target`) inside the class constructor. Therefore, after the constructor call, the fields set for this are re-set to this on the context side. Therefore, there may be some edge cases where the constructor may not work properly. 308 | 309 | ```js 310 | class Cls { 311 | constructor() { 312 | this.hoge = "foo"; 313 | } 314 | } 315 | 316 | arena.expose({ Cls }); 317 | arena.evalCode(`new Cls()`); // Cls { hoge: "foo" } 318 | ``` 319 | 320 | #### Operation synchronization 321 | 322 | For now, only the `set` and `deleteProperty` operations on objects are subject to synchronization. The result of `Object.defineProperty` on a proxied object will not be synchronized to the other side. 323 | 324 | ## License 325 | 326 | [MIT License](LICENSE) 327 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quickjs-emscripten-sync", 3 | "author": "rot1024", 4 | "version": "1.5.2", 5 | "license": "MIT", 6 | "source": "./src/index.ts", 7 | "main": "./dist/quickjs-emscripten-sync.umd.js", 8 | "module": "./dist/quickjs-emscripten-sync.mjs", 9 | "unpkg": "./dist/quickjs-emscripten-sync.umd.js", 10 | "types": "./dist/index.d.ts", 11 | "sideEffects": false, 12 | "exports": { 13 | ".": { 14 | "import": "./dist/quickjs-emscripten-sync.mjs", 15 | "require": "./dist/quickjs-emscripten-sync.umd.js" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "src" 21 | ], 22 | "engines": { 23 | "node": ">=12" 24 | }, 25 | "scripts": { 26 | "build": "tsc && vite build", 27 | "test": "vitest", 28 | "lint": "eslint ." 29 | }, 30 | "peerDependencies": { 31 | "quickjs-emscripten": "*" 32 | }, 33 | "devDependencies": { 34 | "@vitest/coverage-c8": "^0.28.5", 35 | "eslint": "^8.22.0", 36 | "eslint-config-reearth": "^0.2.1", 37 | "prettier": "^2.7.1", 38 | "quickjs-emscripten": "^0.21.0", 39 | "typescript": "^4.8.2", 40 | "vite": "^4.1.2", 41 | "vite-plugin-dts": "^2.0.0-beta.1", 42 | "vitest": "^0.28.5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/contextex.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { ContextEx, wrapContext } from "./contextex"; 5 | 6 | test("wrapContext", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const c = wrapContext(ctx); 9 | c.disposeEx?.(); 10 | ctx.dispose(); 11 | }); 12 | 13 | test("ContextEx", async () => { 14 | try { 15 | const ctx = (await getQuickJS()).newContext(); 16 | const spy = vi.spyOn(ctx, "newFunction"); 17 | const ctxex = new ContextEx(ctx); 18 | 19 | const handle = ctxex.newFunction("", function (handle) { 20 | expect(ctx.getString(this)).toBe("this"); 21 | expect(ctx.getNumber(handle)).toBe(100); 22 | return ctx.newString("result"); 23 | }); 24 | 25 | for (let i = 0; i < 10000; i++) { 26 | const res = ctx.unwrapResult( 27 | ctx.callFunction(handle, ctx.newString("this"), ctx.newNumber(100)), 28 | ); 29 | expect(ctx.getString(res)).toBe("result"); 30 | res.dispose(); 31 | } 32 | expect(spy).toBeCalledTimes(1); 33 | 34 | handle.dispose(); 35 | ctxex.disposeEx(); 36 | ctx.dispose(); 37 | } catch (err) { 38 | // prevent freezing node when printing QuickJSContext 39 | delete (err as any).context; 40 | console.error(err); 41 | throw err; 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /src/contextex.ts: -------------------------------------------------------------------------------- 1 | import { QuickJSContext, QuickJSHandle, VmFunctionImplementation } from "quickjs-emscripten"; 2 | 3 | export type QuickJSContextEx = QuickJSContext & { 4 | disposeEx?: () => void; 5 | }; 6 | 7 | export const wrapContext = (ctx: QuickJSContext): QuickJSContextEx => { 8 | const ctxex = new ContextEx(ctx); 9 | return new Proxy(ctx, { 10 | get(target, p, receiver) { 11 | return p in ctxex ? (ctxex as any)[p] : Reflect.get(target, p, receiver); 12 | }, 13 | }) as QuickJSContextEx; 14 | }; 15 | 16 | export class ContextEx { 17 | context: QuickJSContext; 18 | fn: QuickJSHandle; 19 | fnGenerator: QuickJSHandle; 20 | fnCounter = Number.MIN_SAFE_INTEGER; 21 | fnMap = new Map>(); 22 | 23 | constructor(context: QuickJSContext) { 24 | this.context = context; 25 | const fnMap = this.fnMap; 26 | this.fn = this.context.newFunction("", function (idHandle, ...argHandles) { 27 | const id = context.getNumber(idHandle); 28 | const f = fnMap.get(id); 29 | if (!f) throw new Error("function is not registered"); 30 | return f.call(this, ...argHandles); 31 | }); 32 | this.fnGenerator = context.unwrapResult( 33 | context.evalCode(`((name, length, id, f) => { 34 | const fn = function(...args) { 35 | return f.call(this, id, ...args); 36 | }; 37 | fn.name = name; 38 | fn.length = length; 39 | return fn; 40 | })`), 41 | ); 42 | } 43 | 44 | disposeEx(): void { 45 | this.fnGenerator.dispose(); 46 | this.fn.dispose(); 47 | } 48 | 49 | /** Similar to the original newFunction, but no matter how many new functions are generated, newFunction is called only once. */ 50 | newFunction = (name: string, fn: VmFunctionImplementation): QuickJSHandle => { 51 | this.fnCounter++; 52 | const id = this.fnCounter; 53 | this.fnMap.set(id, fn); 54 | return this.context.unwrapResult( 55 | this.context.callFunction( 56 | this.fnGenerator, 57 | this.context.undefined, 58 | this.context.newString(name), 59 | this.context.newNumber(fn.length), 60 | this.context.newNumber(id), 61 | this.fn, 62 | ), 63 | ); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/default.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default value of registeredObjects option of the Arena class constructor. 3 | */ 4 | export const defaultRegisteredObjects: [any, string][] = [ 5 | // basic objects 6 | [Symbol, "Symbol"], 7 | [Symbol.prototype, "Symbol.prototype"], 8 | [Object, "Object"], 9 | [Object.prototype, "Object.prototype"], 10 | [Function, "Function"], 11 | [Function.prototype, "Function.prototype"], 12 | [Boolean, "Boolean"], 13 | [Boolean.prototype, "Boolean.prototype"], 14 | [Array, "Array"], 15 | [Array.prototype, "Array.prototype"], 16 | // [BigInt, "BigInt"], 17 | // [BigInt.prototype, "BigInt.prototype"], 18 | // errors 19 | [Error, "Error"], 20 | [Error.prototype, "Error.prototype"], 21 | [EvalError, "EvalError"], 22 | [EvalError.prototype, "EvalError.prototype"], 23 | [RangeError, "RangeError"], 24 | [RangeError.prototype, "RangeError.prototype"], 25 | [ReferenceError, "ReferenceError"], 26 | [ReferenceError.prototype, "ReferenceError.prototype"], 27 | [SyntaxError, "SyntaxError"], 28 | [SyntaxError.prototype, "SyntaxError.prototype"], 29 | [TypeError, "TypeError"], 30 | [TypeError.prototype, "TypeError.prototype"], 31 | [URIError, "URIError"], 32 | [URIError.prototype, "URIError.prototype"], 33 | // built-in symbols 34 | ...Object.getOwnPropertyNames(Symbol) 35 | .filter(k => typeof (Symbol as any)[k] === "symbol") 36 | .map<[any, string]>(k => [(Symbol as any)[k], `Symbol.${k}`]), 37 | ]; 38 | -------------------------------------------------------------------------------- /src/edge.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { Arena } from "."; 5 | 6 | // tests for edge cases 7 | 8 | describe("edge cases", () => { 9 | // this test takes more than about 20s 10 | test("getter", async () => { 11 | const ctx = (await getQuickJS()).newContext(); 12 | const arena = new Arena(ctx, { isMarshalable: true }); 13 | 14 | const called: string[] = []; 15 | const obj = { c: 0 }; 16 | const exposed = { 17 | get a() { 18 | called.push("a"); 19 | return { 20 | get b() { 21 | called.push("b"); 22 | return obj; 23 | }, 24 | }; 25 | }, 26 | }; 27 | const cb: { current?: () => any } = {}; 28 | const register = (fn: () => any) => { 29 | cb.current = fn; 30 | }; 31 | 32 | arena.expose({ exposed, register }); 33 | expect(called).toEqual([]); 34 | 35 | arena.evalCode(`register(() => exposed.a.b.c);`); 36 | expect(cb.current?.()).toBe(0); 37 | expect(called).toEqual(["a", "b"]); 38 | 39 | obj.c = 1; 40 | expect(cb.current?.()).toBe(1); // this line causes an error when context is disposed 41 | expect(called).toEqual(["a", "b", "a", "b"]); 42 | 43 | arena.dispose(); 44 | // ctx.dispose(); // reports an error 45 | }); 46 | 47 | test("many newFunction", async () => { 48 | const rt = (await getQuickJS()).newRuntime(); 49 | const ctx = rt.newContext(); 50 | const arena = new Arena(ctx, { 51 | isMarshalable: true, 52 | // enable this option to solve this problem 53 | experimentalContextEx: true, 54 | }); 55 | 56 | arena.expose({ 57 | hoge: () => {}, 58 | }); 59 | // should have an object as an arg 60 | const fn = arena.evalCode(`() => { hoge([]); }`); 61 | // error happens from 3926 times 62 | for (let i = 0; i < 10000; i++) { 63 | fn(); 64 | } 65 | 66 | arena.dispose(); 67 | ctx.dispose(); 68 | rt.dispose(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { isWrapped } from "./wrapper"; 5 | 6 | import { Arena } from "."; 7 | 8 | describe("readme", () => { 9 | test("first", async () => { 10 | class Cls { 11 | field = 0; 12 | 13 | method() { 14 | return ++this.field; 15 | } 16 | } 17 | 18 | const ctx = (await getQuickJS()).newContext(); 19 | const arena = new Arena(ctx, { isMarshalable: true }); 20 | 21 | // We can pass objects to the VM and run code safely 22 | const exposed = { 23 | Cls, 24 | cls: new Cls(), 25 | syncedCls: arena.sync(new Cls()), 26 | }; 27 | arena.expose(exposed); 28 | 29 | expect(arena.evalCode(`cls instanceof Cls`)).toBe(true); 30 | expect(arena.evalCode(`cls.field`)).toBe(0); 31 | expect(arena.evalCode(`cls.method()`)).toBe(1); 32 | expect(arena.evalCode(`cls.field`)).toBe(1); 33 | 34 | expect(arena.evalCode(`syncedCls.field`)).toBe(0); 35 | expect(exposed.syncedCls.method()).toBe(1); 36 | expect(arena.evalCode(`syncedCls.field`)).toBe(1); 37 | 38 | arena.dispose(); 39 | ctx.dispose(); 40 | }); 41 | 42 | test("usage", async () => { 43 | const quickjs = await getQuickJS(); 44 | const ctx = quickjs.newContext(); 45 | 46 | // init Arena 47 | // ⚠️ Marshaling is opt-in for security reasons. 48 | // ⚠️ Be careful when activating marshalling. 49 | const arena = new Arena(ctx, { isMarshalable: true }); 50 | 51 | // expose objects as global objects in QuickJS VM 52 | const log = vi.fn(); 53 | arena.expose({ 54 | console: { log }, 55 | }); 56 | arena.evalCode(`console.log("hello, world");`); // run console.log 57 | expect(log).toBeCalledWith("hello, world"); 58 | arena.evalCode(`1 + 1`); // 2 59 | 60 | // expose objects but also enable sync 61 | const data = arena.sync({ hoge: "foo" }); 62 | arena.expose({ data }); 63 | 64 | arena.evalCode(`data.hoge = "bar"`); 65 | // eval code and operations to exposed objects are automatically synced 66 | expect(data.hoge).toBe("bar"); 67 | data.hoge = "changed!"; 68 | expect(arena.evalCode(`data.hoge`)).toBe("changed!"); 69 | 70 | // Don't forget calling arena.dispose() before disposing QuickJS VM! 71 | arena.dispose(); 72 | ctx.dispose(); 73 | }); 74 | }); 75 | 76 | describe("evalCode", () => { 77 | test("simple object and function", async () => { 78 | const ctx = (await getQuickJS()).newContext(); 79 | const arena = new Arena(ctx, { isMarshalable: true }); 80 | 81 | const result = arena.evalCode( 82 | `({ 83 | a: 1, 84 | b: a => Math.floor(a), 85 | c: () => { throw new Error("hoge") }, 86 | d: (yourFavoriteNumber) => ({ 87 | myFavoriteNumber: 42, 88 | yourFavoriteNumber, 89 | }), 90 | get e() { 91 | return { a: 1 }; 92 | } 93 | })`, 94 | ); 95 | expect(result).toEqual({ 96 | a: 1, 97 | b: expect.any(Function), 98 | c: expect.any(Function), 99 | d: expect.any(Function), 100 | e: { a: 1 }, 101 | }); 102 | expect(result.b(1.1)).toBe(1); 103 | expect(() => result.c()).toThrow("hoge"); 104 | expect(result.d(1)).toStrictEqual({ 105 | myFavoriteNumber: 42, 106 | yourFavoriteNumber: 1, 107 | }); 108 | expect(result.e).toStrictEqual({ a: 1 }); 109 | 110 | arena.dispose(); 111 | ctx.dispose(); 112 | }); 113 | 114 | test("Math", async () => { 115 | const ctx = (await getQuickJS()).newContext(); 116 | const arena = new Arena(ctx, { isMarshalable: true }); 117 | 118 | const VMMath = arena.evalCode(`Math`) as Math; 119 | expect(VMMath.floor(1.1)).toBe(1); 120 | 121 | arena.dispose(); 122 | ctx.dispose(); 123 | }); 124 | 125 | test("Date", async () => { 126 | const ctx = (await getQuickJS()).newContext(); 127 | const arena = new Arena(ctx, { isMarshalable: true }); 128 | 129 | const date = new Date(2022, 7, 26); 130 | expect(arena.evalCode("new Date(2022, 7, 26)")).toEqual(date); 131 | expect(arena.evalCode("d => d instanceof Date")(date)).toBe(true); 132 | expect(arena.evalCode("d => d.getTime()")(date)).toBe(date.getTime()); 133 | 134 | arena.dispose(); 135 | ctx.dispose(); 136 | }); 137 | 138 | test("class", async () => { 139 | const ctx = (await getQuickJS()).newContext(); 140 | const arena = new Arena(ctx, { isMarshalable: true }); 141 | 142 | const instance = arena.evalCode(`{ 143 | globalThis.Cls = class D { 144 | constructor(a) { 145 | this.a = a + 1; 146 | } 147 | foo() { 148 | return ++this.a; 149 | } 150 | }; 151 | 152 | new Cls(100); 153 | }`); 154 | const Cls = arena.evalCode(`globalThis.Cls`); 155 | expect(instance instanceof Cls).toBe(true); 156 | expect(instance.a).toBe(101); 157 | expect(instance.foo()).toBe(102); 158 | expect(instance.a).toBe(102); 159 | 160 | arena.dispose(); 161 | ctx.dispose(); 162 | }); 163 | 164 | test("obj", async () => { 165 | const ctx = (await getQuickJS()).newContext(); 166 | const arena = new Arena(ctx, { isMarshalable: true }); 167 | 168 | const obj = arena.evalCode(`globalThis.AAA = { a: 1 }`); 169 | 170 | expect(obj).toEqual({ a: 1 }); 171 | expect(arena.evalCode(`AAA.a`)).toBe(1); 172 | obj.a = 2; 173 | expect(obj).toEqual({ a: 2 }); 174 | expect(arena.evalCode(`AAA.a`)).toBe(2); 175 | 176 | arena.dispose(); 177 | ctx.dispose(); 178 | }); 179 | 180 | test("promise", async () => { 181 | const ctx = (await getQuickJS()).newContext(); 182 | const arena = new Arena(ctx, { isMarshalable: true }); 183 | 184 | const [promise, resolve] = arena.evalCode<[Promise, (d: string) => void]>(` 185 | let resolve; 186 | const promise = new Promise(r => { 187 | resolve = r; 188 | }).then(d => d + "!"); 189 | [promise, resolve] 190 | `); 191 | expect(promise).instanceOf(Promise); 192 | expect(isWrapped(arena._unwrapIfNotSynced(promise), arena._symbol)).toBe(false); 193 | 194 | resolve("hoge"); 195 | expect(arena.executePendingJobs()).toBe(2); 196 | expect(await promise).toBe("hoge!"); 197 | 198 | arena.dispose(); 199 | ctx.dispose(); 200 | }); 201 | 202 | test("promise2", async () => { 203 | const ctx = (await getQuickJS()).newContext(); 204 | const arena = new Arena(ctx, { isMarshalable: true }); 205 | 206 | const deferred: { resolve?: (s: string) => void } = {}; 207 | const promise = new Promise(resolve => { 208 | deferred.resolve = resolve; 209 | }); 210 | const res = vi.fn(); 211 | arena.evalCode(`(p, r) => { p.then(d => { r(d + "!"); }); }`)(promise, res); 212 | 213 | deferred.resolve?.("hoge"); 214 | await promise; 215 | expect(arena.executePendingJobs()).toBe(1); 216 | expect(res).toBeCalledWith("hoge!"); 217 | 218 | arena.dispose(); 219 | ctx.dispose(); 220 | }); 221 | 222 | test("async function", async () => { 223 | const ctx = (await getQuickJS()).newContext(); 224 | const arena = new Arena(ctx, { isMarshalable: true }); 225 | 226 | const consolelog = vi.fn(); 227 | arena.expose({ 228 | console: { 229 | log: consolelog, 230 | }, 231 | }); 232 | 233 | arena.evalCode(` 234 | const someAsyncOperation = async () => "hello"; 235 | const execute = async () => { 236 | try { 237 | const res = await someAsyncOperation(); 238 | console.log(res); 239 | } catch (e) { 240 | console.log(e); 241 | } 242 | }; 243 | execute(); 244 | `); 245 | expect(consolelog).toBeCalledTimes(0); 246 | expect(arena.executePendingJobs()).toBe(2); 247 | 248 | arena.executePendingJobs(); 249 | 250 | expect(consolelog).toBeCalledTimes(1); 251 | expect(consolelog).toBeCalledWith("hello"); 252 | expect(arena.executePendingJobs()).toBe(0); 253 | 254 | arena.dispose(); 255 | ctx.dispose(); 256 | }); 257 | }); 258 | 259 | describe("expose without sync", () => { 260 | test("simple object and function", async () => { 261 | const ctx = (await getQuickJS()).newContext(); 262 | const arena = new Arena(ctx, { isMarshalable: true }); 263 | 264 | const obj = { 265 | a: 1, 266 | b: (a: number) => Math.floor(a), 267 | c: () => { 268 | throw new Error("hoge"); 269 | }, 270 | d: (yourFavoriteNumber: number) => ({ 271 | myFavoriteNumber: 42, 272 | yourFavoriteNumber, 273 | }), 274 | get e() { 275 | return { a: 1 }; 276 | }, 277 | }; 278 | arena.expose({ 279 | obj, 280 | }); 281 | 282 | expect(arena.evalCode(`obj`)).toBe(obj); 283 | expect(arena.evalCode(`obj.a`)).toBe(1); 284 | expect(arena.evalCode(`obj.b(1.1)`)).toBe(1); 285 | expect(() => arena.evalCode(`obj.c()`)).toThrow("hoge"); 286 | expect(arena.evalCode(`obj.d(1)`)).toStrictEqual({ 287 | myFavoriteNumber: 42, 288 | yourFavoriteNumber: 1, 289 | }); 290 | expect(arena.evalCode(`obj.e`)).toStrictEqual({ a: 1 }); 291 | 292 | arena.dispose(); 293 | ctx.dispose(); 294 | }); 295 | 296 | test("Math", async () => { 297 | const ctx = (await getQuickJS()).newContext(); 298 | const arena = new Arena(ctx, { isMarshalable: true }); 299 | 300 | arena.expose({ Math2: Math }); 301 | expect(arena.evalCode(`Math`)).not.toBe(Math); 302 | expect(arena.evalCode(`Math2`)).toBe(Math); 303 | expect(arena.evalCode(`Math2.floor(1.1)`)).toBe(1); 304 | 305 | arena.dispose(); 306 | ctx.dispose(); 307 | }); 308 | 309 | test("class", async () => { 310 | const ctx = (await getQuickJS()).newContext(); 311 | const arena = new Arena(ctx, { isMarshalable: true }); 312 | 313 | class D { 314 | a: number; 315 | 316 | constructor(a: number) { 317 | this.a = a + 1; 318 | } 319 | 320 | foo() { 321 | return ++this.a; 322 | } 323 | } 324 | 325 | const d = new D(100); 326 | arena.expose({ D, d }); 327 | expect(arena.evalCode(`D`)).toBe(D); 328 | expect(arena.evalCode(`d`)).toBe(d); 329 | expect(arena.evalCode(`d instanceof D`)).toBe(true); 330 | expect(arena.evalCode(`d.a`)).toBe(101); 331 | expect(arena.evalCode(`d.foo()`)).toBe(102); 332 | expect(arena.evalCode(`d.a`)).toBe(102); 333 | 334 | arena.dispose(); 335 | ctx.dispose(); 336 | }); 337 | 338 | test("object and function", async () => { 339 | const ctx = (await getQuickJS()).newContext(); 340 | const arena = new Arena(ctx, { isMarshalable: true }); 341 | 342 | const obj = { 343 | a: 1, 344 | b: (a: number) => Math.floor(a), 345 | c() { 346 | return this.a++; 347 | }, 348 | }; 349 | arena.expose({ obj }); 350 | 351 | expect(arena.evalCode(`obj`)).toBe(obj); 352 | expect(arena.evalCode(`obj.a`)).toBe(1); 353 | expect(arena.evalCode(`obj.b`)).toBe(obj.b); 354 | expect(arena.evalCode(`obj.b(1.1)`)).toBe(1); 355 | expect(arena.evalCode(`obj.c`)).toBe(obj.c); 356 | expect(arena.evalCode(`obj.c()`)).toBe(1); 357 | expect(arena.evalCode(`obj.a`)).toBe(2); 358 | expect(obj.a).toBe(2); 359 | expect(arena.evalCode(`obj.c()`)).toBe(2); 360 | expect(arena.evalCode(`obj.a`)).toBe(3); 361 | expect(obj.a).toBe(3); 362 | 363 | obj.a = 10; 364 | expect(obj.a).toBe(10); 365 | expect(arena.evalCode(`obj.a`)).toBe(3); // not affected 366 | 367 | arena.evalCode(`obj.a = 100`); 368 | expect(obj.a).toBe(10); // not affected 369 | expect(arena.evalCode(`obj.a`)).toBe(100); 370 | 371 | arena.dispose(); 372 | ctx.dispose(); 373 | }); 374 | }); 375 | 376 | describe("expose with sync", () => { 377 | test("sync before expose", async () => { 378 | const ctx = (await getQuickJS()).newContext(); 379 | const arena = new Arena(ctx, { isMarshalable: true }); 380 | 381 | const obj = { 382 | a: 1, 383 | b: (a: number) => Math.floor(a), 384 | c() { 385 | return this.a++; 386 | }, 387 | }; 388 | const obj2 = arena.sync(obj); 389 | arena.expose({ obj: obj2 }); 390 | 391 | const obj3 = arena.evalCode(`obj`); 392 | expect(obj3).toBe(obj2); 393 | expect(arena.evalCode(`obj.c`)).not.toBe(obj.c); // wrapped object 394 | expect(arena.evalCode(`obj.b`)).not.toBe(obj2.b); // wrapped object 395 | expect(arena.evalCode(`obj.b`)).not.toBe(obj3.b); // wrapped object 396 | expect(arena.evalCode(`obj.b(1.1)`)).toBe(1); 397 | expect(arena.evalCode(`obj.a`)).toBe(1); 398 | expect(arena.evalCode(`obj.c`)).not.toBe(obj.c); // wrapped object 399 | expect(arena.evalCode(`obj.c`)).not.toBe(obj2.c); // wrapped object 400 | expect(arena.evalCode(`obj.c`)).not.toBe(obj3.c); // wrapped object 401 | expect(arena.evalCode(`obj.c()`)).toBe(1); 402 | expect(arena.evalCode(`obj.a`)).toBe(2); 403 | expect(obj.a).toBe(2); 404 | expect(arena.evalCode(`obj.c()`)).toBe(2); 405 | expect(arena.evalCode(`obj.a`)).toBe(3); 406 | expect(obj.a).toBe(3); 407 | 408 | expect(obj).not.toBe(obj2); 409 | obj2.a = 10; 410 | expect(obj.a).toBe(10); 411 | expect(arena.evalCode(`obj.a`)).toBe(10); // affected 412 | 413 | arena.evalCode(`obj.a = 100`); 414 | expect(obj.a).toBe(100); // affected 415 | expect(arena.evalCode(`obj.a`)).toBe(100); 416 | 417 | arena.dispose(); 418 | ctx.dispose(); 419 | }); 420 | 421 | test("sync after expose", async () => { 422 | const ctx = (await getQuickJS()).newContext(); 423 | const arena = new Arena(ctx, { isMarshalable: true }); 424 | 425 | const obj = { 426 | a: 1, 427 | b: (a: number) => Math.floor(a), 428 | c() { 429 | return this.a++; 430 | }, 431 | }; 432 | arena.expose({ obj }); 433 | const obj2 = arena.sync(obj); 434 | 435 | const obj3 = arena.evalCode(`obj`); 436 | expect(obj3).not.toBe(obj); // wrapped object 437 | expect(obj3).not.toBe(obj2); // wrapped object 438 | expect(arena.evalCode(`obj.c`)).not.toBe(obj.c); // wrapped object 439 | expect(arena.evalCode(`obj.b`)).not.toBe(obj2.b); // wrapped object 440 | expect(arena.evalCode(`obj.b`)).not.toBe(obj3.b); // wrapped object 441 | expect(arena.evalCode(`obj.b(1.1)`)).toBe(1); 442 | expect(arena.evalCode(`obj.a`)).toBe(1); 443 | expect(arena.evalCode(`obj.c`)).not.toBe(obj.c); // wrapped object 444 | expect(arena.evalCode(`obj.c`)).not.toBe(obj2.c); // wrapped object 445 | expect(arena.evalCode(`obj.c`)).not.toBe(obj3.c); // wrapped object 446 | expect(arena.evalCode(`obj.c()`)).toBe(1); 447 | expect(arena.evalCode(`obj.a`)).toBe(2); 448 | expect(obj.a).toBe(2); 449 | expect(arena.evalCode(`obj.c()`)).toBe(2); 450 | expect(arena.evalCode(`obj.a`)).toBe(3); 451 | expect(obj.a).toBe(3); 452 | 453 | expect(obj).not.toBe(obj2); 454 | obj2.a = 10; 455 | expect(obj.a).toBe(10); 456 | expect(arena.evalCode(`obj.a`)).toBe(10); // affected 457 | 458 | arena.evalCode(`obj.a = 100`); 459 | expect(obj.a).toBe(100); // affected 460 | expect(arena.evalCode(`obj.a`)).toBe(100); 461 | 462 | arena.dispose(); 463 | ctx.dispose(); 464 | }); 465 | }); 466 | 467 | test("evalCode -> expose", async () => { 468 | const ctx = (await getQuickJS()).newContext(); 469 | const arena = new Arena(ctx, { isMarshalable: true }); 470 | 471 | const obj = arena.evalCode(`({ a: 1, b: 1 })`); 472 | arena.expose({ obj }); 473 | 474 | expect(obj).toBe(obj); 475 | expect(obj.a).toBe(1); 476 | expect(arena.evalCode(`obj.a`)).toBe(1); 477 | expect(obj.b).toBe(1); 478 | expect(arena.evalCode(`obj.b`)).toBe(1); 479 | 480 | obj.a = 2; 481 | 482 | expect(obj.a).toBe(2); 483 | expect(arena.evalCode(`obj.a`)).toBe(2); 484 | expect(obj.b).toBe(1); 485 | expect(arena.evalCode(`obj.b`)).toBe(1); 486 | 487 | expect(arena.evalCode(`obj.b = 2`)).toBe(2); 488 | 489 | expect(obj.a).toBe(2); 490 | expect(arena.evalCode(`obj.a`)).toBe(2); 491 | expect(obj.b).toBe(2); 492 | expect(arena.evalCode(`obj.b`)).toBe(2); 493 | 494 | arena.dispose(); 495 | ctx.dispose(); 496 | }); 497 | 498 | test("expose -> evalCode", async () => { 499 | const ctx = (await getQuickJS()).newContext(); 500 | const arena = new Arena(ctx, { isMarshalable: true }); 501 | 502 | const obj = { a: 1 }; 503 | arena.expose({ obj }); 504 | const obj2 = arena.evalCode(`obj`); 505 | 506 | expect(obj2).toBe(obj); 507 | 508 | obj2.a = 2; 509 | expect(obj.a).toBe(2); 510 | expect(arena.evalCode(`obj.a`)).toBe(1); 511 | 512 | arena.evalCode("obj.a = 3"); 513 | expect(obj.a).toBe(2); 514 | expect(arena.evalCode(`obj.a`)).toBe(3); 515 | 516 | arena.dispose(); 517 | ctx.dispose(); 518 | }); 519 | 520 | test("evalCode -> expose -> evalCode", async () => { 521 | const ctx = (await getQuickJS()).newContext(); 522 | const arena = new Arena(ctx, { isMarshalable: true }); 523 | 524 | const obj = [1]; 525 | expect(arena.evalCode("a => a[0] + 10")(obj)).toBe(11); 526 | arena.expose({ obj }); 527 | expect(arena.evalCode("obj")).toBe(obj); 528 | 529 | arena.dispose(); 530 | ctx.dispose(); 531 | }); 532 | 533 | test("register and unregister", async () => { 534 | const ctx = (await getQuickJS()).newContext(); 535 | const arena = new Arena(ctx, { isMarshalable: true, registeredObjects: [] }); 536 | 537 | arena.register(Math, `Math`); 538 | expect(arena.evalCode(`Math`)).toBe(Math); 539 | expect(arena.evalCode(`m => m === Math`)(Math)).toBe(true); 540 | 541 | arena.unregister(Math); 542 | expect(arena.evalCode(`Math`)).not.toBe(Math); 543 | expect(arena.evalCode(`m => m === Math`)(Math)).toBe(false); 544 | 545 | arena.register(Error, `Error`); 546 | arena.register(Error.prototype, `Error.prototype`); 547 | expect(arena.evalCode(`new Error()`)).toBeInstanceOf(Error); 548 | 549 | arena.dispose(); 550 | ctx.dispose(); 551 | }); 552 | 553 | test("registeredObjects option", async () => { 554 | const ctx = (await getQuickJS()).newContext(); 555 | const arena = new Arena(ctx, { 556 | isMarshalable: true, 557 | registeredObjects: [[Symbol.iterator, "Symbol.iterator"]], 558 | }); 559 | 560 | expect(arena.evalCode(`Symbol.iterator`)).toBe(Symbol.iterator); 561 | expect(arena.evalCode(`s => s === Symbol.iterator`)(Symbol.iterator)).toBe(true); 562 | 563 | arena.dispose(); 564 | ctx.dispose(); 565 | }); 566 | 567 | describe("isMarshalable option", () => { 568 | test("false", async () => { 569 | const ctx = (await getQuickJS()).newContext(); 570 | const arena = new Arena(ctx, { isMarshalable: false }); 571 | 572 | expect(arena.evalCode(`s => s === undefined`)(globalThis)).toBe(true); 573 | expect(arena.evalCode(`s => s === undefined`)({})).toBe(true); 574 | arena.expose({ aaa: globalThis }); 575 | expect(arena.evalCode(`aaa`)).toBeUndefined(); 576 | 577 | arena.dispose(); 578 | ctx.dispose(); 579 | }); 580 | 581 | test("json", async () => { 582 | const ctx = (await getQuickJS()).newContext(); 583 | const arena = new Arena(ctx, { isMarshalable: "json" }); 584 | 585 | const obj = { a: () => {}, b: new Date(), c: [() => {}, 1] }; 586 | const objJSON = { b: obj.b.toISOString(), c: [null, 1] }; 587 | const objJSON2 = arena.evalCode(`a => a`)(obj); 588 | expect(objJSON2).toStrictEqual(objJSON); 589 | arena.expose({ obj }); 590 | const exposedObj = arena.evalCode(`obj`); 591 | expect(exposedObj).toStrictEqual(objJSON); 592 | expect(exposedObj).not.toBe(objJSON2); 593 | 594 | arena.dispose(); 595 | ctx.dispose(); 596 | }); 597 | 598 | test("conditional", async () => { 599 | const ctx = (await getQuickJS()).newContext(); 600 | const arena = new Arena(ctx, { 601 | isMarshalable: o => o !== globalThis, 602 | }); 603 | 604 | const obj = { a: 1 }; 605 | expect(arena.evalCode(`s => s === undefined`)(globalThis)).toBe(true); 606 | expect(arena.evalCode(`s => s === undefined`)(obj)).toBe(false); 607 | arena.expose({ aaa: globalThis, bbb: obj }); 608 | expect(arena.evalCode(`aaa`)).toBeUndefined(); 609 | expect(arena.evalCode(`bbb`)).toBe(obj); 610 | 611 | arena.dispose(); 612 | ctx.dispose(); 613 | }); 614 | }); 615 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | QuickJSDeferredPromise, 3 | QuickJSHandle, 4 | QuickJSContext, 5 | SuccessOrFail, 6 | VmCallResult, 7 | } from "quickjs-emscripten"; 8 | 9 | import { wrapContext, QuickJSContextEx } from "./contextex"; 10 | import { defaultRegisteredObjects } from "./default"; 11 | import marshal from "./marshal"; 12 | import unmarshal from "./unmarshal"; 13 | import { complexity, isES2015Class, isObject, walkObject } from "./util"; 14 | import VMMap from "./vmmap"; 15 | import { call, eq, isHandleObject, json, consumeAll, mayConsume, handleFrom } from "./vmutil"; 16 | import { wrap, wrapHandle, unwrap, unwrapHandle, Wrapped } from "./wrapper"; 17 | 18 | export { 19 | VMMap, 20 | defaultRegisteredObjects, 21 | marshal, 22 | unmarshal, 23 | complexity, 24 | isES2015Class, 25 | isObject, 26 | walkObject, 27 | call, 28 | eq, 29 | isHandleObject, 30 | json, 31 | consumeAll, 32 | }; 33 | 34 | export type Options = { 35 | /** A callback that returns a boolean value that determines whether an object is marshalled or not. If false, no marshaling will be done and undefined will be passed to the QuickJS VM, otherwise marshaling will be done. By default, all objects will be marshalled. */ 36 | isMarshalable?: boolean | "json" | ((target: any) => boolean | "json"); 37 | /** Pre-registered pairs of objects that will be considered the same between the host and the QuickJS VM. This will be used automatically during the conversion. By default, it will be registered automatically with `defaultRegisteredObjects`. 38 | * 39 | * Instead of a string, you can also pass a QuickJSHandle directly. In that case, however, you have to dispose of them manually when destroying the VM. 40 | */ 41 | registeredObjects?: Iterable<[any, QuickJSHandle | string]>; 42 | /** Register functions to convert an object to a QuickJS handle. */ 43 | customMarshaller?: Iterable<(target: unknown, ctx: QuickJSContext) => QuickJSHandle | undefined>; 44 | /** Register functions to convert a QuickJS handle to an object. */ 45 | customUnmarshaller?: Iterable<(target: QuickJSHandle, ctx: QuickJSContext) => any>; 46 | /** A callback that returns a boolean value that determines whether an object is wrappable by proxies. If returns false, note that the object cannot be synchronized between the host and the QuickJS even if arena.sync is used. */ 47 | isWrappable?: (target: any) => boolean; 48 | /** A callback that returns a boolean value that determines whether an QuickJS handle is wrappable by proxies. If returns false, note that the handle cannot be synchronized between the host and the QuickJS even if arena.sync is used. */ 49 | isHandleWrappable?: (handle: QuickJSHandle, ctx: QuickJSContext) => boolean; 50 | /** Compatibility with quickjs-emscripten prior to v0.15. Inject code for compatibility into context at Arena class initialization time. */ 51 | compat?: boolean; 52 | /** Experimental: use QuickJSContextEx, which wraps existing QuickJSContext. */ 53 | experimentalContextEx?: boolean; 54 | }; 55 | 56 | /** 57 | * The Arena class manages all generated handles at once by quickjs-emscripten and automatically converts objects between the host and the QuickJS VM. 58 | */ 59 | export class Arena { 60 | context: QuickJSContextEx; 61 | _map: VMMap; 62 | _registeredMap: VMMap; 63 | _registeredMapDispose: Set = new Set(); 64 | _sync: Set = new Set(); 65 | _temporalSync: Set = new Set(); 66 | _symbol = Symbol(); 67 | _symbolHandle: QuickJSHandle; 68 | _options?: Options; 69 | 70 | /** Constructs a new Arena instance. It requires a quickjs-emscripten context initialized with `quickjs.newContext()`. */ 71 | constructor(ctx: QuickJSContext, options?: Options) { 72 | if (options?.compat && !("runtime" in ctx)) { 73 | (ctx as any).runtime = { 74 | hasPendingJob: () => (ctx as any).hasPendingJob(), 75 | executePendingJobs: (maxJobsToExecute?: number | void) => 76 | (ctx as any).executePendingJobs(maxJobsToExecute), 77 | }; 78 | } 79 | 80 | this.context = options?.experimentalContextEx ? wrapContext(ctx) : ctx; 81 | this._options = options; 82 | this._symbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 83 | this._map = new VMMap(ctx); 84 | this._registeredMap = new VMMap(ctx); 85 | this.registerAll(options?.registeredObjects ?? defaultRegisteredObjects); 86 | } 87 | 88 | /** 89 | * Dispose of the arena and managed handles. This method won't dispose the VM itself, so the VM has to be disposed of manually. 90 | */ 91 | dispose() { 92 | this._map.dispose(); 93 | this._registeredMap.dispose(); 94 | this._symbolHandle.dispose(); 95 | this.context.disposeEx?.(); 96 | } 97 | 98 | /** 99 | * Evaluate JS code in the VM and get the result as an object on the host side. It also converts and re-throws error objects when an error is thrown during evaluation. 100 | */ 101 | evalCode(code: string): T { 102 | const handle = this.context.evalCode(code); 103 | return this._unwrapResultAndUnmarshal(handle); 104 | } 105 | 106 | /** 107 | * Almost same as `vm.executePendingJobs()`, but it converts and re-throws error objects when an error is thrown during evaluation. 108 | */ 109 | executePendingJobs(maxJobsToExecute?: number): number { 110 | const result = this.context.runtime.executePendingJobs(maxJobsToExecute); 111 | if ("value" in result) { 112 | return result.value; 113 | } 114 | throw this._unwrapIfNotSynced(result.error.consume(this._unmarshal)); 115 | } 116 | 117 | /** 118 | * Expose objects as global objects in the VM. 119 | * 120 | * By default, exposed objects are not synchronized between the host and the VM. 121 | * If you want to sync an objects, first wrap the object with sync method, and then expose the wrapped object. 122 | */ 123 | expose(obj: { [k: string]: any }) { 124 | for (const [key, value] of Object.entries(obj)) { 125 | mayConsume(this._marshal(value), handle => { 126 | this.context.setProp(this.context.global, key, handle); 127 | }); 128 | } 129 | } 130 | 131 | /** 132 | * Enables sync for the object between the host and the VM and returns objects wrapped with proxies. 133 | * 134 | * The return value is necessary in order to reflect changes to the object from the host to the VM. Please note that setting a value in the field or deleting a field in the original object will not synchronize it. 135 | */ 136 | sync(target: T): T { 137 | const wrapped = this._wrap(target); 138 | if (typeof wrapped === "undefined") return target; 139 | walkObject(wrapped, v => { 140 | const u = this._unwrap(v); 141 | this._sync.add(u); 142 | }); 143 | return wrapped; 144 | } 145 | 146 | /** 147 | * Register a pair of objects that will be considered the same between the host and the QuickJS VM. 148 | * 149 | * Instead of a string, you can also pass a QuickJSHandle directly. In that case, however, when you have to dispose them manually when destroying the VM. 150 | */ 151 | register(target: any, handleOrCode: QuickJSHandle | string) { 152 | if (this._registeredMap.has(target)) return; 153 | const handle = 154 | typeof handleOrCode === "string" 155 | ? this._unwrapResult(this.context.evalCode(handleOrCode)) 156 | : handleOrCode; 157 | if (eq(this.context, handle, this.context.undefined)) return; 158 | if (typeof handleOrCode === "string") { 159 | this._registeredMapDispose.add(target); 160 | } 161 | this._registeredMap.set(target, handle); 162 | } 163 | 164 | /** 165 | * Execute `register` methods for each pair. 166 | */ 167 | registerAll(map: Iterable<[any, QuickJSHandle | string]>) { 168 | for (const [k, v] of map) { 169 | this.register(k, v); 170 | } 171 | } 172 | 173 | /** 174 | * Unregister a pair of objects that were registered with `registeredObjects` option and `register` method. 175 | */ 176 | unregister(target: any, dispose?: boolean) { 177 | this._registeredMap.delete(target, this._registeredMapDispose.has(target) || dispose); 178 | this._registeredMapDispose.delete(target); 179 | } 180 | 181 | /** 182 | * Execute `unregister` methods for each target. 183 | */ 184 | unregisterAll(targets: Iterable, dispose?: boolean) { 185 | for (const t of targets) { 186 | this.unregister(t, dispose); 187 | } 188 | } 189 | 190 | startSync(target: any) { 191 | if (!isObject(target)) return; 192 | const u = this._unwrap(target); 193 | this._sync.add(u); 194 | } 195 | 196 | endSync(target: any) { 197 | this._sync.delete(this._unwrap(target)); 198 | } 199 | 200 | _unwrapResult(result: SuccessOrFail): T { 201 | if ("value" in result) { 202 | return result.value; 203 | } 204 | throw this._unwrapIfNotSynced(result.error.consume(this._unmarshal)); 205 | } 206 | 207 | _unwrapResultAndUnmarshal(result: VmCallResult | undefined): any { 208 | if (!result) return; 209 | return this._unwrapIfNotSynced(this._unwrapResult(result).consume(this._unmarshal)); 210 | } 211 | 212 | _isMarshalable = (t: unknown): boolean | "json" => { 213 | const im = this._options?.isMarshalable; 214 | return (typeof im === "function" ? im(this._unwrap(t)) : im) ?? "json"; 215 | }; 216 | 217 | _marshalFind = (t: unknown) => { 218 | const unwrappedT = this._unwrap(t); 219 | const handle = 220 | this._registeredMap.get(t) ?? 221 | (unwrappedT !== t ? this._registeredMap.get(unwrappedT) : undefined) ?? 222 | this._map.get(t) ?? 223 | (unwrappedT !== t ? this._map.get(unwrappedT) : undefined); 224 | return handle; 225 | }; 226 | 227 | _marshalPre = ( 228 | t: unknown, 229 | h: QuickJSHandle | QuickJSDeferredPromise, 230 | mode: true | "json" | undefined, 231 | ): Wrapped | undefined => { 232 | if (mode === "json") return; 233 | return this._register(t, handleFrom(h), this._map)?.[1]; 234 | }; 235 | 236 | _marshalPreApply = (target: Function, that: unknown, args: unknown[]): void => { 237 | const unwrapped = isObject(that) ? this._unwrap(that) : undefined; 238 | // override sync mode of this object while calling the function 239 | if (unwrapped) this._temporalSync.add(unwrapped); 240 | try { 241 | return target.apply(that, args); 242 | } finally { 243 | // restore sync mode 244 | if (unwrapped) this._temporalSync.delete(unwrapped); 245 | } 246 | }; 247 | 248 | _marshal = (target: any): [QuickJSHandle, boolean] => { 249 | const registered = this._registeredMap.get(target); 250 | if (registered) { 251 | return [registered, false]; 252 | } 253 | 254 | const handle = marshal(this._wrap(target) ?? target, { 255 | ctx: this.context, 256 | unmarshal: this._unmarshal, 257 | isMarshalable: this._isMarshalable, 258 | find: this._marshalFind, 259 | pre: this._marshalPre, 260 | preApply: this._marshalPreApply, 261 | custom: this._options?.customMarshaller, 262 | }); 263 | 264 | return [handle, !this._map.hasHandle(handle)]; 265 | }; 266 | 267 | _preUnmarshal = (t: any, h: QuickJSHandle): Wrapped => { 268 | return this._register(t, h, undefined, true)?.[0]; 269 | }; 270 | 271 | _unmarshalFind = (h: QuickJSHandle): unknown => { 272 | return this._registeredMap.getByHandle(h) ?? this._map.getByHandle(h); 273 | }; 274 | 275 | _unmarshal = (handle: QuickJSHandle): any => { 276 | const registered = this._registeredMap.getByHandle(handle); 277 | if (typeof registered !== "undefined") { 278 | return registered; 279 | } 280 | 281 | const [wrappedHandle] = this._wrapHandle(handle); 282 | return unmarshal(wrappedHandle ?? handle, { 283 | ctx: this.context, 284 | marshal: this._marshal, 285 | find: this._unmarshalFind, 286 | pre: this._preUnmarshal, 287 | custom: this._options?.customUnmarshaller, 288 | }); 289 | }; 290 | 291 | _register( 292 | t: any, 293 | h: QuickJSHandle, 294 | map: VMMap = this._map, 295 | sync?: boolean, 296 | ): [Wrapped, Wrapped] | undefined { 297 | if (this._registeredMap.has(t) || this._registeredMap.hasHandle(h)) { 298 | return; 299 | } 300 | 301 | let wrappedT = this._wrap(t); 302 | const [wrappedH] = this._wrapHandle(h); 303 | const isPromise = t instanceof Promise; 304 | if (!wrappedH || (!wrappedT && !isPromise)) return; // t or h is not an object 305 | if (isPromise) wrappedT = t; 306 | 307 | const unwrappedT = this._unwrap(t); 308 | const [unwrappedH, unwrapped] = this._unwrapHandle(h); 309 | 310 | const res = map.set(wrappedT, wrappedH, unwrappedT, unwrappedH); 311 | if (!res) { 312 | // already registered 313 | if (unwrapped) unwrappedH.dispose(); 314 | throw new Error("already registered"); 315 | } else if (sync) { 316 | this._sync.add(unwrappedT); 317 | } 318 | 319 | return [wrappedT, wrappedH]; 320 | } 321 | 322 | _syncMode = (obj: any): "both" | undefined => { 323 | const obj2 = this._unwrap(obj); 324 | return this._sync.has(obj2) || this._temporalSync.has(obj2) ? "both" : undefined; 325 | }; 326 | 327 | _wrap(target: T): Wrapped | undefined { 328 | return wrap( 329 | this.context, 330 | target, 331 | this._symbol, 332 | this._symbolHandle, 333 | this._marshal, 334 | this._syncMode, 335 | this._options?.isWrappable, 336 | ); 337 | } 338 | 339 | _unwrap(target: T): T { 340 | return unwrap(target, this._symbol); 341 | } 342 | 343 | _unwrapIfNotSynced = (target: T): T => { 344 | const unwrapped = this._unwrap(target); 345 | return unwrapped instanceof Promise || !this._sync.has(unwrapped) ? unwrapped : target; 346 | }; 347 | 348 | _wrapHandle(handle: QuickJSHandle): [Wrapped | undefined, boolean] { 349 | return wrapHandle( 350 | this.context, 351 | handle, 352 | this._symbol, 353 | this._symbolHandle, 354 | this._unmarshal, 355 | this._syncMode, 356 | this._options?.isHandleWrappable, 357 | ); 358 | } 359 | 360 | _unwrapHandle(target: QuickJSHandle): [QuickJSHandle, boolean] { 361 | return unwrapHandle(this.context, target, this._symbolHandle); 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /src/marshal/custom.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { call } from "../vmutil"; 5 | 6 | import marshalCustom, { defaultCustom } from "./custom"; 7 | 8 | test("symbol", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | const pre = vi.fn(); 11 | const sym = Symbol("foobar"); 12 | 13 | const marshal = (t: unknown) => marshalCustom(ctx, t, pre, defaultCustom); 14 | 15 | expect(marshal({})).toBe(undefined); 16 | expect(pre).toBeCalledTimes(0); 17 | 18 | const handle = marshal(sym); 19 | if (!handle) throw new Error("handle is undefined"); 20 | expect(ctx.typeof(handle)).toBe("symbol"); 21 | expect(ctx.getString(ctx.getProp(handle, "description"))).toBe("foobar"); 22 | expect(pre).toReturnTimes(1); 23 | expect(pre.mock.calls[0][0]).toBe(sym); 24 | expect(pre.mock.calls[0][1] === handle).toBe(true); 25 | 26 | handle.dispose(); 27 | ctx.dispose(); 28 | }); 29 | 30 | test("date", async () => { 31 | const ctx = (await getQuickJS()).newContext(); 32 | const pre = vi.fn(); 33 | const date = new Date(2022, 7, 26); 34 | 35 | const marshal = (t: unknown) => marshalCustom(ctx, t, pre, defaultCustom); 36 | 37 | expect(marshal({})).toBe(undefined); 38 | expect(pre).toBeCalledTimes(0); 39 | 40 | const handle = marshal(date); 41 | if (!handle) throw new Error("handle is undefined"); 42 | expect(ctx.dump(call(ctx, "d => d instanceof Date", undefined, handle))).toBe(true); 43 | expect(ctx.dump(call(ctx, "d => d.getTime()", undefined, handle))).toBe(date.getTime()); 44 | expect(pre).toReturnTimes(1); 45 | expect(pre.mock.calls[0][0]).toBe(date); 46 | expect(pre.mock.calls[0][1] === handle).toBe(true); 47 | 48 | handle.dispose(); 49 | ctx.dispose(); 50 | }); 51 | -------------------------------------------------------------------------------- /src/marshal/custom.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | export default function marshalCustom( 6 | ctx: QuickJSContext, 7 | target: unknown, 8 | preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined, 9 | custom: Iterable<(target: unknown, ctx: QuickJSContext) => QuickJSHandle | undefined>, 10 | ): QuickJSHandle | undefined { 11 | let handle: QuickJSHandle | undefined; 12 | for (const c of custom) { 13 | handle = c(target, ctx); 14 | if (handle) break; 15 | } 16 | return handle ? preMarshal(target, handle) ?? handle : undefined; 17 | } 18 | 19 | export function symbol(target: unknown, ctx: QuickJSContext): QuickJSHandle | undefined { 20 | if (typeof target !== "symbol") return; 21 | const handle = call( 22 | ctx, 23 | "d => Symbol(d)", 24 | undefined, 25 | target.description ? ctx.newString(target.description) : ctx.undefined, 26 | ); 27 | return handle; 28 | } 29 | 30 | export function date(target: unknown, ctx: QuickJSContext): QuickJSHandle | undefined { 31 | if (!(target instanceof Date)) return; 32 | const handle = call(ctx, "d => new Date(d)", undefined, ctx.newNumber(target.getTime())); 33 | return handle; 34 | } 35 | 36 | export const defaultCustom = [symbol, date]; 37 | -------------------------------------------------------------------------------- /src/marshal/function.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { json, eq, call } from "../vmutil"; 5 | 6 | import marshalFunction from "./function"; 7 | 8 | test("normal func", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | 11 | const marshal = vi.fn(v => json(ctx, v)); 12 | const unmarshal = vi.fn(v => (eq(ctx, v, ctx.global) ? undefined : ctx.dump(v))); 13 | const preMarshal = vi.fn((_, a) => a); 14 | const innerfn = vi.fn((..._args: any[]) => "hoge"); 15 | const fn = (...args: any[]) => innerfn(...args); 16 | 17 | const handle = marshalFunction(ctx, fn, marshal, unmarshal, preMarshal); 18 | if (!handle) throw new Error("handle is undefined"); 19 | 20 | expect(marshal.mock.calls).toEqual([["length"], [0], ["name"], ["fn"]]); // fn.length, fn.name 21 | expect(preMarshal.mock.calls).toEqual([[fn, handle]]); // fn.length, fn.name 22 | expect(ctx.typeof(handle)).toBe("function"); 23 | expect(ctx.dump(ctx.getProp(handle, "length"))).toBe(0); 24 | expect(ctx.dump(ctx.getProp(handle, "name"))).toBe("fn"); 25 | 26 | const result = ctx.unwrapResult( 27 | ctx.callFunction(handle, ctx.undefined, ctx.newNumber(1), ctx.true), 28 | ); 29 | 30 | expect(ctx.dump(result)).toBe("hoge"); 31 | expect(innerfn).toBeCalledWith(1, true); 32 | expect(marshal).toHaveBeenLastCalledWith("hoge"); 33 | expect(unmarshal).toBeCalledTimes(3); 34 | expect(unmarshal.mock.results[0].value).toBe(undefined); // this 35 | expect(unmarshal.mock.results[1].value).toBe(1); 36 | expect(unmarshal.mock.results[2].value).toBe(true); 37 | 38 | handle.dispose(); 39 | ctx.dispose(); 40 | }); 41 | 42 | test("func which has properties", async () => { 43 | const ctx = (await getQuickJS()).newContext(); 44 | const marshal = vi.fn(v => json(ctx, v)); 45 | 46 | const fn = () => {}; 47 | fn.hoge = "foo"; 48 | 49 | const handle = marshalFunction( 50 | ctx, 51 | fn, 52 | marshal, 53 | v => ctx.dump(v), 54 | (_, a) => a, 55 | ); 56 | if (!handle) throw new Error("handle is undefined"); 57 | 58 | expect(ctx.typeof(handle)).toBe("function"); 59 | expect(ctx.dump(ctx.getProp(handle, "hoge"))).toBe("foo"); 60 | expect(marshal).toBeCalledWith("foo"); 61 | 62 | handle.dispose(); 63 | ctx.dispose(); 64 | }); 65 | 66 | test("class", async () => { 67 | const ctx = (await getQuickJS()).newContext(); 68 | 69 | const disposables: QuickJSHandle[] = []; 70 | const marshal = (v: any) => { 71 | if (typeof v === "string") return ctx.newString(v); 72 | if (typeof v === "number") return ctx.newNumber(v); 73 | if (typeof v === "object") { 74 | const obj = ctx.newObject(); 75 | disposables.push(obj); 76 | return obj; 77 | } 78 | return ctx.null; 79 | }; 80 | const unmarshal = (v: QuickJSHandle) => ctx.dump(v); 81 | 82 | class A { 83 | a: number; 84 | 85 | constructor(a: number) { 86 | this.a = a; 87 | } 88 | } 89 | 90 | const handle = marshalFunction(ctx, A, marshal, unmarshal, (_, a) => a); 91 | if (!handle) throw new Error("handle is undefined"); 92 | 93 | const newA = ctx.unwrapResult(ctx.evalCode(`A => new A(100)`)); 94 | const instance = ctx.unwrapResult(ctx.callFunction(newA, ctx.undefined, handle)); 95 | 96 | expect(ctx.dump(ctx.getProp(handle, "name"))).toBe("A"); 97 | expect(ctx.dump(ctx.getProp(handle, "length"))).toBe(1); 98 | expect(ctx.dump(call(ctx, "(cls, i) => i instanceof cls", undefined, handle, instance))).toBe( 99 | true, 100 | ); 101 | expect(ctx.dump(ctx.getProp(instance, "a"))).toBe(100); 102 | 103 | disposables.forEach(d => d.dispose()); 104 | instance.dispose(); 105 | newA.dispose(); 106 | handle.dispose(); 107 | ctx.dispose(); 108 | }); 109 | 110 | test("preApply", async () => { 111 | const ctx = (await getQuickJS()).newContext(); 112 | 113 | const marshal = (v: any) => { 114 | if (typeof v === "string") return ctx.newString(v); 115 | if (typeof v === "number") return ctx.newNumber(v); 116 | return ctx.null; 117 | }; 118 | const unmarshal = (v: QuickJSHandle) => (ctx.typeof(v) === "object" ? that : ctx.dump(v)); 119 | const preApply = vi.fn((a: Function, b: any, c: any[]) => a.apply(b, c) + "!"); 120 | const that = {}; 121 | const thatHandle = ctx.newObject(); 122 | 123 | const fn = () => "foo"; 124 | const handle = marshalFunction(ctx, fn, marshal, unmarshal, (_, a) => a, preApply); 125 | if (!handle) throw new Error("handle is undefined"); 126 | 127 | expect(preApply).toBeCalledTimes(0); 128 | 129 | const res = ctx.unwrapResult( 130 | ctx.callFunction(handle, thatHandle, ctx.newNumber(100), ctx.newString("hoge")), 131 | ); 132 | 133 | expect(preApply).toBeCalledTimes(1); 134 | expect(preApply).toBeCalledWith(fn, that, [100, "hoge"]); 135 | expect(ctx.dump(res)).toBe("foo!"); 136 | 137 | thatHandle.dispose(); 138 | handle.dispose(); 139 | ctx.dispose(); 140 | }); 141 | 142 | test("undefined", async () => { 143 | const ctx = (await getQuickJS()).newContext(); 144 | const f = vi.fn(); 145 | 146 | expect(marshalFunction(ctx, undefined, f, f, f)).toBe(undefined); 147 | expect(marshalFunction(ctx, null, f, f, f)).toBe(undefined); 148 | expect(marshalFunction(ctx, false, f, f, f)).toBe(undefined); 149 | expect(marshalFunction(ctx, true, f, f, f)).toBe(undefined); 150 | expect(marshalFunction(ctx, 1, f, f, f)).toBe(undefined); 151 | expect(marshalFunction(ctx, [1], f, f, f)).toBe(undefined); 152 | expect(marshalFunction(ctx, { a: 1 }, f, f, f)).toBe(undefined); 153 | expect(f).toBeCalledTimes(0); 154 | 155 | ctx.dispose(); 156 | }); 157 | -------------------------------------------------------------------------------- /src/marshal/function.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { isES2015Class, isObject } from "../util"; 4 | import { call } from "../vmutil"; 5 | 6 | import marshalProperties from "./properties"; 7 | 8 | export default function marshalFunction( 9 | ctx: QuickJSContext, 10 | target: unknown, 11 | marshal: (target: unknown) => QuickJSHandle, 12 | unmarshal: (handle: QuickJSHandle) => unknown, 13 | preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined, 14 | preApply?: (target: Function, thisArg: unknown, args: unknown[]) => any, 15 | ): QuickJSHandle | undefined { 16 | if (typeof target !== "function") return; 17 | 18 | const raw = ctx 19 | .newFunction(target.name, function (...argHandles) { 20 | const that = unmarshal(this); 21 | const args = argHandles.map(a => unmarshal(a)); 22 | 23 | if (isES2015Class(target) && isObject(that)) { 24 | // Class constructors cannot be invoked without new expression, and new.target is not changed 25 | const result = new target(...args); 26 | Object.entries(result).forEach(([key, value]) => { 27 | ctx.setProp(this, key, marshal(value)); 28 | }); 29 | return this; 30 | } 31 | 32 | return marshal(preApply ? preApply(target, that, args) : target.apply(that, args)); 33 | }) 34 | .consume(handle2 => 35 | // fucntions created by vm.newFunction are not callable as a class constrcutor 36 | call( 37 | ctx, 38 | `Cls => { 39 | const fn = function(...args) { return Cls.apply(this, args); }; 40 | fn.name = Cls.name; 41 | fn.length = Cls.length; 42 | return fn; 43 | }`, 44 | undefined, 45 | handle2, 46 | ), 47 | ); 48 | 49 | const handle = preMarshal(target, raw) ?? raw; 50 | marshalProperties(ctx, target, raw, marshal); 51 | 52 | return handle; 53 | } 54 | -------------------------------------------------------------------------------- /src/marshal/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { newDeferred } from "../util"; 5 | import VMMap from "../vmmap"; 6 | import { instanceOf, call, handleFrom, fn } from "../vmutil"; 7 | 8 | import marshal from "."; 9 | 10 | test("primitive, array, object", async () => { 11 | const { ctx, map, marshal, dispose } = await setup(); 12 | 13 | const target = { 14 | hoge: "foo", 15 | foo: 1, 16 | aaa: [1, true, {}], 17 | nested: { aa: null, hoge: undefined }, 18 | }; 19 | const handle = marshal(target); 20 | 21 | expect(ctx.dump(handle)).toEqual(target); 22 | expect(map.size).toBe(4); 23 | expect(map.get(target)).toBe(handle); 24 | expect(map.has(target.aaa)).toBe(true); 25 | expect(map.has(target.nested)).toBe(true); 26 | expect(map.has(target.aaa[2])).toBe(true); 27 | 28 | dispose(); 29 | }); 30 | 31 | test("object with symbol key", async () => { 32 | const { ctx, marshal, dispose } = await setup(); 33 | 34 | const target = { 35 | foo: "hoge", 36 | [Symbol("a")]: 1, 37 | }; 38 | const handle = marshal(target); 39 | expect(ctx.dump(call(ctx, `a => a.foo`, undefined, handle))).toBe("hoge"); 40 | expect(ctx.dump(call(ctx, `a => a[Object.getOwnPropertySymbols(a)[0]]`, undefined, handle))).toBe( 41 | 1, 42 | ); 43 | 44 | dispose(); 45 | }); 46 | 47 | test("arrow function", async () => { 48 | const { ctx, map, marshal, dispose } = await setup(); 49 | const hoge = () => "foo"; 50 | hoge.foo = { bar: 1 }; 51 | const handle = marshal(hoge); 52 | 53 | expect(ctx.typeof(handle)).toBe("function"); 54 | expect(ctx.dump(ctx.getProp(handle, "length"))).toBe(0); 55 | expect(ctx.dump(ctx.getProp(handle, "name"))).toBe("hoge"); 56 | const foo = ctx.getProp(handle, "foo"); 57 | expect(ctx.dump(foo)).toEqual({ bar: 1 }); 58 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(handle, ctx.undefined)))).toBe("foo"); 59 | expect(map.size).toBe(2); 60 | expect(map.get(hoge)).toBe(handle); 61 | expect(map.has(hoge.foo)).toBe(true); 62 | 63 | foo.dispose(); 64 | dispose(); 65 | }); 66 | 67 | test("function", async () => { 68 | const { ctx, map, marshal, dispose } = await setup(); 69 | 70 | const bar = function (a: number, b: { hoge: number }) { 71 | return a + b.hoge; 72 | }; 73 | const handle = marshal(bar); 74 | 75 | expect(ctx.typeof(handle)).toBe("function"); 76 | expect(ctx.dump(ctx.getProp(handle, "length"))).toBe(2); 77 | expect(ctx.dump(ctx.getProp(handle, "name"))).toBe("bar"); 78 | expect(map.size).toBe(2); 79 | expect(map.get(bar)).toBe(handle); 80 | expect(map.has(bar.prototype)).toBe(true); 81 | 82 | const b = ctx.unwrapResult(ctx.evalCode(`({ hoge: 2 })`)); 83 | expect( 84 | ctx.dump(ctx.unwrapResult(ctx.callFunction(handle, ctx.undefined, ctx.newNumber(1), b))), 85 | ).toBe(3); 86 | 87 | b.dispose(); 88 | dispose(); 89 | }); 90 | 91 | test("promise", async () => { 92 | const { ctx, marshal, dispose } = await setup(); 93 | const register = fn( 94 | ctx, 95 | `promise => { promise.then(d => notify("resolve", d), d => notify("reject", d)); }`, 96 | ); 97 | 98 | let notified: any; 99 | ctx 100 | .newFunction("notify", (...handles) => { 101 | notified = handles.map(h => ctx.dump(h)); 102 | }) 103 | .consume(h => { 104 | ctx.setProp(ctx.global, "notify", h); 105 | }); 106 | 107 | const deferred = newDeferred(); 108 | const handle = marshal(deferred.promise); 109 | register(undefined, handle); 110 | 111 | deferred.resolve("foo"); 112 | await deferred.promise; 113 | expect(ctx.unwrapResult(ctx.runtime.executePendingJobs())).toBe(1); 114 | expect(notified).toEqual(["resolve", "foo"]); 115 | 116 | const deferred2 = newDeferred(); 117 | const handle2 = marshal(deferred2.promise); 118 | register(undefined, handle2); 119 | 120 | deferred2.reject("bar"); 121 | await expect(deferred2.promise).rejects.toBe("bar"); 122 | expect(ctx.unwrapResult(ctx.runtime.executePendingJobs())).toBe(1); 123 | expect(notified).toEqual(["reject", "bar"]); 124 | 125 | register.dispose(); 126 | dispose(); 127 | }); 128 | 129 | test("class", async () => { 130 | const { ctx, map, marshal, dispose } = await setup(); 131 | 132 | class A { 133 | a: number; 134 | b: string; 135 | 136 | static a = new A("a"); 137 | 138 | constructor(b: string) { 139 | this.a = 100; 140 | this.b = b + "!"; 141 | } 142 | 143 | hoge() { 144 | return ++this.a; 145 | } 146 | 147 | get foo() { 148 | return this.b; 149 | } 150 | 151 | set foo(b: string) { 152 | this.b = b + "!"; 153 | } 154 | } 155 | 156 | expect(A.name).toBe("A"); 157 | const handle = marshal(A); 158 | if (!map) throw new Error("map is undefined"); 159 | 160 | expect(map.size).toBe(6); 161 | expect(map.get(A)).toBe(handle); 162 | expect(map.has(A.prototype)).toBe(true); 163 | expect(map.has(A.a)).toBe(true); 164 | expect(map.has(A.prototype.hoge)).toBe(true); 165 | expect(map.has(Object.getOwnPropertyDescriptor(A.prototype, "foo")?.get)).toBe(true); 166 | expect(map.has(Object.getOwnPropertyDescriptor(A.prototype, "foo")?.set)).toBe(true); 167 | 168 | expect(ctx.typeof(handle)).toBe("function"); 169 | expect(ctx.dump(ctx.getProp(handle, "length"))).toBe(1); 170 | expect(ctx.dump(ctx.getProp(handle, "name"))).toBe("A"); 171 | const staticA = ctx.getProp(handle, "a"); 172 | expect(instanceOf(ctx, staticA, handle)).toBe(true); 173 | expect(ctx.dump(ctx.getProp(staticA, "a"))).toBe(100); 174 | expect(ctx.dump(ctx.getProp(staticA, "b"))).toBe("a!"); 175 | 176 | const newA = ctx.unwrapResult(ctx.evalCode(`A => new A("foo")`)); 177 | const instance = ctx.unwrapResult(ctx.callFunction(newA, ctx.undefined, handle)); 178 | expect(instanceOf(ctx, instance, handle)).toBe(true); 179 | expect(ctx.dump(ctx.getProp(instance, "a"))).toBe(100); 180 | expect(ctx.dump(ctx.getProp(instance, "b"))).toBe("foo!"); 181 | const methodHoge = ctx.getProp(instance, "hoge"); 182 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(methodHoge, instance)))).toBe(101); 183 | expect(ctx.dump(ctx.getProp(instance, "a"))).toBe(100); // not synced 184 | 185 | const getter = ctx.unwrapResult(ctx.evalCode(`a => a.foo`)); 186 | const setter = ctx.unwrapResult(ctx.evalCode(`(a, b) => a.foo = b`)); 187 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(getter, ctx.undefined, instance)))).toBe( 188 | "foo!", 189 | ); 190 | ctx.unwrapResult(ctx.callFunction(setter, ctx.undefined, instance, ctx.newString("b"))); 191 | expect(ctx.dump(ctx.getProp(instance, "b"))).toBe("foo!"); // not synced 192 | 193 | staticA.dispose(); 194 | newA.dispose(); 195 | instance.dispose(); 196 | methodHoge.dispose(); 197 | getter.dispose(); 198 | setter.dispose(); 199 | dispose(); 200 | }); 201 | 202 | test("date", async () => { 203 | const { ctx, map, marshal, dispose } = await setup(); 204 | 205 | const date = new Date(2022, 7, 26); 206 | const handle = marshal(date); 207 | if (!map) throw new Error("map is undefined"); 208 | 209 | expect(map.size).toBe(1); 210 | expect(map.get(date)).toBe(handle); 211 | 212 | expect(ctx.dump(call(ctx, "d => d instanceof Date", undefined, handle))).toBe(true); 213 | expect(ctx.dump(call(ctx, "d => d.getTime()", undefined, handle))).toBe(date.getTime()); 214 | 215 | dispose(); 216 | }); 217 | 218 | test("marshalable", async () => { 219 | const isMarshalable = vi.fn((a: any) => a !== globalThis); 220 | const { ctx, marshal, dispose } = await setup({ 221 | isMarshalable, 222 | }); 223 | 224 | const handle = marshal({ a: globalThis, b: 1 }); 225 | 226 | expect(ctx.dump(handle)).toEqual({ a: undefined, b: 1 }); 227 | expect(isMarshalable).toBeCalledWith(globalThis); 228 | expect(isMarshalable).toReturnWith(false); 229 | 230 | dispose(); 231 | }); 232 | 233 | test("marshalable json", async () => { 234 | const isMarshalable = vi.fn(() => "json" as const); 235 | const { ctx, marshal, dispose } = await setup({ 236 | isMarshalable, 237 | }); 238 | 239 | class Hoge {} 240 | const target = { 241 | a: { c: () => 1, d: new Date(), e: [() => 1, 1, new Hoge()] }, 242 | b: 1, 243 | }; 244 | const handle = marshal(target); 245 | 246 | expect(ctx.dump(handle)).toEqual({ 247 | a: { d: target.a.d.toISOString(), e: [null, 1, {}] }, 248 | b: 1, 249 | }); 250 | expect(isMarshalable).toBeCalledTimes(1); 251 | expect(isMarshalable).toBeCalledWith(target); 252 | expect(isMarshalable).toReturnWith("json"); 253 | 254 | dispose(); 255 | }); 256 | 257 | const setup = async ({ 258 | isMarshalable, 259 | }: { 260 | isMarshalable?: (target: any) => boolean | "json"; 261 | } = {}) => { 262 | const ctx = (await getQuickJS()).newContext(); 263 | const map = new VMMap(ctx); 264 | return { 265 | ctx, 266 | map, 267 | marshal: (v: any) => 268 | marshal(v, { 269 | ctx: ctx, 270 | unmarshal: h => map.getByHandle(h) ?? ctx.dump(h), 271 | isMarshalable, 272 | pre: (t, d) => { 273 | const h = handleFrom(d); 274 | map.set(t, h); 275 | return h; 276 | }, 277 | find: t => map.get(t), 278 | }), 279 | dispose: () => { 280 | map.dispose(); 281 | ctx.dispose(); 282 | }, 283 | }; 284 | }; 285 | -------------------------------------------------------------------------------- /src/marshal/index.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSDeferredPromise, QuickJSHandle, QuickJSContext } from "quickjs-emscripten"; 2 | 3 | import marshalCustom, { defaultCustom } from "./custom"; 4 | import marshalFunction from "./function"; 5 | import marshalJSON from "./json"; 6 | import marshalObject from "./object"; 7 | import marshalPrimitive from "./primitive"; 8 | import marshalPromise from "./promise"; 9 | 10 | export type Options = { 11 | ctx: QuickJSContext; 12 | unmarshal: (handle: QuickJSHandle) => unknown; 13 | isMarshalable?: (target: unknown) => boolean | "json"; 14 | find: (target: unknown) => QuickJSHandle | undefined; 15 | pre: ( 16 | target: unknown, 17 | handle: QuickJSHandle | QuickJSDeferredPromise, 18 | mode: true | "json" | undefined, 19 | ) => QuickJSHandle | undefined; 20 | preApply?: (target: Function, thisArg: unknown, args: unknown[]) => any; 21 | custom?: Iterable<(obj: unknown, ctx: QuickJSContext) => QuickJSHandle | undefined>; 22 | }; 23 | 24 | export function marshal(target: unknown, options: Options): QuickJSHandle { 25 | const { ctx, unmarshal, isMarshalable, find, pre } = options; 26 | 27 | { 28 | const primitive = marshalPrimitive(ctx, target); 29 | if (primitive) { 30 | return primitive; 31 | } 32 | } 33 | 34 | { 35 | const handle = find(target); 36 | if (handle) return handle; 37 | } 38 | 39 | const marshalable = isMarshalable?.(target); 40 | if (marshalable === false) { 41 | return ctx.undefined; 42 | } 43 | 44 | const pre2 = (target: any, handle: QuickJSHandle | QuickJSDeferredPromise) => 45 | pre(target, handle, marshalable); 46 | if (marshalable === "json") { 47 | return marshalJSON(ctx, target, pre2); 48 | } 49 | 50 | const marshal2 = (t: unknown) => marshal(t, options); 51 | return ( 52 | marshalCustom(ctx, target, pre2, [...defaultCustom, ...(options.custom ?? [])]) ?? 53 | marshalPromise(ctx, target, marshal2, pre2) ?? 54 | marshalFunction(ctx, target, marshal2, unmarshal, pre2, options.preApply) ?? 55 | marshalObject(ctx, target, marshal2, pre2) ?? 56 | ctx.undefined 57 | ); 58 | } 59 | 60 | export default marshal; 61 | -------------------------------------------------------------------------------- /src/marshal/json.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import marshalJSON from "./json"; 5 | 6 | test("empty object", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const prototypeCheck = ctx.unwrapResult( 9 | ctx.evalCode(`o => Object.getPrototypeOf(o) === Object.prototype`), 10 | ); 11 | 12 | const obj = {}; 13 | const preMarshal = vi.fn((_, a) => a); 14 | 15 | const handle = marshalJSON(ctx, obj, preMarshal); 16 | if (!handle) throw new Error("handle is undefined"); 17 | 18 | expect(ctx.typeof(handle)).toBe("object"); 19 | expect(preMarshal).toBeCalledTimes(1); 20 | expect(preMarshal.mock.calls[0][0]).toBe(obj); 21 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 22 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(prototypeCheck, ctx.undefined, handle)))).toBe( 23 | true, 24 | ); 25 | 26 | handle.dispose(); 27 | prototypeCheck.dispose(); 28 | ctx.dispose(); 29 | }); 30 | 31 | test("normal object", async () => { 32 | const ctx = (await getQuickJS()).newContext(); 33 | const prototypeCheck = ctx.unwrapResult( 34 | ctx.evalCode(`o => Object.getPrototypeOf(o) === Object.prototype`), 35 | ); 36 | const entries = ctx.unwrapResult(ctx.evalCode(`Object.entries`)); 37 | 38 | const obj = { a: 100, b: "hoge" }; 39 | const preMarshal = vi.fn((_, a) => a); 40 | 41 | const handle = marshalJSON(ctx, obj, preMarshal); 42 | if (!handle) throw new Error("handle is undefined"); 43 | 44 | expect(ctx.typeof(handle)).toBe("object"); 45 | expect(ctx.getNumber(ctx.getProp(handle, "a"))).toBe(100); 46 | expect(ctx.getString(ctx.getProp(handle, "b"))).toBe("hoge"); 47 | expect(preMarshal).toBeCalledTimes(1); 48 | expect(preMarshal.mock.calls[0][0]).toBe(obj); 49 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 50 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(prototypeCheck, ctx.undefined, handle)))).toBe( 51 | true, 52 | ); 53 | const e = ctx.unwrapResult(ctx.callFunction(entries, ctx.undefined, handle)); 54 | expect(ctx.dump(e)).toEqual([ 55 | ["a", 100], 56 | ["b", "hoge"], 57 | ]); 58 | 59 | e.dispose(); 60 | handle.dispose(); 61 | prototypeCheck.dispose(); 62 | entries.dispose(); 63 | ctx.dispose(); 64 | }); 65 | 66 | test("array", async () => { 67 | const ctx = (await getQuickJS()).newContext(); 68 | const isArray = ctx.unwrapResult(ctx.evalCode(`Array.isArray`)); 69 | 70 | const array = [1, "aa"]; 71 | const preMarshal = vi.fn((_, a) => a); 72 | 73 | const handle = marshalJSON(ctx, array, preMarshal); 74 | if (!handle) throw new Error("handle is undefined"); 75 | 76 | expect(ctx.typeof(handle)).toBe("object"); 77 | expect(ctx.getNumber(ctx.getProp(handle, 0))).toBe(1); 78 | expect(ctx.getString(ctx.getProp(handle, 1))).toBe("aa"); 79 | expect(ctx.getNumber(ctx.getProp(handle, "length"))).toBe(2); 80 | expect(preMarshal).toBeCalledTimes(1); 81 | expect(preMarshal.mock.calls[0][0]).toBe(array); 82 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 83 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(isArray, ctx.undefined, handle)))).toBe(true); 84 | 85 | handle.dispose(); 86 | isArray.dispose(); 87 | ctx.dispose(); 88 | }); 89 | -------------------------------------------------------------------------------- /src/marshal/json.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { json } from "../vmutil"; 4 | 5 | export default function marshalJSON( 6 | ctx: QuickJSContext, 7 | target: unknown, 8 | preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined, 9 | ): QuickJSHandle { 10 | const raw = json(ctx, target); 11 | const handle = preMarshal(target, raw) ?? raw; 12 | return handle; 13 | } 14 | -------------------------------------------------------------------------------- /src/marshal/object.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { call } from "../vmutil"; 5 | 6 | import marshalObject from "./object"; 7 | 8 | test("empty object", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | const prototypeCheck = ctx.unwrapResult( 11 | ctx.evalCode(`o => Object.getPrototypeOf(o) === Object.prototype`), 12 | ); 13 | 14 | const obj = {}; 15 | const marshal = vi.fn(); 16 | const preMarshal = vi.fn((_, a) => a); 17 | 18 | const handle = marshalObject(ctx, obj, marshal, preMarshal); 19 | if (!handle) throw new Error("handle is undefined"); 20 | 21 | expect(ctx.typeof(handle)).toBe("object"); 22 | expect(marshal).toBeCalledTimes(0); 23 | expect(preMarshal).toBeCalledTimes(1); 24 | expect(preMarshal.mock.calls[0][0]).toBe(obj); 25 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 26 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(prototypeCheck, ctx.undefined, handle)))).toBe( 27 | true, 28 | ); 29 | 30 | handle.dispose(); 31 | prototypeCheck.dispose(); 32 | ctx.dispose(); 33 | }); 34 | 35 | test("normal object", async () => { 36 | const ctx = (await getQuickJS()).newContext(); 37 | const prototypeCheck = ctx.unwrapResult( 38 | ctx.evalCode(`o => Object.getPrototypeOf(o) === Object.prototype`), 39 | ); 40 | const entries = ctx.unwrapResult(ctx.evalCode(`Object.entries`)); 41 | 42 | const obj = { a: 100, b: "hoge" }; 43 | const marshal = vi.fn(v => 44 | typeof v === "number" ? ctx.newNumber(v) : typeof v === "string" ? ctx.newString(v) : ctx.null, 45 | ); 46 | const preMarshal = vi.fn((_, a) => a); 47 | 48 | const handle = marshalObject(ctx, obj, marshal, preMarshal); 49 | if (!handle) throw new Error("handle is undefined"); 50 | 51 | expect(ctx.typeof(handle)).toBe("object"); 52 | expect(ctx.getNumber(ctx.getProp(handle, "a"))).toBe(100); 53 | expect(ctx.getString(ctx.getProp(handle, "b"))).toBe("hoge"); 54 | expect(marshal.mock.calls).toEqual([["a"], [100], ["b"], ["hoge"]]); 55 | expect(preMarshal).toBeCalledTimes(1); 56 | expect(preMarshal.mock.calls[0][0]).toBe(obj); 57 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 58 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(prototypeCheck, ctx.undefined, handle)))).toBe( 59 | true, 60 | ); 61 | const e = ctx.unwrapResult(ctx.callFunction(entries, ctx.undefined, handle)); 62 | expect(ctx.dump(e)).toEqual([ 63 | ["a", 100], 64 | ["b", "hoge"], 65 | ]); 66 | 67 | e.dispose(); 68 | handle.dispose(); 69 | prototypeCheck.dispose(); 70 | entries.dispose(); 71 | ctx.dispose(); 72 | }); 73 | 74 | test("array", async () => { 75 | const ctx = (await getQuickJS()).newContext(); 76 | const isArray = ctx.unwrapResult(ctx.evalCode(`Array.isArray`)); 77 | 78 | const array = [1, "aa"]; 79 | const marshal = vi.fn(v => 80 | typeof v === "number" ? ctx.newNumber(v) : typeof v === "string" ? ctx.newString(v) : ctx.null, 81 | ); 82 | const preMarshal = vi.fn((_, a) => a); 83 | 84 | const handle = marshalObject(ctx, array, marshal, preMarshal); 85 | if (!handle) throw new Error("handle is undefined"); 86 | 87 | expect(ctx.typeof(handle)).toBe("object"); 88 | expect(ctx.getNumber(ctx.getProp(handle, 0))).toBe(1); 89 | expect(ctx.getString(ctx.getProp(handle, 1))).toBe("aa"); 90 | expect(ctx.getNumber(ctx.getProp(handle, "length"))).toBe(2); 91 | expect(marshal.mock.calls).toEqual([["0"], [1], ["1"], ["aa"], ["length"], [2]]); 92 | expect(preMarshal).toBeCalledTimes(1); 93 | expect(preMarshal.mock.calls[0][0]).toBe(array); 94 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 95 | expect(ctx.dump(ctx.unwrapResult(ctx.callFunction(isArray, ctx.undefined, handle)))).toBe(true); 96 | 97 | handle.dispose(); 98 | isArray.dispose(); 99 | ctx.dispose(); 100 | }); 101 | 102 | test("prototype", async () => { 103 | const ctx = (await getQuickJS()).newContext(); 104 | 105 | const proto = { a: 100 }; 106 | const protoHandle = ctx.newObject(); 107 | ctx.setProp(protoHandle, "a", ctx.newNumber(100)); 108 | const preMarshal = vi.fn((_, a) => a); 109 | 110 | const obj = Object.create(proto); 111 | obj.b = "hoge"; 112 | const handle = marshalObject( 113 | ctx, 114 | obj, 115 | v => (v === proto ? protoHandle : typeof v === "string" ? ctx.newString(v) : ctx.null), 116 | preMarshal, 117 | ); 118 | if (!handle) throw new Error("handle is undefined"); 119 | 120 | expect(preMarshal).toBeCalledTimes(1); 121 | expect(preMarshal.mock.calls[0][0]).toBe(obj); 122 | expect(preMarshal.mock.calls[0][1] === handle).toBe(true); // avoid freeze 123 | expect(ctx.typeof(handle)).toBe("object"); 124 | expect(ctx.getNumber(ctx.getProp(handle, "a"))).toBe(100); 125 | expect(ctx.getString(ctx.getProp(handle, "b"))).toBe("hoge"); 126 | const e = call(ctx, "Object.entries", undefined, handle); 127 | expect(ctx.dump(e)).toEqual([["b", "hoge"]]); 128 | const protoHandle2 = call(ctx, "Object.getPrototypeOf", ctx.undefined, handle); 129 | expect(ctx.dump(call(ctx, `Object.is`, undefined, protoHandle, protoHandle2))).toBe(true); 130 | 131 | protoHandle2.dispose(); 132 | e.dispose(); 133 | handle.dispose(); 134 | protoHandle.dispose(); 135 | ctx.dispose(); 136 | }); 137 | -------------------------------------------------------------------------------- /src/marshal/object.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | import marshalProperties from "./properties"; 6 | 7 | export default function marshalObject( 8 | ctx: QuickJSContext, 9 | target: unknown, 10 | marshal: (target: unknown) => QuickJSHandle, 11 | preMarshal: (target: unknown, handle: QuickJSHandle) => QuickJSHandle | undefined, 12 | ): QuickJSHandle | undefined { 13 | if (typeof target !== "object" || target === null) return; 14 | 15 | const raw = Array.isArray(target) ? ctx.newArray() : ctx.newObject(); 16 | const handle = preMarshal(target, raw) ?? raw; 17 | 18 | // prototype 19 | const prototype = Object.getPrototypeOf(target); 20 | const prototypeHandle = 21 | prototype && prototype !== Object.prototype && prototype !== Array.prototype 22 | ? marshal(prototype) 23 | : undefined; 24 | if (prototypeHandle) { 25 | call(ctx, "Object.setPrototypeOf", undefined, handle, prototypeHandle).dispose(); 26 | } 27 | 28 | marshalProperties(ctx, target, raw, marshal); 29 | 30 | return handle; 31 | } 32 | -------------------------------------------------------------------------------- /src/marshal/primitive.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { eq } from "../vmutil"; 5 | 6 | import marshalPrimitive from "./primitive"; 7 | 8 | test("works", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | 11 | expect(marshalPrimitive(ctx, undefined)).toBe(ctx.undefined); 12 | expect(marshalPrimitive(ctx, null)).toBe(ctx.null); 13 | expect(marshalPrimitive(ctx, false)).toBe(ctx.false); 14 | expect(marshalPrimitive(ctx, true)).toBe(ctx.true); 15 | expect(eq(ctx, marshalPrimitive(ctx, 1) ?? ctx.undefined, ctx.newNumber(1))).toBe(true); 16 | expect(eq(ctx, marshalPrimitive(ctx, -100) ?? ctx.undefined, ctx.newNumber(-100))).toBe(true); 17 | expect(eq(ctx, marshalPrimitive(ctx, "hoge") ?? ctx.undefined, ctx.newString("hoge"))).toBe(true); 18 | // expect( 19 | // eq( 20 | // ctx, 21 | // marshalPrimitive(ctx, BigInt(1)) ?? ctx.undefined, 22 | // ctx.unwrapResult(ctx.evalCode("BigInt(1)")) 23 | // ) 24 | // ).toBe(true); 25 | 26 | expect(marshalPrimitive(ctx, () => {})).toBe(undefined); 27 | expect(marshalPrimitive(ctx, [])).toBe(undefined); 28 | expect(marshalPrimitive(ctx, {})).toBe(undefined); 29 | 30 | ctx.dispose(); 31 | }); 32 | -------------------------------------------------------------------------------- /src/marshal/primitive.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | // import { call } from "../vmutil"; 4 | 5 | export default function marshalPrimitive( 6 | ctx: QuickJSContext, 7 | target: unknown, 8 | ): QuickJSHandle | undefined { 9 | switch (typeof target) { 10 | case "undefined": 11 | return ctx.undefined; 12 | case "number": 13 | return ctx.newNumber(target); 14 | case "string": 15 | return ctx.newString(target); 16 | case "boolean": 17 | return target ? ctx.true : ctx.false; 18 | case "object": 19 | return target === null ? ctx.null : undefined; 20 | 21 | // BigInt is not supported by quickjs-emscripten 22 | // case "bigint": 23 | // return call( 24 | // ctx, 25 | // `s => BigInt(s)`, 26 | // undefined, 27 | // ctx.newString(target.toString()) 28 | // ); 29 | } 30 | 31 | return undefined; 32 | } 33 | -------------------------------------------------------------------------------- /src/marshal/promise.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getQuickJS, 3 | type Disposable, 4 | type QuickJSDeferredPromise, 5 | type QuickJSHandle, 6 | } from "quickjs-emscripten"; 7 | import { expect, test, vi } from "vitest"; 8 | 9 | import { newDeferred } from "../util"; 10 | import { fn, json } from "../vmutil"; 11 | 12 | import marshalPromise from "./promise"; 13 | 14 | const testPromise = (reject: boolean) => async () => { 15 | const ctx = (await getQuickJS()).newContext(); 16 | 17 | const disposables: Disposable[] = []; 18 | const marshal = vi.fn(v => { 19 | const handle = json(ctx, v); 20 | disposables.push(handle); 21 | return handle; 22 | }); 23 | const preMarshal = vi.fn((_: any, a: QuickJSDeferredPromise): QuickJSHandle => { 24 | disposables.push(a); 25 | return a.handle; 26 | }); 27 | 28 | const mockNotify = vi.fn(); 29 | const notify = ctx.newFunction("notify", (handle1, handle2) => { 30 | const arg1 = ctx.dump(handle1); 31 | const arg2 = ctx.dump(handle2); 32 | mockNotify(arg1, arg2); 33 | }); 34 | disposables.push(notify); 35 | 36 | const notifier = fn( 37 | ctx, 38 | `(notify, promise) => { promise.then(d => notify("resolved", d), d => notify("rejected", d)); }`, 39 | ); 40 | disposables.push(notifier); 41 | 42 | const deferred = newDeferred(); 43 | if (reject) { 44 | deferred.promise.catch(() => {}); 45 | } 46 | const handle = marshalPromise(ctx, deferred.promise, marshal, preMarshal); 47 | if (!handle) throw new Error("handle is undefined"); 48 | 49 | expect(marshal).toBeCalledTimes(0); 50 | expect(preMarshal).toBeCalledTimes(1); 51 | expect(preMarshal.mock.calls[0][0]).toBe(deferred.promise); 52 | expect(preMarshal.mock.calls[0][1].handle).toBe(handle); 53 | 54 | notifier(undefined, notify, handle); 55 | 56 | expect(mockNotify).toBeCalledTimes(0); 57 | expect(deferred.resolve).not.toBeUndefined(); 58 | expect(ctx.runtime.hasPendingJob()).toBe(false); 59 | 60 | if (reject) { 61 | deferred.reject("hoge"); 62 | } else { 63 | deferred.resolve("hoge"); 64 | } 65 | 66 | expect(ctx.runtime.hasPendingJob()).toBe(false); 67 | if (reject) { 68 | await expect(deferred.promise).rejects.toBe("hoge"); 69 | } else { 70 | await expect(deferred.promise).resolves.toBe("hoge"); 71 | } 72 | expect(ctx.runtime.hasPendingJob()).toBe(true); 73 | const executed = ctx.unwrapResult(ctx.runtime.executePendingJobs()); 74 | expect(executed).toBe(1); 75 | expect(mockNotify).toBeCalledTimes(1); 76 | expect(mockNotify).toBeCalledWith(reject ? "rejected" : "resolved", "hoge"); 77 | expect(marshal).toBeCalledTimes(1); 78 | expect(marshal.mock.calls).toEqual([["hoge"]]); 79 | 80 | disposables.forEach(h => h.dispose()); 81 | ctx.dispose(); 82 | }; 83 | 84 | test("resolve", testPromise(false)); 85 | test("reject", testPromise(true)); 86 | -------------------------------------------------------------------------------- /src/marshal/promise.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSDeferredPromise, QuickJSHandle, QuickJSContext } from "quickjs-emscripten"; 2 | 3 | export default function marshalPromise( 4 | ctx: QuickJSContext, 5 | target: unknown, 6 | marshal: (target: unknown) => QuickJSHandle, 7 | preMarshal: (target: unknown, handle: QuickJSDeferredPromise) => QuickJSHandle | undefined, 8 | ) { 9 | if (!(target instanceof Promise)) return; 10 | 11 | const promise = ctx.newPromise(); 12 | 13 | target.then( 14 | d => promise.resolve(marshal(d)), 15 | d => promise.reject(marshal(d)), 16 | ); 17 | 18 | return preMarshal(target, promise) ?? promise.handle; 19 | } 20 | -------------------------------------------------------------------------------- /src/marshal/properties.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { json } from "../vmutil"; 5 | 6 | import marshalProperties from "./properties"; 7 | 8 | test("works", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | const descTester = ctx.unwrapResult( 11 | ctx.evalCode(`(obj, expected) => { 12 | const descs = Object.getOwnPropertyDescriptors(obj); 13 | for (const [k, v] of Object.entries(expected)) { 14 | const d = descs[k]; 15 | if (v.valueType && typeof d.value !== v.valueType) throw new Error(k + " value invalid"); 16 | if (v.getType && typeof d.get !== v.getType) throw new Error(k + " get invalid"); 17 | if (v.setType && typeof d.set !== v.setType) throw new Error(k + " set invalid"); 18 | if (typeof v.enumerable === "boolean" && d.enumerable !== v.enumerable) throw new Error(k + " enumerable invalid: " + d.enumerable); 19 | if (typeof v.configurable === "boolean" && d.configurable !== v.configurable) throw new Error(k + " configurable invalid: " + d.configurable); 20 | if (typeof v.writable === "boolean" && d.writable !== v.writable) throw new Error(k + " writable invalid: " + d.writable); 21 | } 22 | }`), 23 | ); 24 | 25 | const disposables: QuickJSHandle[] = []; 26 | const marshal = vi.fn(t => { 27 | if (typeof t !== "function") return json(ctx, t); 28 | const fn = ctx.newFunction("", () => {}); 29 | disposables.push(fn); 30 | return fn; 31 | }); 32 | 33 | const handle = ctx.newObject(); 34 | const obj = {}; 35 | const bar = () => {}; 36 | const fooGet = () => {}; 37 | const fooSet = () => {}; 38 | Object.defineProperties(obj, { 39 | bar: { 40 | value: bar, 41 | enumerable: true, 42 | configurable: true, 43 | writable: true, 44 | }, 45 | foo: { 46 | get: fooGet, 47 | set: fooSet, 48 | enumerable: false, 49 | configurable: true, 50 | }, 51 | }); 52 | 53 | marshalProperties(ctx, obj, handle, marshal); 54 | expect(marshal.mock.calls).toEqual([["bar"], [bar], ["foo"], [fooGet], [fooSet]]); 55 | 56 | const expected = ctx.unwrapResult( 57 | ctx.evalCode(`({ 58 | bar: { valueType: "function", getType: "undefined", setType: "undefined", enumerable: true, configurable: true, writable: true }, 59 | foo: { valueType: "undefined", getType: "function", setType: "function", enumerable: false, configurable: true } 60 | })`), 61 | ); 62 | ctx.unwrapResult(ctx.callFunction(descTester, ctx.undefined, handle, expected)); 63 | 64 | expected.dispose(); 65 | disposables.forEach(d => d.dispose()); 66 | handle.dispose(); 67 | descTester.dispose(); 68 | ctx.dispose(); 69 | }); 70 | 71 | test("empty", async () => { 72 | const ctx = (await getQuickJS()).newContext(); 73 | const marshal = vi.fn(); 74 | const handle = ctx.newObject(); 75 | const obj = {}; 76 | 77 | marshalProperties(ctx, obj, handle, marshal); 78 | expect(marshal).toHaveBeenCalledTimes(0); 79 | 80 | handle.dispose(); 81 | ctx.dispose(); 82 | }); 83 | -------------------------------------------------------------------------------- /src/marshal/properties.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | export default function marshalProperties( 6 | ctx: QuickJSContext, 7 | target: object | Function, 8 | handle: QuickJSHandle, 9 | marshal: (target: unknown) => QuickJSHandle, 10 | ): void { 11 | const descs = ctx.newObject(); 12 | const cb = (key: string | number | symbol, desc: PropertyDescriptor) => { 13 | const keyHandle = marshal(key); 14 | const valueHandle = typeof desc.value === "undefined" ? undefined : marshal(desc.value); 15 | const getHandle = typeof desc.get === "undefined" ? undefined : marshal(desc.get); 16 | const setHandle = typeof desc.set === "undefined" ? undefined : marshal(desc.set); 17 | 18 | ctx.newObject().consume(descObj => { 19 | Object.entries(desc).forEach(([k, v]) => { 20 | const v2 = 21 | k === "value" 22 | ? valueHandle 23 | : k === "get" 24 | ? getHandle 25 | : k === "set" 26 | ? setHandle 27 | : v 28 | ? ctx.true 29 | : ctx.false; 30 | if (v2) { 31 | ctx.setProp(descObj, k, v2); 32 | } 33 | }); 34 | ctx.setProp(descs, keyHandle, descObj); 35 | }); 36 | }; 37 | 38 | const desc = Object.getOwnPropertyDescriptors(target); 39 | Object.entries(desc).forEach(([k, v]) => cb(k, v)); 40 | Object.getOwnPropertySymbols(desc).forEach(k => cb(k, (desc as any)[k])); 41 | 42 | call(ctx, `Object.defineProperties`, undefined, handle, descs).dispose(); 43 | 44 | descs.dispose(); 45 | } 46 | -------------------------------------------------------------------------------- /src/unmarshal/custom.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import unmarshalCustom, { defaultCustom } from "./custom"; 5 | 6 | test("symbol", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const pre = vi.fn(); 9 | const obj = ctx.newObject(); 10 | const handle = ctx.unwrapResult(ctx.evalCode(`Symbol("foobar")`)); 11 | 12 | const unmarshal = (h: QuickJSHandle): any => unmarshalCustom(ctx, h, pre, defaultCustom); 13 | 14 | expect(unmarshal(obj)).toBe(undefined); 15 | expect(pre).toBeCalledTimes(0); 16 | 17 | const sym = unmarshal(handle); 18 | expect(typeof sym).toBe("symbol"); 19 | expect((sym as any).description).toBe("foobar"); 20 | expect(pre).toReturnTimes(1); 21 | expect(pre.mock.calls[0][0]).toBe(sym); 22 | expect(pre.mock.calls[0][1] === handle).toBe(true); 23 | 24 | handle.dispose(); 25 | obj.dispose(); 26 | ctx.dispose(); 27 | }); 28 | 29 | test("date", async () => { 30 | const ctx = (await getQuickJS()).newContext(); 31 | const pre = vi.fn(); 32 | const obj = ctx.newObject(); 33 | const handle = ctx.unwrapResult(ctx.evalCode(`new Date(2022, 7, 26)`)); 34 | 35 | const unmarshal = (h: QuickJSHandle): any => unmarshalCustom(ctx, h, pre, defaultCustom); 36 | 37 | expect(unmarshal(obj)).toBe(undefined); 38 | expect(pre).toBeCalledTimes(0); 39 | 40 | const date = unmarshal(handle); 41 | expect(date).toBeInstanceOf(Date); 42 | expect(date.getTime()).toBe(new Date(2022, 7, 26).getTime()); 43 | expect(pre).toReturnTimes(1); 44 | expect(pre.mock.calls[0][0]).toBe(date); 45 | expect(pre.mock.calls[0][1] === handle).toBe(true); 46 | 47 | handle.dispose(); 48 | obj.dispose(); 49 | ctx.dispose(); 50 | }); 51 | -------------------------------------------------------------------------------- /src/unmarshal/custom.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | export default function unmarshalCustom( 6 | ctx: QuickJSContext, 7 | handle: QuickJSHandle, 8 | preUnmarshal: (target: T, handle: QuickJSHandle) => T | undefined, 9 | custom: Iterable<(handle: QuickJSHandle, ctx: QuickJSContext) => any>, 10 | ): symbol | undefined { 11 | let obj: any; 12 | for (const c of custom) { 13 | obj = c(handle, ctx); 14 | if (obj) break; 15 | } 16 | return obj ? preUnmarshal(obj, handle) ?? obj : undefined; 17 | } 18 | 19 | export function symbol(handle: QuickJSHandle, ctx: QuickJSContext): symbol | undefined { 20 | if (ctx.typeof(handle) !== "symbol") return; 21 | const desc = ctx.getString(ctx.getProp(handle, "description")); 22 | return Symbol(desc); 23 | } 24 | 25 | export function date(handle: QuickJSHandle, ctx: QuickJSContext): Date | undefined { 26 | if (!ctx.dump(call(ctx, "a => a instanceof Date", undefined, handle))) return; 27 | const t = ctx.getNumber(call(ctx, "a => a.getTime()", undefined, handle)); 28 | return new Date(t); 29 | } 30 | 31 | export const defaultCustom = [symbol, date]; 32 | -------------------------------------------------------------------------------- /src/unmarshal/function.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { json } from "../vmutil"; 5 | 6 | import unmarshalFunction from "./function"; 7 | 8 | test("arrow function", async () => { 9 | const ctx = (await getQuickJS()).newContext(); 10 | const marshal = vi.fn((v): [QuickJSHandle, boolean] => [json(ctx, v), false]); 11 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => [ctx.dump(v), false]); 12 | const preUnmarshal = vi.fn(a => a); 13 | 14 | const handle = ctx.unwrapResult(ctx.evalCode(`(a, b) => a + b`)); 15 | const func = unmarshalFunction(ctx, handle, marshal, unmarshal, preUnmarshal); 16 | if (!func) throw new Error("func is undefined"); 17 | 18 | expect(func(1, 2)).toBe(3); 19 | expect(marshal).toBeCalledTimes(3); 20 | expect(marshal).toBeCalledWith(undefined); 21 | expect(marshal).toBeCalledWith(1); 22 | expect(marshal).toBeCalledWith(2); 23 | expect(unmarshal).toReturnTimes(5); 24 | expect(unmarshal).toReturnWith([3, false]); // a + b 25 | expect(unmarshal).toReturnWith(["name", false]); 26 | expect(unmarshal).toReturnWith([func.name, false]); 27 | expect(unmarshal).toReturnWith(["length", false]); 28 | expect(unmarshal).toReturnWith([func.length, false]); 29 | expect(preUnmarshal).toBeCalledTimes(1); 30 | expect(preUnmarshal).toBeCalledWith(func, handle); 31 | 32 | handle.dispose(); 33 | expect(() => func(1, 2)).toThrow("Lifetime not alive"); 34 | 35 | ctx.dispose(); 36 | }); 37 | 38 | test("function", async () => { 39 | const ctx = (await getQuickJS()).newContext(); 40 | const that = { a: 1 }; 41 | const thatHandle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 42 | const marshal = vi.fn((v): [QuickJSHandle, boolean] => [ 43 | v === that ? thatHandle : json(ctx, v), 44 | false, 45 | ]); 46 | const disposables: QuickJSHandle[] = []; 47 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => { 48 | const ty = ctx.typeof(v); 49 | if (ty === "object" || ty === "function") disposables.push(v); 50 | return [ctx.dump(v), false]; 51 | }); 52 | const preUnmarshal = vi.fn(a => a); 53 | 54 | const handle = ctx.unwrapResult(ctx.evalCode(`(function (a) { return this.a + a; })`)); 55 | 56 | const func = unmarshalFunction(ctx, handle, marshal, unmarshal, preUnmarshal); 57 | if (!func) throw new Error("func is undefined"); 58 | 59 | expect(func.call(that, 2)).toBe(3); 60 | expect(marshal).toBeCalledTimes(2); // this, 2 61 | expect(marshal).toBeCalledWith(that); 62 | expect(marshal).toBeCalledWith(2); 63 | expect(unmarshal).toReturnTimes(7); // this.a + b, func.prototype, func.name, func.length 64 | expect(unmarshal).toReturnWith([3, false]); // this.a + b 65 | expect(unmarshal).toReturnWith(["prototype", false]); 66 | expect(unmarshal).toReturnWith([func.prototype, false]); 67 | expect(unmarshal).toReturnWith(["name", false]); 68 | expect(unmarshal).toReturnWith([func.name, false]); 69 | expect(unmarshal).toReturnWith(["length", false]); 70 | expect(unmarshal).toReturnWith([func.length, false]); 71 | expect(preUnmarshal).toBeCalledTimes(1); 72 | expect(preUnmarshal).toBeCalledWith(func, handle); 73 | 74 | disposables.forEach(d => d.dispose()); 75 | thatHandle.dispose(); 76 | handle.dispose(); 77 | ctx.dispose(); 78 | }); 79 | 80 | test("constructor", async () => { 81 | const ctx = (await getQuickJS()).newContext(); 82 | const disposables: QuickJSHandle[] = []; 83 | const marshal = vi.fn((v): [QuickJSHandle, boolean] => [ 84 | typeof v === "object" ? ctx.undefined : json(ctx, v), 85 | false, 86 | ]); 87 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => { 88 | const ty = ctx.typeof(v); 89 | if (ty === "object" || ty === "function") disposables.push(v); 90 | return [ctx.dump(v), false]; 91 | }); 92 | const preUnmarshal = vi.fn(a => a); 93 | 94 | const handle = ctx.unwrapResult(ctx.evalCode(`(function (b) { this.a = b + 2; })`)); 95 | 96 | const Cls = unmarshalFunction(ctx, handle, marshal, unmarshal, preUnmarshal) as any; 97 | if (!Cls) throw new Error("Cls is undefined"); 98 | 99 | const instance = new Cls(100); 100 | expect(instance instanceof Cls).toBe(true); 101 | expect(instance.a).toBe(102); 102 | expect(marshal).toBeCalledTimes(2); 103 | expect(marshal).toBeCalledWith(instance); 104 | expect(marshal).toBeCalledWith(100); 105 | expect(unmarshal).toReturnTimes(7); 106 | expect(unmarshal).toReturnWith([instance, false]); 107 | expect(unmarshal).toReturnWith(["prototype", false]); 108 | expect(unmarshal).toReturnWith([Cls.prototype, false]); 109 | expect(unmarshal).toReturnWith(["name", false]); 110 | expect(unmarshal).toReturnWith([Cls.name, false]); 111 | expect(unmarshal).toReturnWith(["length", false]); 112 | expect(unmarshal).toReturnWith([Cls.length, false]); 113 | expect(preUnmarshal).toBeCalledTimes(1); 114 | expect(preUnmarshal).toBeCalledWith(Cls, handle); 115 | 116 | disposables.forEach(d => d.dispose()); 117 | handle.dispose(); 118 | ctx.dispose(); 119 | }); 120 | 121 | test("class", async () => { 122 | const ctx = (await getQuickJS()).newContext(); 123 | const marshal = vi.fn((v): [QuickJSHandle, boolean] => [ 124 | typeof v === "object" ? ctx.undefined : json(ctx, v), 125 | false, 126 | ]); 127 | const disposables: QuickJSHandle[] = []; 128 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => { 129 | const ty = ctx.typeof(v); 130 | if (ty === "object" || ty === "function") disposables.push(v); 131 | return [ctx.dump(v), false]; 132 | }); 133 | const preUnmarshal = vi.fn(a => a); 134 | 135 | const handle = ctx.unwrapResult(ctx.evalCode(`(class A { constructor(a) { this.a = a + 1; } })`)); 136 | 137 | const Cls = unmarshalFunction(ctx, handle, marshal, unmarshal, preUnmarshal) as any; 138 | if (!Cls) throw new Error("Cls is undefined"); 139 | 140 | const instance = new Cls(2); 141 | expect(instance instanceof Cls).toBe(true); 142 | expect(instance.a).toBe(3); 143 | expect(marshal).toBeCalledTimes(2); 144 | expect(marshal).toBeCalledWith(instance); 145 | expect(marshal).toBeCalledWith(2); 146 | expect(unmarshal).toReturnTimes(7); 147 | expect(unmarshal).toReturnWith([instance, false]); 148 | expect(unmarshal).toReturnWith(["prototype", false]); 149 | expect(unmarshal).toReturnWith([Cls.prototype, false]); 150 | expect(unmarshal).toReturnWith(["name", false]); 151 | expect(unmarshal).toReturnWith([Cls.name, false]); 152 | expect(unmarshal).toReturnWith(["length", false]); 153 | expect(unmarshal).toReturnWith([Cls.length, false]); 154 | expect(preUnmarshal).toBeCalledTimes(1); 155 | expect(preUnmarshal).toBeCalledWith(Cls, handle); 156 | 157 | disposables.forEach(d => d.dispose()); 158 | handle.dispose(); 159 | ctx.dispose(); 160 | }); 161 | 162 | test("undefined", async () => { 163 | const ctx = (await getQuickJS()).newContext(); 164 | const f = vi.fn(); 165 | 166 | expect(unmarshalFunction(ctx, ctx.undefined, f, f, f)).toEqual(undefined); 167 | expect(unmarshalFunction(ctx, ctx.true, f, f, f)).toEqual(undefined); 168 | expect(unmarshalFunction(ctx, ctx.false, f, f, f)).toEqual(undefined); 169 | expect(unmarshalFunction(ctx, ctx.null, f, f, f)).toEqual(undefined); 170 | expect(unmarshalFunction(ctx, ctx.newString("hoge"), f, f, f)).toEqual(undefined); 171 | expect(unmarshalFunction(ctx, ctx.newNumber(-10), f, f, f)).toEqual(undefined); 172 | 173 | const obj = ctx.newObject(); 174 | expect(unmarshalFunction(ctx, obj, f, f, f)).toEqual(undefined); 175 | const array = ctx.newArray(); 176 | expect(unmarshalFunction(ctx, array, f, f, f)).toEqual(undefined); 177 | 178 | expect(f).toBeCalledTimes(0); 179 | 180 | obj.dispose(); 181 | array.dispose(); 182 | ctx.dispose(); 183 | }); 184 | -------------------------------------------------------------------------------- /src/unmarshal/function.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call, mayConsumeAll } from "../vmutil"; 4 | 5 | import unmarshalProperties from "./properties"; 6 | 7 | export default function unmarshalFunction( 8 | ctx: QuickJSContext, 9 | handle: QuickJSHandle, 10 | /** marshal returns handle and boolean indicates that the handle should be disposed after use */ 11 | marshal: (value: unknown) => [QuickJSHandle, boolean], 12 | unmarshal: (handle: QuickJSHandle) => [unknown, boolean], 13 | preUnmarshal: (target: T, handle: QuickJSHandle) => T | undefined, 14 | ): Function | undefined { 15 | if (ctx.typeof(handle) !== "function") return; 16 | 17 | const raw = function (this: any, ...args: any[]) { 18 | return mayConsumeAll( 19 | [marshal(this), ...args.map(a => marshal(a))], 20 | (thisHandle, ...argHandles) => { 21 | if (new.target) { 22 | const [instance] = unmarshal( 23 | call(ctx, `(Cls, ...args) => new Cls(...args)`, thisHandle, handle, ...argHandles), 24 | ); 25 | Object.defineProperties(this, Object.getOwnPropertyDescriptors(instance)); 26 | return this; 27 | } 28 | 29 | const resultHandle = ctx.unwrapResult(ctx.callFunction(handle, thisHandle, ...argHandles)); 30 | 31 | const [result, alreadyExists] = unmarshal(resultHandle); 32 | if (alreadyExists) resultHandle.dispose(); 33 | 34 | return result; 35 | }, 36 | ); 37 | }; 38 | 39 | const func = preUnmarshal(raw, handle) ?? raw; 40 | unmarshalProperties(ctx, handle, raw, unmarshal); 41 | 42 | return func; 43 | } 44 | -------------------------------------------------------------------------------- /src/unmarshal/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import VMMap from "../vmmap"; 5 | import { json } from "../vmutil"; 6 | 7 | import unmarshal from "."; 8 | 9 | test("primitive, array, object", async () => { 10 | const { ctx, unmarshal, marshal, map, dispose } = await setup(); 11 | 12 | const handle = ctx.unwrapResult( 13 | ctx.evalCode(`({ 14 | hoge: "foo", 15 | foo: 1, 16 | aaa: [1, true, {}], 17 | nested: { aa: null, hoge: undefined }, 18 | bbb: () => "bar" 19 | })`), 20 | ); 21 | const target = unmarshal(handle); 22 | 23 | expect(target).toEqual({ 24 | hoge: "foo", 25 | foo: 1, 26 | aaa: [1, true, {}], 27 | nested: { aa: null, hoge: undefined }, 28 | bbb: expect.any(Function), 29 | }); 30 | expect(map.size).toBe(5); 31 | expect(map.getByHandle(handle)).toBe(target); 32 | ctx.getProp(handle, "aaa").consume(h => expect(map.getByHandle(h)).toBe(target.aaa)); 33 | ctx 34 | .getProp(handle, "aaa") 35 | .consume(h => ctx.getProp(h, 2)) 36 | .consume(h => expect(map.getByHandle(h)).toBe(target.aaa[2])); 37 | ctx.getProp(handle, "nested").consume(h => expect(map.getByHandle(h)).toBe(target.nested)); 38 | ctx.getProp(handle, "bbb").consume(h => expect(map.getByHandle(h)).toBe(target.bbb)); 39 | 40 | expect(marshal).toBeCalledTimes(0); 41 | expect(target.bbb()).toBe("bar"); 42 | expect(marshal).toBeCalledTimes(1); 43 | expect(marshal).toBeCalledWith(target); // thisArg of target.bbb() 44 | 45 | dispose(); 46 | }); 47 | 48 | test("object with symbol key", async () => { 49 | const { ctx, unmarshal, dispose } = await setup(); 50 | 51 | const handle = ctx.unwrapResult( 52 | ctx.evalCode(`({ 53 | hoge: "foo", 54 | [Symbol("a")]: "bar" 55 | })`), 56 | ); 57 | const target = unmarshal(handle); 58 | 59 | expect(target.hoge).toBe("foo"); 60 | expect(target[Object.getOwnPropertySymbols(target)[0]]).toBe("bar"); 61 | 62 | dispose(); 63 | }); 64 | 65 | test("function", async () => { 66 | const { ctx, unmarshal, marshal, map, dispose } = await setup(); 67 | 68 | const handle = ctx.unwrapResult(ctx.evalCode(`(function(a) { return a.a + "!"; })`)); 69 | const func = unmarshal(handle); 70 | const arg = { a: "hoge" }; 71 | expect(func(arg)).toBe("hoge!"); 72 | expect(marshal).toBeCalledTimes(2); 73 | expect(marshal).toBeCalledWith(undefined); // this 74 | expect(marshal).toBeCalledWith(arg); // arg 75 | expect(map.size).toBe(3); 76 | expect(map.getByHandle(handle)).toBe(func); 77 | expect(map.has(func)).toBe(true); 78 | expect(map.has(func.prototype)).toBe(true); 79 | expect(map.has(arg)).toBe(true); 80 | 81 | dispose(); 82 | }); 83 | 84 | test("promise", async () => { 85 | const { ctx, unmarshal, dispose } = await setup(); 86 | 87 | const deferred = ctx.newPromise(); 88 | const promise = unmarshal(deferred.handle); 89 | deferred.resolve(ctx.newString("resolved!")); 90 | ctx.runtime.executePendingJobs(); 91 | await expect(promise).resolves.toBe("resolved!"); 92 | 93 | const deferred2 = ctx.newPromise(); 94 | const promise2 = unmarshal(deferred2.handle); 95 | deferred2.reject(ctx.newString("rejected!")); 96 | ctx.runtime.executePendingJobs(); 97 | await expect(promise2).rejects.toBe("rejected!"); 98 | 99 | deferred.dispose(); 100 | deferred2.dispose(); 101 | dispose(); 102 | }); 103 | 104 | test("class", async () => { 105 | const { ctx, unmarshal, dispose } = await setup(); 106 | 107 | const handle = ctx.unwrapResult( 108 | ctx.evalCode(`{ 109 | class Cls { 110 | static hoge = "foo"; 111 | 112 | constructor(a) { 113 | this.foo = a + 2; 114 | } 115 | } 116 | Cls.foo = new Cls(1); 117 | 118 | Cls 119 | }`), 120 | ); 121 | const Cls = unmarshal(handle); 122 | 123 | expect(Cls.hoge).toBe("foo"); 124 | expect(Cls.foo).toBeInstanceOf(Cls); 125 | expect(Cls.foo.foo).toBe(3); 126 | const cls = new Cls(2); 127 | expect(cls).toBeInstanceOf(Cls); 128 | expect(cls.foo).toBe(4); 129 | 130 | handle.dispose(); 131 | dispose(); 132 | }); 133 | 134 | test("date", async () => { 135 | const { ctx, unmarshal, dispose } = await setup(); 136 | 137 | const handle = ctx.unwrapResult(ctx.evalCode("new Date(2022, 7, 26)")); 138 | const date = unmarshal(handle); 139 | const expected = new Date(2022, 7, 26); 140 | 141 | expect(date).toBeInstanceOf(Date); 142 | expect(date.getTime()).toBe(expected.getTime()); 143 | 144 | handle.dispose(); 145 | dispose(); 146 | }); 147 | 148 | const setup = async () => { 149 | const ctx = (await getQuickJS()).newContext(); 150 | const map = new VMMap(ctx); 151 | const disposables: QuickJSHandle[] = []; 152 | const marshal = vi.fn((target: unknown): [QuickJSHandle, boolean] => { 153 | const handle = map.get(target); 154 | if (handle) return [handle, false]; 155 | 156 | const handle2 = 157 | typeof target === "function" 158 | ? ctx.newFunction(target.name, (...handles) => { 159 | target(...handles.map(h => ctx.dump(h))); 160 | }) 161 | : json(ctx, target); 162 | const ty = ctx.typeof(handle2); 163 | if (ty === "object" || ty === "function") map.set(target, handle2); 164 | return [handle2, false]; 165 | }); 166 | 167 | return { 168 | ctx, 169 | map, 170 | unmarshal: (handle: QuickJSHandle) => 171 | unmarshal(handle, { 172 | find: h => map.getByHandle(h), 173 | marshal, 174 | pre: (t, h) => { 175 | map.set(t, h); 176 | return t; 177 | }, 178 | ctx: ctx, 179 | }), 180 | marshal, 181 | dispose: () => { 182 | disposables.forEach(d => d.dispose()); 183 | map.dispose(); 184 | ctx.dispose(); 185 | }, 186 | }; 187 | }; 188 | -------------------------------------------------------------------------------- /src/unmarshal/index.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import unmarshalCustom, { defaultCustom } from "./custom"; 4 | import unmarshalFunction from "./function"; 5 | import unmarshalObject from "./object"; 6 | import unmarshalPrimitive from "./primitive"; 7 | import unmarshalPromise from "./promise"; 8 | 9 | export type Options = { 10 | ctx: QuickJSContext; 11 | /** marshal returns handle and boolean indicates that the handle should be disposed after use */ 12 | marshal: (target: unknown) => [QuickJSHandle, boolean]; 13 | find: (handle: QuickJSHandle) => unknown | undefined; 14 | pre: (target: T, handle: QuickJSHandle) => T | undefined; 15 | custom?: Iterable<(obj: QuickJSHandle, ctx: QuickJSContext) => any>; 16 | }; 17 | 18 | export function unmarshal(handle: QuickJSHandle, options: Options): any { 19 | const [result] = unmarshalInner(handle, options); 20 | return result; 21 | } 22 | 23 | function unmarshalInner(handle: QuickJSHandle, options: Options): [any, boolean] { 24 | const { ctx, marshal, find, pre } = options; 25 | 26 | { 27 | const [target, ok] = unmarshalPrimitive(ctx, handle); 28 | if (ok) return [target, false]; 29 | } 30 | 31 | { 32 | const target = find(handle); 33 | if (target) { 34 | return [target, true]; 35 | } 36 | } 37 | 38 | const unmarshal2 = (h: QuickJSHandle) => unmarshalInner(h, options); 39 | 40 | const result = 41 | unmarshalCustom(ctx, handle, pre, [...defaultCustom, ...(options.custom ?? [])]) ?? 42 | unmarshalPromise(ctx, handle, marshal, pre) ?? 43 | unmarshalFunction(ctx, handle, marshal, unmarshal2, pre) ?? 44 | unmarshalObject(ctx, handle, unmarshal2, pre); 45 | 46 | return [result, false]; 47 | } 48 | 49 | export default unmarshal; 50 | -------------------------------------------------------------------------------- /src/unmarshal/object.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import unmarshalObject from "./object"; 5 | 6 | test("normal object", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => [ctx.dump(v), false]); 9 | const preUnmarshal = vi.fn(a => a); 10 | 11 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1, b: true })`)); 12 | const obj = unmarshalObject(ctx, handle, unmarshal, preUnmarshal); 13 | if (!obj) throw new Error("obj is undefined"); 14 | expect(obj).toEqual({ a: 1, b: true }); 15 | expect(unmarshal).toReturnTimes(4); 16 | expect(unmarshal).toReturnWith(["a", false]); 17 | expect(unmarshal).toReturnWith([1, false]); 18 | expect(unmarshal).toReturnWith(["b", false]); 19 | expect(unmarshal).toReturnWith([true, false]); 20 | expect(preUnmarshal).toBeCalledTimes(1); 21 | expect(preUnmarshal).toBeCalledWith(obj, handle); 22 | 23 | handle.dispose(); 24 | ctx.dispose(); 25 | }); 26 | 27 | test("properties", async () => { 28 | const ctx = (await getQuickJS()).newContext(); 29 | const disposables: QuickJSHandle[] = []; 30 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => { 31 | disposables.push(v); 32 | return [ctx.typeof(v) === "function" ? () => {} : ctx.dump(v), false]; 33 | }); 34 | const preUnmarshal = vi.fn(a => a); 35 | 36 | const handle = ctx.unwrapResult( 37 | ctx.evalCode(`{ 38 | const obj = {}; 39 | Object.defineProperties(obj, { 40 | a: { value: 1, writable: true, configurable: true, enumerable: true }, 41 | b: { value: 2 }, 42 | c: { get: () => {}, set: () => {} }, 43 | }); 44 | obj 45 | }`), 46 | ); 47 | const obj = unmarshalObject(ctx, handle, unmarshal, preUnmarshal); 48 | if (!obj) throw new Error("obj is undefined"); 49 | expect(obj).toEqual({ 50 | a: 1, 51 | }); 52 | expect(Object.getOwnPropertyDescriptors(obj)).toEqual({ 53 | a: { value: 1, writable: true, configurable: true, enumerable: true }, 54 | b: { value: 2, writable: false, configurable: false, enumerable: false }, 55 | c: { 56 | get: expect.any(Function), 57 | set: expect.any(Function), 58 | configurable: false, 59 | enumerable: false, 60 | }, 61 | }); 62 | expect(unmarshal).toBeCalledTimes(7); // a.value, b.value, c.get, c.set 63 | expect(unmarshal).toReturnWith(["a", false]); 64 | expect(unmarshal).toReturnWith([1, false]); 65 | expect(unmarshal).toReturnWith(["b", false]); 66 | expect(unmarshal).toReturnWith([2, false]); 67 | expect(unmarshal).toReturnWith(["c", false]); 68 | expect(unmarshal).toReturnWith([expect.any(Function), false]); // get, set 69 | expect(preUnmarshal).toBeCalledTimes(1); 70 | expect(preUnmarshal).toBeCalledWith(obj, handle); 71 | 72 | disposables.forEach(d => d.dispose()); 73 | handle.dispose(); 74 | ctx.dispose(); 75 | }); 76 | 77 | test("array", async () => { 78 | const ctx = (await getQuickJS()).newContext(); 79 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => [ctx.dump(v), false]); 80 | const preUnmarshal = vi.fn(a => a); 81 | 82 | const handle = ctx.unwrapResult(ctx.evalCode(`[1, true, "a"]`)); 83 | const array = unmarshalObject(ctx, handle, unmarshal, preUnmarshal); 84 | expect((array as any)[0]).toEqual(1); 85 | expect(Array.isArray(array)).toBe(true); 86 | expect(unmarshal.mock.results[0].value).toEqual(["0", false]); 87 | expect(unmarshal.mock.results[1].value).toEqual([1, false]); 88 | expect(unmarshal.mock.results[2].value).toEqual(["1", false]); 89 | expect(unmarshal.mock.results[3].value).toEqual([true, false]); 90 | expect(unmarshal.mock.results[4].value).toEqual(["2", false]); 91 | expect(unmarshal.mock.results[5].value).toEqual(["a", false]); 92 | expect(unmarshal.mock.results[6].value).toEqual(["length", false]); 93 | expect(unmarshal.mock.results[7].value).toEqual([3, false]); 94 | expect(preUnmarshal).toBeCalledWith(array, handle); 95 | 96 | handle.dispose(); 97 | ctx.dispose(); 98 | }); 99 | 100 | test("prototype", async () => { 101 | const ctx = (await getQuickJS()).newContext(); 102 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => [ 103 | ctx.typeof(v) === "object" ? { a: () => 1 } : ctx.dump(v), 104 | false, 105 | ]); 106 | const preUnmarshal = vi.fn(a => a); 107 | 108 | const handle = ctx.unwrapResult(ctx.evalCode(`Object.create({ a: () => 1 })`)); 109 | const obj = unmarshalObject(ctx, handle, unmarshal, preUnmarshal) as any; 110 | if (!obj) throw new Error("obj is undefined"); 111 | expect(Object.getPrototypeOf(obj)).toEqual({ a: expect.any(Function) }); 112 | expect(obj.a()).toBe(1); 113 | expect(unmarshal.mock.calls.length).toBe(1); 114 | expect(unmarshal).toReturnWith([Object.getPrototypeOf(obj), false]); 115 | expect(preUnmarshal).toBeCalledTimes(1); 116 | expect(preUnmarshal).toBeCalledWith(obj, handle); 117 | 118 | handle.dispose(); 119 | ctx.dispose(); 120 | }); 121 | 122 | test("undefined", async () => { 123 | const ctx = (await getQuickJS()).newContext(); 124 | const f = vi.fn(); 125 | 126 | expect(unmarshalObject(ctx, ctx.undefined, f, f)).toEqual(undefined); 127 | expect(unmarshalObject(ctx, ctx.true, f, f)).toEqual(undefined); 128 | expect(unmarshalObject(ctx, ctx.false, f, f)).toEqual(undefined); 129 | expect(unmarshalObject(ctx, ctx.null, f, f)).toEqual(undefined); 130 | expect(unmarshalObject(ctx, ctx.newString("hoge"), f, f)).toEqual(undefined); 131 | expect(unmarshalObject(ctx, ctx.newNumber(-10), f, f)).toEqual(undefined); 132 | 133 | const func = ctx.newFunction("", () => {}); 134 | expect(unmarshalObject(ctx, func, f, f)).toEqual(undefined); 135 | 136 | expect(f).toBeCalledTimes(0); 137 | 138 | func.dispose(); 139 | ctx.dispose(); 140 | }); 141 | -------------------------------------------------------------------------------- /src/unmarshal/object.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | import unmarshalProperties from "./properties"; 6 | 7 | export default function unmarshalObject( 8 | ctx: QuickJSContext, 9 | handle: QuickJSHandle, 10 | unmarshal: (handle: QuickJSHandle) => [unknown, boolean], 11 | preUnmarshal: (target: T, handle: QuickJSHandle) => T | undefined, 12 | ): object | undefined { 13 | if ( 14 | ctx.typeof(handle) !== "object" || 15 | // null check 16 | ctx 17 | .unwrapResult(ctx.evalCode("o => o === null")) 18 | .consume(n => ctx.dump(ctx.unwrapResult(ctx.callFunction(n, ctx.undefined, handle)))) 19 | ) 20 | return; 21 | 22 | const raw = call(ctx, "Array.isArray", undefined, handle).consume(r => ctx.dump(r)) ? [] : {}; 23 | const obj = preUnmarshal(raw, handle) ?? raw; 24 | 25 | const prototype = call( 26 | ctx, 27 | `o => { 28 | const p = Object.getPrototypeOf(o); 29 | return !p || p === Object.prototype || p === Array.prototype ? undefined : p; 30 | }`, 31 | undefined, 32 | handle, 33 | ).consume(prototype => { 34 | if (ctx.typeof(prototype) === "undefined") return; 35 | const [proto] = unmarshal(prototype); 36 | return proto; 37 | }); 38 | if (typeof prototype === "object") { 39 | Object.setPrototypeOf(obj, prototype); 40 | } 41 | 42 | unmarshalProperties(ctx, handle, raw, unmarshal); 43 | 44 | return obj; 45 | } 46 | -------------------------------------------------------------------------------- /src/unmarshal/primitive.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test } from "vitest"; 3 | 4 | import unmarshalPrimitive from "./primitive"; 5 | 6 | test("works", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | 9 | expect(unmarshalPrimitive(ctx, ctx.undefined)).toEqual([undefined, true]); 10 | expect(unmarshalPrimitive(ctx, ctx.true)).toEqual([true, true]); 11 | expect(unmarshalPrimitive(ctx, ctx.false)).toEqual([false, true]); 12 | expect(unmarshalPrimitive(ctx, ctx.null)).toEqual([null, true]); 13 | expect(unmarshalPrimitive(ctx, ctx.newString("hoge"))).toEqual(["hoge", true]); 14 | expect(unmarshalPrimitive(ctx, ctx.newNumber(-10))).toEqual([-10, true]); 15 | // expect( 16 | // unmarshalPrimitive(ctx, ctx.unwrapResult(vm.evalCode(`BigInt(1)`))) 17 | // ).toEqual([BigInt(1), true]); 18 | 19 | const obj = ctx.newObject(); 20 | expect(unmarshalPrimitive(ctx, obj)).toEqual([undefined, false]); 21 | const array = ctx.newArray(); 22 | expect(unmarshalPrimitive(ctx, array)).toEqual([undefined, false]); 23 | const func = ctx.newFunction("", () => {}); 24 | expect(unmarshalPrimitive(ctx, func)).toEqual([undefined, false]); 25 | 26 | obj.dispose(); 27 | array.dispose(); 28 | func.dispose(); 29 | ctx.dispose(); 30 | }); 31 | -------------------------------------------------------------------------------- /src/unmarshal/primitive.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | export default function unmarshalPrimitive( 4 | ctx: QuickJSContext, 5 | handle: QuickJSHandle, 6 | ): [any, boolean] { 7 | const ty = ctx.typeof(handle); 8 | if (ty === "undefined" || ty === "number" || ty === "string" || ty === "boolean") { 9 | return [ctx.dump(handle), true]; 10 | } else if (ty === "object") { 11 | const isNull = ctx 12 | .unwrapResult(ctx.evalCode("a => a === null")) 13 | .consume(n => ctx.dump(ctx.unwrapResult(ctx.callFunction(n, ctx.undefined, handle)))); 14 | if (isNull) { 15 | return [null, true]; 16 | } 17 | } 18 | 19 | // BigInt is not supported by quickjs-emscripten 20 | // if (ty === "bigint") { 21 | // const str = ctx 22 | // .getProp(handle, "toString") 23 | // .consume(toString => vm.unwrapResult(vm.callFunction(toString, handle))) 24 | // .consume(str => ctx.getString(str)); 25 | // const bi = BigInt(str); 26 | // return [bi, true]; 27 | // } 28 | 29 | return [undefined, false]; 30 | } 31 | -------------------------------------------------------------------------------- /src/unmarshal/promise.test.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, getQuickJS, type QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import unmarshalPromise from "./promise"; 5 | 6 | const testPromise = (reject: boolean) => async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const disposables: Disposable[] = []; 9 | const marshal = vi.fn((v): [QuickJSHandle, boolean] => { 10 | const f = ctx.newFunction(v.name, h => { 11 | v(ctx.dump(h)); 12 | }); 13 | disposables.push(f); 14 | return [f, false]; 15 | }); 16 | const preUnmarshal = vi.fn(a => a); 17 | 18 | const deferred = ctx.newPromise(); 19 | disposables.push(deferred); 20 | const promise = unmarshalPromise(ctx, deferred.handle, marshal, preUnmarshal); 21 | 22 | expect(marshal).toBeCalledTimes(2); 23 | expect(preUnmarshal).toBeCalledTimes(1); 24 | expect(ctx.runtime.hasPendingJob()).toBe(false); 25 | 26 | if (reject) { 27 | deferred.reject(ctx.newString("hoge")); 28 | } else { 29 | deferred.resolve(ctx.newString("hoge")); 30 | } 31 | expect(ctx.runtime.hasPendingJob()).toBe(true); 32 | expect(ctx.unwrapResult(ctx.runtime.executePendingJobs())).toBe(1); 33 | if (reject) { 34 | expect(promise).rejects.toThrow("hoge"); 35 | } else { 36 | expect(promise).resolves.toBe("hoge"); 37 | } 38 | 39 | disposables.forEach(d => d.dispose()); 40 | ctx.dispose(); 41 | }; 42 | 43 | test("resolve", testPromise(false)); 44 | test("reject", testPromise(true)); 45 | -------------------------------------------------------------------------------- /src/unmarshal/promise.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { newDeferred } from "../util"; 4 | import { call, instanceOf } from "../vmutil"; 5 | 6 | export default function unmarshalPromise( 7 | ctx: QuickJSContext, 8 | handle: QuickJSHandle, 9 | /** marshal returns handle and boolean indicates that the handle should be disposed after use */ 10 | marshal: (value: unknown) => [QuickJSHandle, boolean], 11 | preUnmarshal: (target: T, handle: QuickJSHandle) => T | undefined, 12 | ): Promise | undefined { 13 | if (!isPromiseHandle(ctx, handle)) return; 14 | 15 | const deferred = newDeferred(); 16 | const [resHandle, resShouldBeDisposed] = marshal(deferred.resolve); 17 | const [rejHandle, rejShouldBeDisposed] = marshal(deferred.reject); 18 | call(ctx, "(p, res, rej) => { p.then(res, rej); }", undefined, handle, resHandle, rejHandle); 19 | if (resShouldBeDisposed) resHandle.dispose(); 20 | if (rejShouldBeDisposed) rejHandle.dispose(); 21 | 22 | return preUnmarshal(deferred.promise, handle) ?? deferred.promise; 23 | } 24 | 25 | function isPromiseHandle(ctx: QuickJSContext, handle: QuickJSHandle): boolean { 26 | if (!handle.owner) return false; 27 | return ctx.unwrapResult(ctx.evalCode("Promise")).consume(promise => { 28 | if (!handle.owner) return false; 29 | return instanceOf(ctx, handle, promise); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/unmarshal/properties.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import unmarshalProperties from "./properties"; 5 | 6 | test("works", async () => { 7 | const ctx = (await getQuickJS()).newContext(); 8 | const disposables: QuickJSHandle[] = []; 9 | const unmarshal = vi.fn((v: QuickJSHandle): [unknown, boolean] => { 10 | disposables.push(v); 11 | return [ctx.typeof(v) === "function" ? () => {} : ctx.dump(v), false]; 12 | }); 13 | const obj = {}; 14 | 15 | const handle = ctx.unwrapResult( 16 | ctx.evalCode(`{ 17 | const obj = {}; 18 | Object.defineProperties(obj, { 19 | a: { value: 1, writable: true, configurable: true, enumerable: true }, 20 | b: { value: 2 }, 21 | c: { get: () => {}, set: () => {} }, 22 | }); 23 | obj 24 | }`), 25 | ); 26 | 27 | unmarshalProperties(ctx, handle, obj, unmarshal); 28 | 29 | expect(obj).toEqual({ 30 | a: 1, 31 | }); 32 | expect(Object.getOwnPropertyDescriptors(obj)).toEqual({ 33 | a: { value: 1, writable: true, configurable: true, enumerable: true }, 34 | b: { value: 2, writable: false, configurable: false, enumerable: false }, 35 | c: { 36 | get: expect.any(Function), 37 | set: expect.any(Function), 38 | configurable: false, 39 | enumerable: false, 40 | }, 41 | }); 42 | expect(unmarshal).toBeCalledTimes(7); // a.value, b.value, c.get, c.set 43 | expect(unmarshal).toReturnWith(["a", false]); 44 | expect(unmarshal).toReturnWith([1, false]); 45 | expect(unmarshal).toReturnWith(["b", false]); 46 | expect(unmarshal).toReturnWith([2, false]); 47 | expect(unmarshal).toReturnWith(["c", false]); 48 | expect(unmarshal).toReturnWith([expect.any(Function), false]); // get, set 49 | 50 | disposables.forEach(d => d.dispose()); 51 | handle.dispose(); 52 | ctx.dispose(); 53 | }); 54 | -------------------------------------------------------------------------------- /src/unmarshal/properties.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | import { call } from "../vmutil"; 4 | 5 | export default function unmarshalProperties( 6 | ctx: QuickJSContext, 7 | handle: QuickJSHandle, 8 | target: object | Function, 9 | unmarshal: (handle: QuickJSHandle) => [unknown, boolean], 10 | ) { 11 | ctx 12 | .newFunction("", (key, value) => { 13 | const [keyName] = unmarshal(key); 14 | if (typeof keyName !== "string" && typeof keyName !== "number" && typeof keyName !== "symbol") 15 | return; 16 | 17 | const desc = ( 18 | [ 19 | ["value", true], 20 | ["get", true], 21 | ["set", true], 22 | ["configurable", false], 23 | ["enumerable", false], 24 | ["writable", false], 25 | ] as const 26 | ).reduce((desc, [key, unmarshable]) => { 27 | const h = ctx.getProp(value, key); 28 | const ty = ctx.typeof(h); 29 | 30 | if (ty === "undefined") return desc; 31 | if (!unmarshable && ty === "boolean") { 32 | desc[key] = ctx.dump(ctx.getProp(value, key)); 33 | return desc; 34 | } 35 | 36 | const [v, alreadyExists] = unmarshal(h); 37 | if (alreadyExists) { 38 | h.dispose(); 39 | } 40 | desc[key] = v; 41 | 42 | return desc; 43 | }, {}); 44 | 45 | Object.defineProperty(target, keyName, desc); 46 | }) 47 | .consume(fn => { 48 | call( 49 | ctx, 50 | `(o, fn) => { 51 | const descs = Object.getOwnPropertyDescriptors(o); 52 | Object.entries(descs).forEach(([k, v]) => fn(k, v)); 53 | Object.getOwnPropertySymbols(descs).forEach(k => fn(k, descs[k])); 54 | }`, 55 | undefined, 56 | handle, 57 | fn, 58 | ).dispose(); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from "vitest"; 2 | 3 | import { isES2015Class, isObject, walkObject, complexity, newDeferred } from "./util"; 4 | 5 | test("isES2015Class", () => { 6 | expect(isES2015Class(class {})).toBe(true); 7 | expect(isES2015Class(class A {})).toBe(true); 8 | expect(isES2015Class(function () {})).toBe(false); 9 | expect(isES2015Class(function A() {})).toBe(false); 10 | expect(isES2015Class(() => {})).toBe(false); 11 | expect(isES2015Class({})).toBe(false); 12 | expect(isES2015Class(1)).toBe(false); 13 | expect(isES2015Class(true)).toBe(false); 14 | }); 15 | 16 | test("isObject", () => { 17 | expect(isObject({})).toBe(true); 18 | expect(isObject(Object.create(null))).toBe(true); 19 | expect(isObject(function () {})).toBe(true); 20 | expect(isObject(function A() {})).toBe(true); 21 | expect(isObject(() => {})).toBe(true); 22 | expect(isObject(class {})).toBe(true); 23 | expect(isObject(class A {})).toBe(true); 24 | expect(isObject(null)).toBe(false); 25 | expect(isObject(1)).toBe(false); 26 | expect(isObject(true)).toBe(false); 27 | }); 28 | 29 | test("walkObject", () => { 30 | const cb = vi.fn(); 31 | const obj = { a: { b: 1, c: () => {} } }; 32 | const set = new Set([obj, obj.a, obj.a.c]); 33 | expect(walkObject(obj, cb)).toEqual(set); 34 | expect(cb).toBeCalledTimes(3); 35 | expect(cb).toBeCalledWith(obj, set); 36 | expect(cb).toBeCalledWith(obj.a, set); 37 | expect(cb).toBeCalledWith(obj.a.c, set); 38 | }); 39 | 40 | test("complexity", () => { 41 | expect(complexity(0)).toBe(0); 42 | expect(complexity(NaN)).toBe(0); 43 | expect(complexity(true)).toBe(0); 44 | expect(complexity(false)).toBe(0); 45 | expect(complexity(null)).toBe(0); 46 | expect(complexity(undefined)).toBe(0); 47 | expect(complexity([])).toBe(1); 48 | expect(complexity({})).toBe(1); 49 | expect(complexity({ a: 1 })).toBe(1); 50 | expect(complexity(() => {})).toBe(1); 51 | expect(complexity([{}])).toBe(2); 52 | expect(complexity(function () {})).toBe(2); 53 | expect(complexity(class {})).toBe(2); 54 | expect(complexity({ a: {} })).toBe(2); 55 | expect(complexity({ a: {} }, 1)).toBe(1); 56 | }); 57 | 58 | test("newDeferred", () => { 59 | const deferred = newDeferred(); 60 | deferred.resolve("foo"); 61 | expect(deferred.promise).resolves.toBe("foo"); 62 | 63 | const deferred2 = newDeferred(); 64 | deferred2.reject("bar"); 65 | expect(deferred2.promise).rejects.toBe("bar"); 66 | }); 67 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function isES2015Class(cls: any): cls is new (...args: any[]) => any { 2 | return typeof cls === "function" && /^class\s/.test(Function.prototype.toString.call(cls)); 3 | } 4 | 5 | export function isObject(value: any): value is object | Function { 6 | return typeof value === "function" || (typeof value === "object" && value !== null); 7 | } 8 | 9 | export function walkObject( 10 | value: any, 11 | callback?: (target: any, set: Set) => boolean | void, 12 | ): Set { 13 | const set = new Set(); 14 | const walk = (v: any) => { 15 | if (!isObject(v) || set.has(v) || callback?.(v, set) === false) return; 16 | set.add(v); 17 | 18 | if (Array.isArray(v)) { 19 | for (const e of v) { 20 | walk(e); 21 | } 22 | return; 23 | } 24 | 25 | if (typeof v === "object") { 26 | const proto = Object.getPrototypeOf(v); 27 | if (proto && proto !== Object.prototype) { 28 | walk(proto); 29 | } 30 | } 31 | 32 | for (const d of Object.values(Object.getOwnPropertyDescriptors(v))) { 33 | if ("value" in d) walk(d.value); 34 | if ("get" in d) walk(d.get); 35 | if ("set" in d) walk(d.set); 36 | } 37 | }; 38 | 39 | walk(value); 40 | return set; 41 | } 42 | 43 | /** 44 | * Measure the complexity of an object as you traverse the field and prototype chain. If max is specified, when the complexity reaches max, the traversal is terminated and it returns the max. In this function, one object and function are counted as a complexity of 1, and primitives are not counted as a complexity. 45 | */ 46 | export function complexity(value: any, max?: number): number { 47 | return walkObject(value, max ? (_, set) => set.size < max : undefined).size; 48 | } 49 | 50 | export function newDeferred() { 51 | let res: (v: T | PromiseLike) => void = () => {}; 52 | let rej: (v: T | PromiseLike) => void = () => {}; 53 | const promise = new Promise((resolve, reject) => { 54 | res = resolve; 55 | rej = reject; 56 | }); 57 | 58 | return { 59 | promise, 60 | resolve: res, 61 | reject: rej, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/vmmap.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test } from "vitest"; 3 | 4 | import VMMap from "./vmmap"; 5 | import { call } from "./vmutil"; 6 | 7 | test("init and dispose", async () => { 8 | const ctx = (await getQuickJS()).newContext(); 9 | const map = new VMMap(ctx); 10 | map.dispose(); 11 | ctx.dispose(); 12 | }); 13 | 14 | test("get and set", async () => { 15 | const ctx = (await getQuickJS()).newContext(); 16 | 17 | const target = {}; 18 | const handle = ctx.newObject(); 19 | 20 | const map = new VMMap(ctx); 21 | expect(map.get(target)).toBe(undefined); 22 | expect(map.set(target, handle)).toBe(true); 23 | expect(map.get(target)).toBe(handle); 24 | // a new handle that points to the same value 25 | const handle2 = call(ctx, `a => a`, undefined, handle); 26 | expect(map.set(target, handle2)).toBe(false); 27 | 28 | handle2.dispose(); 29 | handle.dispose(); 30 | expect(map.get(target)).toBe(undefined); 31 | 32 | map.dispose(); 33 | ctx.dispose(); 34 | }); 35 | 36 | test("getByHandle", async () => { 37 | const ctx = (await getQuickJS()).newContext(); 38 | 39 | const target = {}; 40 | const handle = ctx.newObject(); 41 | const handle2 = ctx.newObject(); 42 | 43 | const map = new VMMap(ctx); 44 | expect(map.getByHandle(handle)).toBe(undefined); 45 | map.set(target, handle); 46 | expect(map.getByHandle(handle)).toBe(target); 47 | expect(map.getByHandle(handle2)).toBe(undefined); 48 | handle.dispose(); 49 | expect(map.getByHandle(handle)).toBe(undefined); 50 | 51 | handle2.dispose(); 52 | map.dispose(); 53 | ctx.dispose(); 54 | }); 55 | 56 | test("keys", async () => { 57 | const ctx = (await getQuickJS()).newContext(); 58 | 59 | const target = {}; 60 | const handle = ctx.newObject(); 61 | const handle2 = ctx.newObject(); 62 | 63 | const map = new VMMap(ctx); 64 | expect(Array.from(map.keys())).toEqual([]); 65 | map.set(target, handle); 66 | expect(Array.from(map.keys())).toEqual([target]); 67 | handle.dispose(); 68 | expect(Array.from(map.keys())).toEqual([target]); 69 | 70 | handle2.dispose(); 71 | map.dispose(); 72 | ctx.dispose(); 73 | }); 74 | 75 | test("delete", async () => { 76 | const ctx = (await getQuickJS()).newContext(); 77 | 78 | const target = {}; 79 | const handle = ctx.newObject(); 80 | 81 | const map = new VMMap(ctx); 82 | map.set(target, handle); 83 | expect(map.get(target)).toBe(handle); 84 | map.delete({}); 85 | expect(map.get(target)).toBe(handle); 86 | map.delete(target); 87 | expect(map.get(target)).toBe(undefined); 88 | 89 | handle.dispose(); 90 | map.dispose(); 91 | ctx.dispose(); 92 | }); 93 | 94 | test("size", async () => { 95 | const ctx = (await getQuickJS()).newContext(); 96 | 97 | const target = {}; 98 | const handle = ctx.newObject(); 99 | 100 | const map = new VMMap(ctx); 101 | expect(map.size).toBe(0); 102 | map.set(target, handle); 103 | expect(map.size).toBe(1); 104 | handle.dispose(); 105 | expect(map.size).toBe(1); 106 | 107 | map.dispose(); 108 | ctx.dispose(); 109 | }); 110 | 111 | test("clear", async () => { 112 | const ctx = (await getQuickJS()).newContext(); 113 | 114 | const target = {}; 115 | const handle = ctx.newObject(); 116 | 117 | const map = new VMMap(ctx); 118 | map.set(target, handle); 119 | expect(map.size).toBe(1); 120 | expect(map.get(target)).toBe(handle); 121 | map.clear(); 122 | expect(map.size).toBe(0); 123 | expect(map.get(target)).toBe(undefined); 124 | 125 | handle.dispose(); 126 | map.dispose(); 127 | ctx.dispose(); 128 | }); 129 | 130 | test("merge", async () => { 131 | const ctx = (await getQuickJS()).newContext(); 132 | 133 | const target = {}; 134 | const target2 = {}; 135 | const handle = ctx.newObject(); 136 | const handle2 = ctx.newObject(); 137 | 138 | const map = new VMMap(ctx); 139 | const map2 = new VMMap(ctx); 140 | map.set(target, handle, target2, handle2); 141 | expect(map.size).toBe(1); 142 | expect(map.get(target)).toBe(handle); 143 | expect(map.get(target2)).toBe(handle); 144 | expect(map2.size).toBe(0); 145 | map2.merge(map); 146 | expect(map2.size).toBe(1); 147 | expect(map2.get(target)).toBe(handle); 148 | expect(map2.get(target2)).toBe(handle); 149 | 150 | map.clear(); 151 | map.dispose(); 152 | map2.dispose(); 153 | ctx.dispose(); 154 | }); 155 | 156 | test("iterator", async () => { 157 | const ctx = (await getQuickJS()).newContext(); 158 | 159 | const target = {}; 160 | const target2 = {}; 161 | const handle = ctx.newObject(); 162 | const handle2 = ctx.newObject(); 163 | 164 | const map = new VMMap(ctx); 165 | map.set(target, handle, target2, handle2); 166 | 167 | const iter = map[Symbol.iterator](); 168 | const first = iter.next(); 169 | expect(first.value[0]).toBe(target); 170 | expect(first.value[1] === handle).toBe(true); 171 | expect(first.value[2]).toBe(target2); 172 | expect(first.value[3] === handle2).toBe(true); 173 | expect(first.done).toBe(false); 174 | 175 | const second = iter.next(); 176 | expect(second.done).toBe(true); 177 | 178 | let i = 0; 179 | for (const [k, v, k2, v2] of map) { 180 | expect(k).toBe(target); 181 | expect(v === handle).toBe(true); 182 | expect(k2).toBe(target2); 183 | expect(v2 === handle2).toBe(true); 184 | i++; 185 | } 186 | expect(i).toBe(1); 187 | 188 | map.dispose(); 189 | ctx.dispose(); 190 | }); 191 | 192 | test("get and set 2", async () => { 193 | const ctx = (await getQuickJS()).newContext(); 194 | 195 | const target = {}; 196 | const target2 = {}; 197 | const handle = ctx.newObject(); 198 | const handle2 = ctx.newObject(); 199 | const handle3 = call(ctx, `a => a`, undefined, handle); 200 | 201 | const map = new VMMap(ctx); 202 | 203 | map.set(target, handle, target2, handle2); 204 | expect(map.get(target)).toBe(handle); 205 | expect(map.get(target2)).toBe(handle); 206 | expect(map.getByHandle(handle)).toBe(target); 207 | expect(map.getByHandle(handle2)).toBe(target); 208 | expect(map.getByHandle(handle3)).toBe(target); 209 | 210 | handle3.dispose(); 211 | handle2.dispose(); 212 | handle.dispose(); 213 | 214 | expect(map.get(target)).toBe(undefined); 215 | expect(map.get(target2)).toBe(undefined); 216 | expect(map.getByHandle(handle)).toBe(undefined); 217 | expect(map.getByHandle(handle2)).toBe(undefined); 218 | 219 | map.dispose(); 220 | ctx.dispose(); 221 | }); 222 | 223 | test("delete 2", async () => { 224 | const ctx = (await getQuickJS()).newContext(); 225 | 226 | const target = {}; 227 | const target2 = {}; 228 | const handle = ctx.newObject(); 229 | const handle2 = ctx.newObject(); 230 | 231 | const map = new VMMap(ctx); 232 | 233 | map.set(target, handle, target2, handle2); 234 | expect(map.get(target)).toBe(handle); 235 | expect(map.get(target2)).toBe(handle); 236 | expect(map.getByHandle(handle)).toBe(target); 237 | expect(map.getByHandle(handle2)).toBe(target); 238 | 239 | map.delete(target); 240 | 241 | expect(map.get(target)).toBe(undefined); 242 | expect(map.get(target2)).toBe(undefined); 243 | expect(map.getByHandle(handle)).toBe(undefined); 244 | expect(map.getByHandle(handle2)).toBe(undefined); 245 | 246 | handle.dispose(); 247 | handle2.dispose(); 248 | map.dispose(); 249 | ctx.dispose(); 250 | }); 251 | 252 | test("delete 3", async () => { 253 | const ctx = (await getQuickJS()).newContext(); 254 | 255 | const target = {}; 256 | const target2 = {}; 257 | const handle = ctx.newObject(); 258 | const handle2 = ctx.newObject(); 259 | 260 | const map = new VMMap(ctx); 261 | 262 | map.set(target, handle, target2, handle2); 263 | expect(map.get(target)).toBe(handle); 264 | expect(map.get(target2)).toBe(handle); 265 | expect(map.getByHandle(handle)).toBe(target); 266 | expect(map.getByHandle(handle2)).toBe(target); 267 | 268 | map.delete(target2); 269 | 270 | expect(map.get(target)).toBe(undefined); 271 | expect(map.get(target2)).toBe(undefined); 272 | expect(map.getByHandle(handle)).toBe(undefined); 273 | expect(map.getByHandle(handle2)).toBe(undefined); 274 | 275 | handle.dispose(); 276 | handle2.dispose(); 277 | map.dispose(); 278 | ctx.dispose(); 279 | }); 280 | 281 | test("delete with dispose", async () => { 282 | const ctx = (await getQuickJS()).newContext(); 283 | 284 | const target = {}; 285 | const target2 = {}; 286 | const handle = ctx.newObject(); 287 | const handle2 = ctx.newObject(); 288 | 289 | const map = new VMMap(ctx); 290 | map.set(target, handle, target2, handle2); 291 | map.delete(target, true); 292 | 293 | expect(handle.alive).toBe(false); 294 | expect(handle2.alive).toBe(false); 295 | 296 | map.dispose(); 297 | ctx.dispose(); 298 | }); 299 | 300 | test("deleteByHandle", async () => { 301 | const ctx = (await getQuickJS()).newContext(); 302 | 303 | const target = {}; 304 | const target2 = {}; 305 | const handle = ctx.newObject(); 306 | const handle2 = ctx.newObject(); 307 | 308 | const map = new VMMap(ctx); 309 | map.set(target, handle, target2, handle2); 310 | 311 | expect(map.getByHandle(handle)).toBe(target); 312 | expect(map.getByHandle(handle2)).toBe(target); 313 | 314 | map.deleteByHandle(handle); 315 | 316 | expect(map.getByHandle(handle)).toBe(undefined); 317 | expect(map.getByHandle(handle2)).toBe(undefined); 318 | 319 | handle.dispose(); 320 | handle2.dispose(); 321 | map.dispose(); 322 | ctx.dispose(); 323 | }); 324 | 325 | test("deleteByHandle with dispose", async () => { 326 | const ctx = (await getQuickJS()).newContext(); 327 | 328 | const target = {}; 329 | const target2 = {}; 330 | const handle = ctx.newObject(); 331 | const handle2 = ctx.newObject(); 332 | 333 | const map = new VMMap(ctx); 334 | map.set(target, handle, target2, handle2); 335 | map.deleteByHandle(handle, true); 336 | 337 | expect(handle.alive).toBe(false); 338 | expect(handle2.alive).toBe(false); 339 | 340 | map.dispose(); 341 | ctx.dispose(); 342 | }); 343 | -------------------------------------------------------------------------------- /src/vmmap.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten"; 2 | 3 | export default class VMMap { 4 | ctx: QuickJSContext; 5 | _map1: Map = new Map(); 6 | _map2: Map = new Map(); 7 | _map3: Map = new Map(); 8 | _map4: Map = new Map(); 9 | _counterMap: Map = new Map(); 10 | _disposables: Set = new Set(); 11 | _mapGet: QuickJSHandle; 12 | _mapSet: QuickJSHandle; 13 | _mapDelete: QuickJSHandle; 14 | _mapClear: QuickJSHandle; 15 | _counter = Number.MIN_SAFE_INTEGER; 16 | 17 | constructor(ctx: QuickJSContext) { 18 | this.ctx = ctx; 19 | 20 | const result = ctx 21 | .unwrapResult( 22 | ctx.evalCode(`() => { 23 | const mapSym = new Map(); 24 | let map = new WeakMap(); 25 | let map2 = new WeakMap(); 26 | const isObj = o => typeof o === "object" && o !== null || typeof o === "function"; 27 | return { 28 | get: key => mapSym.get(key) ?? map.get(key) ?? map2.get(key) ?? -1, 29 | set: (key, value, key2) => { 30 | if (typeof key === "symbol") mapSym.set(key, value); 31 | if (isObj(key)) map.set(key, value); 32 | if (isObj(key2)) map2.set(key2, value); 33 | }, 34 | delete: (key, key2) => { 35 | mapSym.delete(key); 36 | map.delete(key); 37 | map2.delete(key2); 38 | }, 39 | clear: () => { 40 | mapSym.clear(); 41 | map = new WeakMap(); 42 | map2 = new WeakMap(); 43 | } 44 | }; 45 | }`), 46 | ) 47 | .consume(fn => this._call(fn, undefined)); 48 | 49 | this._mapGet = ctx.getProp(result, "get"); 50 | this._mapSet = ctx.getProp(result, "set"); 51 | this._mapDelete = ctx.getProp(result, "delete"); 52 | this._mapClear = ctx.getProp(result, "clear"); 53 | 54 | result.dispose(); 55 | 56 | this._disposables.add(this._mapGet); 57 | this._disposables.add(this._mapSet); 58 | this._disposables.add(this._mapDelete); 59 | this._disposables.add(this._mapClear); 60 | } 61 | 62 | set(key: any, handle: QuickJSHandle, key2?: any, handle2?: QuickJSHandle): boolean { 63 | if (!handle.alive || (handle2 && !handle2.alive)) return false; 64 | 65 | const v = this.get(key) ?? this.get(key2); 66 | if (v) { 67 | // handle and handle2 are unused so they should be disposed 68 | return v === handle || v === handle2; 69 | } 70 | 71 | const counter = this._counter++; 72 | this._map1.set(key, counter); 73 | this._map3.set(counter, handle); 74 | this._counterMap.set(counter, key); 75 | if (key2) { 76 | this._map2.set(key2, counter); 77 | if (handle2) { 78 | this._map4.set(counter, handle2); 79 | } 80 | } 81 | 82 | this.ctx.newNumber(counter).consume(c => { 83 | this._call(this._mapSet, undefined, handle, c, handle2 ?? this.ctx.undefined); 84 | }); 85 | 86 | return true; 87 | } 88 | 89 | merge( 90 | iteratable: 91 | | Iterable< 92 | | [any, QuickJSHandle | undefined] 93 | | [any, QuickJSHandle | undefined, any, QuickJSHandle | undefined] 94 | > 95 | | undefined, 96 | ) { 97 | if (!iteratable) return; 98 | for (const iter of iteratable) { 99 | if (!iter) continue; 100 | if (iter[1]) { 101 | this.set(iter[0], iter[1], iter[2], iter[3]); 102 | } 103 | } 104 | } 105 | 106 | get(key: any) { 107 | const num = this._map1.get(key) ?? this._map2.get(key); 108 | const handle = typeof num === "number" ? this._map3.get(num) : undefined; 109 | 110 | if (!handle) return; 111 | if (!handle.alive) { 112 | this.delete(key); 113 | return; 114 | } 115 | 116 | return handle; 117 | } 118 | 119 | getByHandle(handle: QuickJSHandle) { 120 | if (!handle.alive) { 121 | return; 122 | } 123 | return this._counterMap.get(this.ctx.getNumber(this._call(this._mapGet, undefined, handle))); 124 | } 125 | 126 | has(key: any) { 127 | return !!this.get(key); 128 | } 129 | 130 | hasHandle(handle: QuickJSHandle) { 131 | return typeof this.getByHandle(handle) !== "undefined"; 132 | } 133 | 134 | keys() { 135 | return this._map1.keys(); 136 | } 137 | 138 | delete(key: any, dispose?: boolean) { 139 | const num = this._map1.get(key) ?? this._map2.get(key); 140 | if (typeof num === "undefined") return; 141 | 142 | const handle = this._map3.get(num); 143 | const handle2 = this._map4.get(num); 144 | this._call( 145 | this._mapDelete, 146 | undefined, 147 | ...[handle, handle2].filter((h): h is QuickJSHandle => !!h?.alive), 148 | ); 149 | 150 | this._map1.delete(key); 151 | this._map2.delete(key); 152 | this._map3.delete(num); 153 | this._map4.delete(num); 154 | 155 | for (const [k, v] of this._map1) { 156 | if (v === num) { 157 | this._map1.delete(k); 158 | break; 159 | } 160 | } 161 | 162 | for (const [k, v] of this._map2) { 163 | if (v === num) { 164 | this._map2.delete(k); 165 | break; 166 | } 167 | } 168 | 169 | for (const [k, v] of this._counterMap) { 170 | if (v === key) { 171 | this._counterMap.delete(k); 172 | break; 173 | } 174 | } 175 | 176 | if (dispose) { 177 | if (handle?.alive) handle.dispose(); 178 | if (handle2?.alive) handle2.dispose(); 179 | } 180 | } 181 | 182 | deleteByHandle(handle: QuickJSHandle, dispose?: boolean) { 183 | const key = this.getByHandle(handle); 184 | if (typeof key !== "undefined") { 185 | this.delete(key, dispose); 186 | } 187 | } 188 | 189 | clear() { 190 | this._counter = 0; 191 | this._map1.clear(); 192 | this._map2.clear(); 193 | this._map3.clear(); 194 | this._map4.clear(); 195 | this._counterMap.clear(); 196 | if (this._mapClear.alive) { 197 | this._call(this._mapClear, undefined); 198 | } 199 | } 200 | 201 | dispose() { 202 | for (const v of this._disposables.values()) { 203 | if (v.alive) { 204 | v.dispose(); 205 | } 206 | } 207 | for (const v of this._map3.values()) { 208 | if (v.alive) { 209 | v.dispose(); 210 | } 211 | } 212 | for (const v of this._map4.values()) { 213 | if (v.alive) { 214 | v.dispose(); 215 | } 216 | } 217 | this._disposables.clear(); 218 | this.clear(); 219 | } 220 | 221 | get size() { 222 | return this._map1.size; 223 | } 224 | 225 | [Symbol.iterator](): Iterator<[any, QuickJSHandle, any, QuickJSHandle | undefined]> { 226 | const keys = this._map1.keys(); 227 | return { 228 | next: () => { 229 | // eslint-disable-next-line no-constant-condition 230 | while (true) { 231 | const k1 = keys.next(); 232 | if (k1.done) return { value: undefined, done: true }; 233 | const n = this._map1.get(k1.value); 234 | if (typeof n === "undefined") continue; 235 | const v1 = this._map3.get(n); 236 | const v2 = this._map4.get(n); 237 | if (!v1) continue; 238 | const k2 = this._get2(n); 239 | return { value: [k1.value, v1, k2, v2], done: false }; 240 | } 241 | }, 242 | }; 243 | } 244 | 245 | _get2(num: number) { 246 | for (const [k, v] of this._map2) { 247 | if (v === num) return k; 248 | } 249 | } 250 | 251 | _call(fn: QuickJSHandle, thisArg: QuickJSHandle | undefined, ...args: QuickJSHandle[]) { 252 | return this.ctx.unwrapResult( 253 | this.ctx.callFunction( 254 | fn, 255 | typeof thisArg === "undefined" ? this.ctx.undefined : thisArg, 256 | ...args, 257 | ), 258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/vmutil.test.ts: -------------------------------------------------------------------------------- 1 | import { getQuickJS, QuickJSHandle } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { 5 | fn, 6 | call, 7 | consumeAll, 8 | eq, 9 | instanceOf, 10 | isHandleObject, 11 | json, 12 | mayConsume, 13 | mayConsumeAll, 14 | handleFrom, 15 | } from "./vmutil"; 16 | 17 | test("fn", async () => { 18 | const ctx = (await getQuickJS()).newContext(); 19 | 20 | const f = fn(ctx, "(a, b) => a + b"); 21 | expect(ctx.getNumber(f(undefined, ctx.newNumber(1), ctx.newNumber(2)))).toBe(3); 22 | 23 | const obj = ctx.newObject(); 24 | ctx.setProp(obj, "a", ctx.newNumber(2)); 25 | const f2 = fn(ctx, "(function() { return this.a + 1; })"); 26 | expect(ctx.getNumber(f2(obj))).toBe(3); 27 | 28 | obj.dispose(); 29 | expect(f.alive).toBe(true); 30 | expect(f2.alive).toBe(true); 31 | f2.dispose(); 32 | f.dispose(); 33 | expect(f.alive).toBe(false); 34 | expect(f2.alive).toBe(false); 35 | ctx.dispose(); 36 | }); 37 | 38 | test("call", async () => { 39 | const ctx = (await getQuickJS()).newContext(); 40 | 41 | expect( 42 | ctx.getNumber(call(ctx, "(a, b) => a + b", undefined, ctx.newNumber(1), ctx.newNumber(2))), 43 | ).toBe(3); 44 | 45 | const obj = ctx.newObject(); 46 | ctx.setProp(obj, "a", ctx.newNumber(2)); 47 | expect(ctx.getNumber(call(ctx, "(function() { return this.a + 1; })", obj))).toBe(3); 48 | 49 | obj.dispose(); 50 | ctx.dispose(); 51 | }); 52 | 53 | test("eq", async () => { 54 | const ctx = (await getQuickJS()).newContext(); 55 | 56 | const math1 = ctx.unwrapResult(ctx.evalCode("Math")); 57 | const math2 = ctx.unwrapResult(ctx.evalCode("Math")); 58 | const obj = ctx.newObject(); 59 | expect(math1 === math2).toBe(false); 60 | expect(eq(ctx, math1, math2)).toBe(true); 61 | expect(eq(ctx, math1, obj)).toBe(false); 62 | 63 | math1.dispose(); 64 | math2.dispose(); 65 | obj.dispose(); 66 | ctx.dispose(); 67 | }); 68 | 69 | test("instanceOf", async () => { 70 | const ctx = (await getQuickJS()).newContext(); 71 | 72 | const pr = ctx.unwrapResult(ctx.evalCode("Promise")); 73 | const func = ctx.unwrapResult(ctx.evalCode("(function() {})")); 74 | const p = ctx.unwrapResult(ctx.evalCode("Promise.resolve()")); 75 | expect(instanceOf(ctx, p, pr)).toBe(true); 76 | expect(instanceOf(ctx, p, func)).toBe(false); 77 | 78 | p.dispose(); 79 | pr.dispose(); 80 | func.dispose(); 81 | ctx.dispose(); 82 | }); 83 | 84 | test("isHandleObject", async () => { 85 | const ctx = (await getQuickJS()).newContext(); 86 | 87 | const obj = ctx.newObject(); 88 | expect(isHandleObject(ctx, obj)).toBe(true); 89 | const func = ctx.newFunction("", () => {}); 90 | expect(isHandleObject(ctx, func)).toBe(true); 91 | const array = ctx.newArray(); 92 | expect(isHandleObject(ctx, array)).toBe(true); 93 | const num = ctx.newNumber(NaN); 94 | expect(isHandleObject(ctx, num)).toBe(false); 95 | 96 | obj.dispose(); 97 | func.dispose(); 98 | array.dispose(); 99 | ctx.dispose(); 100 | }); 101 | 102 | test("json", async () => { 103 | const ctx = (await getQuickJS()).newContext(); 104 | 105 | const handle = json(ctx, { 106 | hoge: { foo: ["bar"] }, 107 | }); 108 | expect(ctx.dump(call(ctx, `a => a.hoge.foo[0] === "bar"`, undefined, handle))).toBe(true); 109 | expect(ctx.typeof(json(ctx, undefined))).toBe("undefined"); 110 | 111 | handle.dispose(); 112 | ctx.dispose(); 113 | }); 114 | 115 | test("consumeAll", async () => { 116 | const ctx = (await getQuickJS()).newContext(); 117 | 118 | const o = {}; 119 | 120 | const handles = [ctx.newObject(), ctx.newObject()]; 121 | expect( 122 | consumeAll( 123 | handles, 124 | vi.fn(() => o), 125 | ), 126 | ).toBe(o); 127 | expect(handles.every(h => !h.alive)).toBe(true); 128 | 129 | const handles2 = [ctx.newObject(), ctx.newObject()]; 130 | expect(() => 131 | consumeAll(handles2, () => { 132 | throw new Error("qes error"); 133 | }), 134 | ).toThrow("qes error"); 135 | expect(handles2.every(h => !h.alive)).toBe(true); 136 | 137 | ctx.dispose(); 138 | }); 139 | 140 | test("mayConsume", async () => { 141 | const ctx = (await getQuickJS()).newContext(); 142 | 143 | const o = {}; 144 | 145 | const handle = ctx.newArray(); 146 | expect( 147 | mayConsume( 148 | [handle, false], 149 | vi.fn(() => o), 150 | ), 151 | ).toBe(o); 152 | expect(handle.alive).toBe(true); 153 | 154 | mayConsume([handle, true], () => {}); 155 | expect(handle.alive).toBe(false); 156 | 157 | const handle2 = ctx.newArray(); 158 | expect(() => 159 | mayConsume([handle2, true], () => { 160 | throw new Error("qes error"); 161 | }), 162 | ).toThrow("qes error"); 163 | expect(handle.alive).toBe(false); 164 | 165 | ctx.dispose(); 166 | }); 167 | 168 | test("mayConsumeAll", async () => { 169 | const ctx = (await getQuickJS()).newContext(); 170 | 171 | const o = {}; 172 | 173 | const handles: [QuickJSHandle, boolean][] = [ 174 | [ctx.newObject(), false], 175 | [ctx.newObject(), true], 176 | ]; 177 | expect( 178 | mayConsumeAll( 179 | handles, 180 | vi.fn((..._: any[]) => o), 181 | ), 182 | ).toBe(o); 183 | expect(handles[0][0].alive).toBe(true); 184 | expect(handles[1][0].alive).toBe(false); 185 | 186 | const handles2: [QuickJSHandle, boolean][] = [ 187 | [ctx.newObject(), false], 188 | [ctx.newObject(), true], 189 | ]; 190 | expect(() => 191 | mayConsumeAll(handles2, (..._args) => { 192 | throw new Error("qes error"); 193 | }), 194 | ).toThrow("qes error"); 195 | expect(handles2[0][0].alive).toBe(true); 196 | expect(handles2[1][0].alive).toBe(false); 197 | 198 | handles[0][0].dispose(); 199 | handles2[0][0].dispose(); 200 | ctx.dispose(); 201 | }); 202 | 203 | test("handleFrom", async () => { 204 | const ctx = (await getQuickJS()).newContext(); 205 | 206 | const handle = ctx.newObject(); 207 | const promise = ctx.newPromise(); 208 | 209 | expect(handleFrom(handle) === handle).toBe(true); 210 | expect(handleFrom(promise) === promise.handle).toBe(true); 211 | 212 | handle.dispose(); 213 | promise.dispose(); 214 | ctx.dispose(); 215 | }); 216 | -------------------------------------------------------------------------------- /src/vmutil.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Disposable, 3 | QuickJSContext, 4 | QuickJSHandle, 5 | QuickJSDeferredPromise, 6 | } from "quickjs-emscripten"; 7 | 8 | export function fn( 9 | ctx: QuickJSContext, 10 | code: string, 11 | ): ((thisArg: QuickJSHandle | undefined, ...args: QuickJSHandle[]) => QuickJSHandle) & Disposable { 12 | const handle = ctx.unwrapResult(ctx.evalCode(code)); 13 | const f = (thisArg: QuickJSHandle | undefined, ...args: QuickJSHandle[]): any => { 14 | return ctx.unwrapResult(ctx.callFunction(handle, thisArg ?? ctx.undefined, ...args)); 15 | }; 16 | f.dispose = () => handle.dispose(); 17 | f.alive = true; 18 | Object.defineProperty(f, "alive", { 19 | get: () => handle.alive, 20 | }); 21 | return f; 22 | } 23 | 24 | export function call( 25 | ctx: QuickJSContext, 26 | code: string, 27 | thisArg?: QuickJSHandle, 28 | ...args: QuickJSHandle[] 29 | ): QuickJSHandle { 30 | const f = fn(ctx, code); 31 | try { 32 | return f(thisArg, ...args); 33 | } finally { 34 | f.dispose(); 35 | } 36 | } 37 | 38 | export function eq(ctx: QuickJSContext, a: QuickJSHandle, b: QuickJSHandle): boolean { 39 | return ctx.dump(call(ctx, "Object.is", undefined, a, b)); 40 | } 41 | 42 | export function instanceOf(ctx: QuickJSContext, a: QuickJSHandle, b: QuickJSHandle): boolean { 43 | return ctx.dump(call(ctx, "(a, b) => a instanceof b", undefined, a, b)); 44 | } 45 | 46 | export function isHandleObject(ctx: QuickJSContext, h: QuickJSHandle): boolean { 47 | return ctx.dump( 48 | call(ctx, `a => typeof a === "object" && a !== null || typeof a === "function"`, undefined, h), 49 | ); 50 | } 51 | 52 | export function json(ctx: QuickJSContext, target: any): QuickJSHandle { 53 | const json = JSON.stringify(target); 54 | if (!json) return ctx.undefined; 55 | return call(ctx, `JSON.parse`, undefined, ctx.newString(json)); 56 | } 57 | 58 | export function consumeAll(handles: T, cb: (handles: T) => K): K { 59 | try { 60 | return cb(handles); 61 | } finally { 62 | for (const h of handles) { 63 | if (h.alive) h.dispose(); 64 | } 65 | } 66 | } 67 | 68 | export function mayConsume( 69 | [handle, shouldBeDisposed]: [QuickJSHandle, boolean], 70 | fn: (h: QuickJSHandle) => T, 71 | ) { 72 | try { 73 | return fn(handle); 74 | } finally { 75 | if (shouldBeDisposed) { 76 | handle.dispose(); 77 | } 78 | } 79 | } 80 | 81 | export function mayConsumeAll( 82 | handles: { [P in keyof H]: [QuickJSHandle, boolean] }, 83 | fn: (...args: H) => T, 84 | ) { 85 | try { 86 | return fn(...(handles.map(h => h[0]) as H)); 87 | } finally { 88 | for (const [handle, shouldBeDisposed] of handles) { 89 | if (shouldBeDisposed) { 90 | handle.dispose(); 91 | } 92 | } 93 | } 94 | } 95 | 96 | function isQuickJSDeferredPromise(d: Disposable): d is QuickJSDeferredPromise { 97 | return "handle" in d; 98 | } 99 | 100 | export function handleFrom(d: QuickJSDeferredPromise | QuickJSHandle): QuickJSHandle { 101 | return isQuickJSDeferredPromise(d) ? d.handle : d; 102 | } 103 | -------------------------------------------------------------------------------- /src/wrapper.test.ts: -------------------------------------------------------------------------------- 1 | import { QuickJSHandle, getQuickJS } from "quickjs-emscripten"; 2 | import { expect, test, vi } from "vitest"; 3 | 4 | import { call, eq, json } from "./vmutil"; 5 | import { 6 | wrap, 7 | unwrap, 8 | isWrapped, 9 | wrapHandle, 10 | unwrapHandle, 11 | isHandleWrapped, 12 | SyncMode, 13 | } from "./wrapper"; 14 | 15 | test("wrap, unwrap, isWrapped", async () => { 16 | const ctx = (await getQuickJS()).newContext(); 17 | const target = { a: 1 }; 18 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 19 | const proxyKeySymbol = Symbol(); 20 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 21 | const marshal = vi.fn(); 22 | const syncMode = vi.fn(); 23 | 24 | expect(isWrapped(target, proxyKeySymbol)).toBe(false); 25 | expect(unwrap(target, proxyKeySymbol)).toBe(target); 26 | 27 | const wrapped = wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode); 28 | if (!wrapped) throw new Error("wrapped is undefined"); 29 | 30 | expect(wrapped).toEqual(target); 31 | expect(isWrapped(wrapped, proxyKeySymbol)).toBe(true); 32 | expect(unwrap(wrapped, proxyKeySymbol)).toBe(target); 33 | 34 | expect(wrap(ctx, wrapped, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode)).toBe(wrapped); 35 | 36 | // promise cannot be wrapped 37 | expect( 38 | wrap(ctx, Promise.resolve(1), proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode), 39 | ).toBeUndefined(); 40 | 41 | proxyKeySymbolHandle.dispose(); 42 | handle.dispose(); 43 | ctx.dispose(); 44 | }); 45 | 46 | test("wrap without sync", async () => { 47 | const ctx = (await getQuickJS()).newContext(); 48 | const target = { a: 1 }; 49 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 50 | const proxyKeySymbol = Symbol(); 51 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 52 | const marshal = vi.fn(); 53 | const syncMode = vi.fn(); 54 | 55 | const wrapped = wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode); 56 | if (!wrapped) throw new Error("wrapped is undefined"); 57 | 58 | expect(marshal).toBeCalledTimes(0); 59 | expect(syncMode).toBeCalledTimes(0); 60 | 61 | wrapped.a = 2; 62 | 63 | expect(target.a).toBe(2); 64 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(1); // not synced 65 | expect(marshal).toBeCalledTimes(0); 66 | expect(syncMode).toBeCalledTimes(1); 67 | expect(syncMode).toBeCalledWith(wrapped); 68 | 69 | proxyKeySymbolHandle.dispose(); 70 | handle.dispose(); 71 | ctx.dispose(); 72 | 73 | wrapped.a = 3; // no error even if vm is disposed 74 | expect(wrapped.a).toBe(3); 75 | }); 76 | 77 | test("wrap with both sync", async () => { 78 | const ctx = (await getQuickJS()).newContext(); 79 | const target = { a: 1 } as { a?: number }; 80 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 81 | const proxyKeySymbol = Symbol(); 82 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 83 | const marshal = vi.fn((t: any): [QuickJSHandle, boolean] => [ 84 | t === wrapped ? handle : json(ctx, t), 85 | false, 86 | ]); 87 | const syncMode = vi.fn((): SyncMode => "both"); 88 | 89 | const wrapped = wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode); 90 | if (!wrapped) throw new Error("wrapped is undefined"); 91 | 92 | expect(marshal).toBeCalledTimes(0); 93 | expect(syncMode).toBeCalledTimes(0); 94 | 95 | wrapped.a = 2; 96 | 97 | expect(target.a).toBe(2); 98 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(2); // synced 99 | expect(marshal).toBeCalledTimes(3); 100 | expect(marshal).toBeCalledWith(2); 101 | expect(marshal).toBeCalledWith("a"); 102 | expect(syncMode).toBeCalledTimes(1); 103 | expect(syncMode).toBeCalledWith(wrapped); 104 | 105 | delete wrapped.a; 106 | expect(target.a).toBe(undefined); 107 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(undefined); // synced 108 | 109 | proxyKeySymbolHandle.dispose(); 110 | handle.dispose(); 111 | ctx.dispose(); 112 | 113 | wrapped.a = 3; // no error even if vm is disposed 114 | expect(wrapped.a).toBe(3); 115 | }); 116 | 117 | test("wrap with vm sync", async () => { 118 | const ctx = (await getQuickJS()).newContext(); 119 | const target = { a: 1 } as { a?: number }; 120 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 121 | const proxyKeySymbol = Symbol(); 122 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 123 | const marshal = vi.fn((t: any): [QuickJSHandle, boolean] => [ 124 | t === wrapped ? handle : json(ctx, t), 125 | false, 126 | ]); 127 | const syncMode = vi.fn((): SyncMode => "vm"); 128 | 129 | const wrapped = wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode); 130 | if (!wrapped) throw new Error("wrapped is undefined"); 131 | 132 | expect(marshal).toBeCalledTimes(0); 133 | expect(syncMode).toBeCalledTimes(0); 134 | 135 | wrapped.a = 2; 136 | 137 | expect(target.a).toBe(1); // not set 138 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(2); // synced 139 | expect(marshal).toBeCalledTimes(3); 140 | expect(marshal).toBeCalledWith(2); 141 | expect(marshal).toBeCalledWith("a"); 142 | expect(syncMode).toBeCalledTimes(1); 143 | expect(syncMode).toBeCalledWith(wrapped); 144 | 145 | delete wrapped.a; 146 | expect(target.a).toBe(1); // not deleted 147 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(undefined); // synced 148 | 149 | proxyKeySymbolHandle.dispose(); 150 | handle.dispose(); 151 | ctx.dispose(); 152 | 153 | wrapped.a = 3; // no error even after vm is disposed 154 | expect(wrapped.a).toBe(1); // vm mode cannot modify obj in host 155 | }); 156 | 157 | test("wrapHandle, unwrapHandle, isHandleWrapped", async () => { 158 | const ctx = (await getQuickJS()).newContext(); 159 | const target = { a: 1 }; 160 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 161 | const proxyKeySymbol = Symbol(); 162 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 163 | const unmarshal = vi.fn(); 164 | const syncMode = vi.fn(); 165 | 166 | expect(isHandleWrapped(ctx, handle, proxyKeySymbolHandle)).toBe(false); 167 | expect(unwrapHandle(ctx, handle, proxyKeySymbolHandle)).toEqual([handle, false]); 168 | 169 | const [wrapped, w] = wrapHandle( 170 | ctx, 171 | handle, 172 | proxyKeySymbol, 173 | proxyKeySymbolHandle, 174 | unmarshal, 175 | syncMode, 176 | ); 177 | if (!wrapped || !w) throw new Error("wrapped is undefined"); 178 | 179 | expect(ctx.dump(wrapped)).toEqual(target); // vm.dump does not support proxies 180 | expect(ctx.dump(ctx.getProp(wrapped, "a"))).toBe(1); 181 | expect(isHandleWrapped(ctx, wrapped, proxyKeySymbolHandle)).toBe(true); 182 | 183 | const [handle2, unwrapped2] = unwrapHandle(ctx, wrapped, proxyKeySymbolHandle); 184 | expect(unwrapped2).toBe(true); 185 | handle2.consume(h => { 186 | expect(eq(ctx, handle, h)).toBe(true); 187 | }); 188 | 189 | const [wrapped2] = wrapHandle( 190 | ctx, 191 | wrapped, 192 | proxyKeySymbol, 193 | proxyKeySymbolHandle, 194 | unmarshal, 195 | syncMode, 196 | ); 197 | expect(wrapped2 === wrapped).toBe(true); 198 | 199 | // promise cannot be wrapped 200 | const deferred = ctx.newPromise(); 201 | expect(isHandleWrapped(ctx, deferred.handle, proxyKeySymbolHandle)).toBe(true); 202 | 203 | deferred.dispose(); 204 | wrapped.dispose(); 205 | handle.dispose(); 206 | proxyKeySymbolHandle.dispose(); 207 | ctx.dispose(); 208 | }); 209 | 210 | test("wrapHandle without sync", async () => { 211 | const ctx = (await getQuickJS()).newContext(); 212 | const target = { a: 1 }; 213 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 214 | const proxyKeySymbol = Symbol(); 215 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 216 | const unmarshal = vi.fn((h: QuickJSHandle) => 217 | wrapped && eq(ctx, h, wrapped) ? target : ctx.dump(h), 218 | ); 219 | const syncMode = vi.fn(); 220 | 221 | const [wrapped, w] = wrapHandle( 222 | ctx, 223 | handle, 224 | proxyKeySymbol, 225 | proxyKeySymbolHandle, 226 | unmarshal, 227 | syncMode, 228 | ); 229 | if (!wrapped || !w) throw new Error("wrapped is undefined"); 230 | 231 | expect(unmarshal).toBeCalledTimes(0); 232 | expect(syncMode).toBeCalledTimes(0); 233 | 234 | call(ctx, `a => a.a = 2`, undefined, wrapped); 235 | 236 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(2); 237 | expect(target.a).toBe(1); // not synced 238 | expect(unmarshal).toBeCalledTimes(1); 239 | expect(unmarshal).toReturnWith(target); 240 | expect(syncMode).toBeCalledTimes(1); 241 | expect(syncMode).toBeCalledWith(target); 242 | 243 | wrapped.dispose(); 244 | handle.dispose(); 245 | proxyKeySymbolHandle.dispose(); 246 | ctx.dispose(); 247 | }); 248 | 249 | test("wrapHandle with both sync", async () => { 250 | const ctx = (await getQuickJS()).newContext(); 251 | const target = { a: 1 }; 252 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 253 | const proxyKeySymbol = Symbol(); 254 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 255 | const unmarshal = vi.fn((h: QuickJSHandle) => { 256 | return wrapped && eq(ctx, h, wrapped) ? target : ctx.dump(h); 257 | }); 258 | const syncMode = vi.fn((): SyncMode => "both"); 259 | 260 | const [wrapped, w] = wrapHandle( 261 | ctx, 262 | handle, 263 | proxyKeySymbol, 264 | proxyKeySymbolHandle, 265 | unmarshal, 266 | syncMode, 267 | ); 268 | if (!wrapped || !w) throw new Error("wrapped is undefined"); 269 | 270 | expect(unmarshal).toBeCalledTimes(0); 271 | expect(syncMode).toBeCalledTimes(0); 272 | 273 | call(ctx, `a => a.a = 2`, undefined, wrapped); 274 | 275 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(2); 276 | expect(target.a).toBe(2); // synced 277 | expect(unmarshal).toBeCalledTimes(4); 278 | expect(unmarshal).toReturnWith(target); // twice 279 | expect(unmarshal).toReturnWith("a"); 280 | expect(unmarshal).toReturnWith(2); 281 | expect(syncMode).toBeCalledTimes(1); 282 | expect(syncMode).toBeCalledWith(target); 283 | 284 | call(ctx, `a => { delete a.a; }`, undefined, wrapped); 285 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(undefined); 286 | expect(target.a).toBe(undefined); // synced 287 | 288 | // changing __proto__ will be blocked 289 | call(ctx, `a => { a.__proto__ = null; }`, undefined, wrapped); 290 | expect(ctx.dump(call(ctx, `Object.getPrototypeOf`, undefined, handle))).toBe(null); 291 | expect(Object.getPrototypeOf(target)).toBe(Object.prototype); // not changed 292 | 293 | wrapped.dispose(); 294 | handle.dispose(); 295 | proxyKeySymbolHandle.dispose(); 296 | ctx.dispose(); 297 | }); 298 | 299 | test("wrapHandle with host sync", async () => { 300 | const ctx = (await getQuickJS()).newContext(); 301 | const target = { a: 1 }; 302 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 303 | const proxyKeySymbol = Symbol(); 304 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 305 | const unmarshal = vi.fn((handle: QuickJSHandle) => 306 | wrapped && eq(ctx, handle, wrapped) ? target : ctx.dump(handle), 307 | ); 308 | const syncMode = vi.fn((): SyncMode => "host"); 309 | 310 | const [wrapped, w] = wrapHandle( 311 | ctx, 312 | handle, 313 | proxyKeySymbol, 314 | proxyKeySymbolHandle, 315 | unmarshal, 316 | syncMode, 317 | ); 318 | if (!wrapped || !w) throw new Error("wrapped is undefined"); 319 | 320 | expect(unmarshal).toBeCalledTimes(0); 321 | expect(syncMode).toBeCalledTimes(0); 322 | 323 | call(ctx, `a => a.a = 2`, undefined, wrapped); 324 | 325 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(1); // not set 326 | expect(target.a).toBe(2); // synced 327 | expect(unmarshal).toBeCalledTimes(4); 328 | expect(unmarshal).toReturnWith(target); // twice 329 | expect(unmarshal).toReturnWith("a"); 330 | expect(unmarshal).toReturnWith(2); 331 | expect(syncMode).toBeCalledTimes(1); 332 | expect(syncMode).toBeCalledWith(target); 333 | 334 | call(ctx, `a => delete a.a`, undefined, handle); 335 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(undefined); 336 | expect(target.a).toBe(2); // not synced 337 | 338 | wrapped.dispose(); 339 | handle.dispose(); 340 | proxyKeySymbolHandle.dispose(); 341 | ctx.dispose(); 342 | }); 343 | 344 | test("wrap and wrapHandle", async () => { 345 | const ctx = (await getQuickJS()).newContext(); 346 | const target = { a: 1 }; 347 | const handle = ctx.unwrapResult(ctx.evalCode(`({ a: 1 })`)); 348 | const proxyKeySymbol = Symbol(); 349 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 350 | const marshal = vi.fn((t: any): [QuickJSHandle, boolean] => [ 351 | wrappedHandle && t === wrapped ? wrappedHandle : json(ctx, t), 352 | false, 353 | ]); 354 | const unmarshal = vi.fn((handle: QuickJSHandle) => 355 | wrappedHandle && eq(ctx, handle, wrappedHandle) ? wrapped : ctx.dump(handle), 356 | ); 357 | const syncMode = vi.fn((): SyncMode => "both"); 358 | 359 | const wrapped = wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, marshal, syncMode); 360 | if (!wrapped) throw new Error("wrapped is undefined"); 361 | const [wrappedHandle, w] = wrapHandle( 362 | ctx, 363 | handle, 364 | proxyKeySymbol, 365 | proxyKeySymbolHandle, 366 | unmarshal, 367 | syncMode, 368 | ); 369 | if (!wrappedHandle || !w) throw new Error("wrappedHandle is undefined"); 370 | 371 | call(ctx, `a => a.a = 2`, undefined, wrappedHandle); 372 | 373 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(2); 374 | expect(target.a).toBe(2); 375 | expect(marshal).toBeCalledTimes(0); 376 | expect(unmarshal).toBeCalledTimes(4); 377 | expect(unmarshal).toReturnWith(wrapped); // twice 378 | expect(unmarshal).toReturnWith("a"); 379 | expect(unmarshal).toReturnWith(2); 380 | 381 | marshal.mockClear(); 382 | unmarshal.mockClear(); 383 | 384 | wrapped.a = 3; 385 | 386 | expect(ctx.dump(ctx.getProp(handle, "a"))).toBe(3); 387 | expect(target.a).toBe(3); 388 | expect(marshal).toBeCalledTimes(3); 389 | expect(marshal).toBeCalledWith(wrapped); 390 | expect(marshal).toBeCalledWith("a"); 391 | expect(marshal).toBeCalledWith(3); 392 | expect(unmarshal).toBeCalledTimes(0); 393 | 394 | wrappedHandle.dispose(); 395 | handle.dispose(); 396 | proxyKeySymbolHandle.dispose(); 397 | ctx.dispose(); 398 | }); 399 | 400 | test("non object", async () => { 401 | const ctx = (await getQuickJS()).newContext(); 402 | const target = 1; 403 | const handle = ctx.newNumber(1); 404 | const proxyKeySymbol = Symbol(); 405 | const proxyKeySymbolHandle = ctx.unwrapResult(ctx.evalCode(`Symbol()`)); 406 | 407 | expect(wrap(ctx, target, proxyKeySymbol, proxyKeySymbolHandle, vi.fn(), vi.fn())).toBe(undefined); 408 | 409 | expect(wrapHandle(ctx, handle, proxyKeySymbol, proxyKeySymbolHandle, vi.fn(), vi.fn())).toEqual([ 410 | undefined, 411 | false, 412 | ]); 413 | 414 | proxyKeySymbolHandle.dispose(); 415 | ctx.dispose(); 416 | }); 417 | -------------------------------------------------------------------------------- /src/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { QuickJSHandle, QuickJSContext } from "quickjs-emscripten"; 2 | 3 | import { isObject } from "./util"; 4 | import { call, isHandleObject, mayConsumeAll } from "./vmutil"; 5 | 6 | export type SyncMode = "both" | "vm" | "host"; 7 | 8 | export type Wrapped = T & { __qes_wrapped: never }; 9 | 10 | export function wrap( 11 | ctx: QuickJSContext, 12 | target: T, 13 | proxyKeySymbol: symbol, 14 | proxyKeySymbolHandle: QuickJSHandle, 15 | marshal: (target: any) => [QuickJSHandle, boolean], 16 | syncMode?: (target: T) => SyncMode | undefined, 17 | wrappable?: (target: unknown) => boolean, 18 | ): Wrapped | undefined { 19 | // promise and date cannot be wrapped 20 | if ( 21 | !isObject(target) || 22 | target instanceof Promise || 23 | target instanceof Date || 24 | (wrappable && !wrappable(target)) 25 | ) 26 | return undefined; 27 | 28 | if (isWrapped(target, proxyKeySymbol)) return target; 29 | 30 | const rec = new Proxy(target as any, { 31 | get(obj, key) { 32 | return key === proxyKeySymbol ? obj : Reflect.get(obj, key); 33 | }, 34 | set(obj, key, value, receiver) { 35 | const v = unwrap(value, proxyKeySymbol); 36 | const sync = syncMode?.(receiver) ?? "host"; 37 | if ((sync !== "vm" && !Reflect.set(obj, key, v, receiver)) || sync === "host" || !ctx.alive) 38 | return true; 39 | 40 | mayConsumeAll( 41 | [marshal(receiver), marshal(key), marshal(v)], 42 | (receiverHandle, keyHandle, valueHandle) => { 43 | const [handle2, unwrapped] = unwrapHandle(ctx, receiverHandle, proxyKeySymbolHandle); 44 | if (unwrapped) { 45 | handle2.consume(h => ctx.setProp(h, keyHandle, valueHandle)); 46 | } else { 47 | ctx.setProp(handle2, keyHandle, valueHandle); 48 | } 49 | }, 50 | ); 51 | 52 | return true; 53 | }, 54 | deleteProperty(obj, key) { 55 | const sync = syncMode?.(rec) ?? "host"; 56 | return mayConsumeAll([marshal(rec), marshal(key)], (recHandle, keyHandle) => { 57 | const [handle2, unwrapped] = unwrapHandle(ctx, recHandle, proxyKeySymbolHandle); 58 | 59 | if (sync === "vm" || Reflect.deleteProperty(obj, key)) { 60 | if (sync === "host" || !ctx.alive) return true; 61 | 62 | if (unwrapped) { 63 | handle2.consume(h => call(ctx, `(a, b) => delete a[b]`, undefined, h, keyHandle)); 64 | } else { 65 | call(ctx, `(a, b) => delete a[b]`, undefined, handle2, keyHandle); 66 | } 67 | } 68 | return true; 69 | }); 70 | }, 71 | }) as Wrapped; 72 | return rec; 73 | } 74 | 75 | export function wrapHandle( 76 | ctx: QuickJSContext, 77 | handle: QuickJSHandle, 78 | proxyKeySymbol: symbol, 79 | proxyKeySymbolHandle: QuickJSHandle, 80 | unmarshal: (handle: QuickJSHandle) => any, 81 | syncMode?: (target: QuickJSHandle) => SyncMode | undefined, 82 | wrappable?: (target: QuickJSHandle, ctx: QuickJSContext) => boolean, 83 | ): [Wrapped | undefined, boolean] { 84 | if (!isHandleObject(ctx, handle) || (wrappable && !wrappable(handle, ctx))) 85 | return [undefined, false]; 86 | 87 | if (isHandleWrapped(ctx, handle, proxyKeySymbolHandle)) return [handle, false]; 88 | 89 | const getSyncMode = (h: QuickJSHandle) => { 90 | const res = syncMode?.(unmarshal(h)); 91 | if (typeof res === "string") return ctx.newString(res); 92 | return ctx.undefined; 93 | }; 94 | 95 | const setter = (h: QuickJSHandle, keyHandle: QuickJSHandle, valueHandle: QuickJSHandle) => { 96 | const target = unmarshal(h); 97 | if (!target) return; 98 | const key = unmarshal(keyHandle); 99 | if (key === "__proto__") return; // for security 100 | const value = unmarshal(valueHandle); 101 | unwrap(target, proxyKeySymbol)[key] = value; 102 | }; 103 | 104 | const deleter = (h: QuickJSHandle, keyHandle: QuickJSHandle) => { 105 | const target = unmarshal(h); 106 | if (!target) return; 107 | const key = unmarshal(keyHandle); 108 | delete unwrap(target, proxyKeySymbol)[key]; 109 | }; 110 | 111 | return ctx 112 | .newFunction("proxyFuncs", (t, ...args) => { 113 | const name = ctx.getNumber(t); 114 | switch (name) { 115 | case 1: 116 | return getSyncMode(args[0]); 117 | case 2: 118 | return setter(args[0], args[1], args[2]); 119 | case 3: 120 | return deleter(args[0], args[1]); 121 | } 122 | return ctx.undefined; 123 | }) 124 | .consume(proxyFuncs => [ 125 | call( 126 | ctx, 127 | `(target, sym, proxyFuncs) => { 128 | const rec = new Proxy(target, { 129 | get(obj, key, receiver) { 130 | return key === sym ? obj : Reflect.get(obj, key, receiver) 131 | }, 132 | set(obj, key, value, receiver) { 133 | const v = typeof value === "object" && value !== null || typeof value === "function" 134 | ? value[sym] ?? value 135 | : value; 136 | const sync = proxyFuncs(1, receiver) ?? "vm"; 137 | if (sync === "host" || Reflect.set(obj, key, v, receiver)) { 138 | if (sync !== "vm") { 139 | proxyFuncs(2, receiver, key, v); 140 | } 141 | } 142 | return true; 143 | }, 144 | deleteProperty(obj, key) { 145 | const sync = proxyFuncs(1, rec) ?? "vm"; 146 | if (sync === "host" || Reflect.deleteProperty(obj, key)) { 147 | if (sync !== "vm") { 148 | proxyFuncs(3, rec, key); 149 | } 150 | } 151 | return true; 152 | }, 153 | }); 154 | return rec; 155 | }`, 156 | undefined, 157 | handle, 158 | proxyKeySymbolHandle, 159 | proxyFuncs, 160 | ) as Wrapped, 161 | true, 162 | ]); 163 | } 164 | 165 | export function unwrap(obj: T, key: string | symbol): T { 166 | return isObject(obj) ? ((obj as any)[key] as T) ?? obj : obj; 167 | } 168 | 169 | export function unwrapHandle( 170 | ctx: QuickJSContext, 171 | handle: QuickJSHandle, 172 | key: QuickJSHandle, 173 | ): [QuickJSHandle, boolean] { 174 | if (!isHandleWrapped(ctx, handle, key)) return [handle, false]; 175 | return [ctx.getProp(handle, key), true]; 176 | } 177 | 178 | export function isWrapped(obj: T, key: string | symbol): obj is Wrapped { 179 | return isObject(obj) && !!(obj as any)[key]; 180 | } 181 | 182 | export function isHandleWrapped( 183 | ctx: QuickJSContext, 184 | handle: QuickJSHandle, 185 | key: QuickJSHandle, 186 | ): handle is Wrapped { 187 | return !!ctx.dump( 188 | call( 189 | ctx, 190 | // promise and date cannot be wrapped 191 | `(a, s) => (a instanceof Promise) || (a instanceof Date) || (typeof a === "object" && a !== null || typeof a === "function") && !!a[s]`, 192 | undefined, 193 | handle, 194 | key, 195 | ), 196 | ); 197 | } 198 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import { defineConfig } from "vite"; 5 | import dts from "vite-plugin-dts"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | dts({ 10 | rollupTypes: true, 11 | }), 12 | ], 13 | build: { 14 | target: "es2015", 15 | lib: { 16 | formats: ["es", "umd"], 17 | entry: "src/index.ts", 18 | name: "QuickjsEmscriptenSync", 19 | }, 20 | rollupOptions: { 21 | external: ["quickjs-emscripten"], 22 | output: { 23 | globals: { 24 | "quickjs-emscripten": "QuickjsEmscripten", 25 | }, 26 | }, 27 | }, 28 | }, 29 | test: { 30 | coverage: { 31 | reporter: ["text", "json"], 32 | }, 33 | }, 34 | }); 35 | --------------------------------------------------------------------------------