quickjs 封装 JavaScript 沙箱 quickjs 封装 JavaScript 沙箱详情
rxliuli blog 人气:21、场景
在前文JavaScript 沙箱探索 中声明了沙箱的接口,并且给出了一些简单的执行任意第三方 js 脚本的代码,但并未实现完整的 IJavaScriptShadowbox
,下面便讲一下如何基于 quickjs
实现它。
quickjs
在 js 的封装库是quickjs-emscripten,基本原理是将 c 编译为 wasm
然后运行在浏览器、nodejs
上,它提供了以下基础的 api。
export interface LowLevelJavascriptVm<VmHandle> { global: VmHandle; undefined: VmHandle; typeof(handle: VmHandle): string; getNumber(handle: VmHandle): number; getString(handle: VmHandle): string; newNumber(value: number): VmHandle; newString(value: string): VmHandle; newObject(prototype?: VmHandle): VmHandle; newFunction( name: string, value: VmFunctionImplementation<VmHandle> ): VmHandle; getProp(handle: VmHandle, key: string | VmHandle): VmHandle; setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void; defineProp( handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor<VmHandle> ): void; callFunction( func: VmHandle, thisVal: VmHandle, ...args: VmHandle[] ): VmCallResult<VmHandle>; evalCode(code: string): VmCallResult<VmHandle>; }
下面是一段官方的代码示例
import { getQuickJS } from "quickjs-emscripten"; async function main() { const QuickJS = await getQuickJS(); const vm = QuickJS.createVm(); const world = vm.newString("world"); vm.setProp(vm.global, "NAME", world); world.dispose(); const result = vm.evalCode(`"Hello " + NAME + "!"`); if (result.error) { console.log("Execution failed:", vm.dump(result.error)); result.error.dispose(); } else { console.log("Success:", vm.dump(result.value)); result.value.dispose(); } vm.dispose(); } main();
可以看到,创建 vm 中的变量后还必须留意调用 dispose
,有点像是后端连接数据库时必须注意关闭连接,而这其实是比较繁琐的,尤其是在复杂的情况下。简而言之,它的 api 太过于底层了。在 github issue
中有人创建了 quickjs-emscripten-sync
,这给了吾辈很多灵感,所以吾辈基于quickjs-emscripten 封装了一些工具函数,辅助而非替代它。
2、简化底层 api
主要目的有两个:
- 自动调用
dispose
- 提供更好的创建
vm
值的方法
2.1自动调用 dispose
主要思路是自动收集所有需要调用 dispose
的值,使用高阶函数在 callback
执行完之后自动调用。
这里还需要注意避免不需要的多层嵌套代理,主要是考虑到下面更多的底层 api 基于它实现,而它们之间可能存在嵌套调用。
import { QuickJSHandle, QuickJSVm } from "quickjs-emscripten"; const QuickJSVmScopeSymbol = Symbol("QuickJSVmScope"); /** * 为 QuickJSVm 添加局部作用域,局部作用域的所有方法调用不再需要手动释放内存 * @param vm * @param handle */ export function withScope<F extends (vm: QuickJSVm) => any>( vm: QuickJSVm, handle: F ): { value: ReturnType<F>; dispose(): void; } { let disposes: (() => void)[] = []; function wrap(handle: QuickJSHandle) { disposes.push(() => handle.alive && handle.dispose()); return handle; } //避免多层代理 const isProxy = !!Reflect.get(vm, QuickJSVmScopeSymbol); function dispose() { if (isProxy) { Reflect.get(vm, QuickJSVmScopeSymbol)(); return; } disposes.forEach((dispose) => dispose()); //手动释放闭包变量的内存 disposes.length = 0; } const value = handle( isProxy ? vm : new Proxy(vm, { get( target: QuickJSVm, p: keyof QuickJSVm | typeof QuickJSVmScopeSymbol ): any { if (p === QuickJSVmScopeSymbol) { return dispose; } //锁定所有方法的 this 值为 QuickJSVm 对象而非 Proxy 对象 const res = Reflect.get(target, p, target); if ( p.startsWith("new") || ["getProp", "unwrapResult"].includes(p) ) { return (...args: any[]): QuickJSHandle => { return wrap(Reflect.apply(res, target, args)); }; } if (["evalCode", "callFunction"].includes(p)) { return (...args: any[]) => { const res = (target[p] as any)(...args); disposes.push(() => { const handle = res.error ?? res.value; handle.alive && handle.dispose(); }); return res; }; } if (typeof res === "function") { return (...args: any[]) => { return Reflect.apply(res, target, args); }; } return res; }, }) ); return { value, dispose }; }
使用
withScope(vm, (vm) => { const _hello = vm.newFunction("hello", () => {}); const _object = vm.newObject(); vm.setProp(_object, "hello", _hello); vm.setProp(_object, "name", vm.newString("liuli")); expect(vm.dump(vm.getProp(_object, "hello"))).not.toBeNull(); vm.setProp(vm.global, "VM_GLOBAL", _object); }).dispose();
甚至支持嵌套调用,而且仅需要在最外层统一调用 dispose
即可
withScope(vm, (vm) => withScope(vm, (vm) => { console.log(vm.dump(vm.unwrapResult(vm.evalCode("1+1")))); }) ).dispose();
2.2 提供更好的创建 vm 值的方法
主要思路是判断创建 vm
变量的类型,自动调用相应的函数,然后返回创建的变量。
import { QuickJSHandle, QuickJSVm } from "quickjs-emscripten"; import { withScope } from "./withScope"; type MarshalValue = { value: QuickJSHandle; dispose: () => void }; /** * 简化使用 QuickJSVm 创建复杂对象的操作 * @param vm */ export function marshal(vm: QuickJSVm) { function marshal(value: (...args: any[]) => any, name: string): MarshalValue; function marshal(value: any): MarshalValue; function marshal(value: any, name?: string): MarshalValue { return withScope(vm, (vm) => { function _f(value: any, name?: string): QuickJSHandle { if (typeof value === "string") { return vm.newString(value); } if (typeof value === "number") { return vm.newNumber(value); } if (typeof value === "boolean") { return vm.unwrapResult(vm.evalCode(`${value}`)); } if (value === undefined) { return vm.undefined; } if (value === null) { return vm.null; } if (typeof value === "bigint") { return vm.unwrapResult(vm.evalCode(`BigInt(${value})`)); } if (typeof value === "function") { return vm.newFunction(name!, value); } if (typeof value === "object") { if (Array.isArray(value)) { const _array = vm.newArray(); value.forEach((v) => { if (typeof v === "function") { throw new Error("数组中禁止包含函数,因为无法指定名字"); } vm.callFunction(vm.getProp(_array, "push"), _array, _f(v)); }); return _array; } if (value instanceof Map) { const _map = vm.unwrapResult(vm.evalCode("new Map()")); value.forEach((v, k) => { vm.unwrapResult( vm.callFunction(vm.getProp(_map, "set"), _map, _f(k), _f(v, k)) ); }); return _map; } const _object = vm.newObject(); Object.entries(value).forEach(([k, v]) => { vm.setProp(_object, k, _f(v, k)); }); return _object; } throw new Error("不支持的类型"); } return _f(value, name); }); } return marshal; }
使用
const mockHello = jest.fn(); const now = new Date(); const { value, dispose } = marshal(vm)({ name: "liuli", age: 1, sex: false, hobby: [1, 2, 3], account: { username: "li", }, hello: mockHello, map: new Map().set(1, "a"), date: now, }); vm.setProp(vm.global, "vm_global", value); dispose(); function evalCode(code: string) { return vm.unwrapResult(vm.evalCode(code)).consume(vm.dump.bind(vm)); } expect(evalCode("vm_global.name")).toBe("liuli"); expect(evalCode("vm_global.age")).toBe(1); expect(evalCode("vm_global.sex")).toBe(false); expect(evalCode("vm_global.hobby")).toEqual([1, 2, 3]); expect(new Date(evalCode("vm_global.date"))).toEqual(now); expect(evalCode("vm_global.account.username")).toEqual("li"); evalCode("vm_global.hello()"); expect(mockHello.mock.calls.length).toBe(1); expect(evalCode("vm_global.map.size")).toBe(1); expect(evalCode("vm_global.map.get(1)")).toBe("a");
目前支持的类型与 JavaScript 结构化克隆算法 对比,后者在很多地方(iframe/web worker/worker_threads
)均有使用
对象类型 | quickjs | 结构化克隆 | 注意 |
---|---|---|---|
所有的原始类型 | ✔ | ❌ | symbols 除外 |
Function | ✔ | ✔ | |
Array | ✔ | ✔ | |
Object | ✔ | ✔ | 仅包括普通对象(如对象字面量) |
Map | ✔ | ✔ | |
Set | ✔ | ✔ | |
Date | ✔ | ✔ | |
Error | ❌ | ❌ | |
Boolean | ❌ | ✔ | 对象 |
String | ❌ | ✔ | 对象 |
RegExp | ❌ | ✔ | lastIndex 字段不会被保留。 |
Blob | ❌ | ✔ | |
File | ❌ | ✔ | |
FileList | ❌ | ✔ | |
ArrayBuffer | ❌ | ✔ | |
ArrayBufferView | ❌ | ✔ | 这基本上意味着所有的类型化数组 |
ImageData | ❌ | ✔ |
以上不支持的非常见类型并非 quickjs 不支持,仅仅是 marshal 暂未支持。
3、实现 console/setTimeout/setInterval 等常见 api
由于 console/setTimeout/setInterval
均不是 js 语言级别的 api(但是浏览器、nodejs 均实现了),所以吾辈必须手动实现并注入它们。
3.1 实现 console
基本思路:为 vm 注入全局 console 对象,将参数 dump 之后转发到真正的 console api
import { QuickJSVm } from "quickjs-emscripten"; import { marshal } from "../util/marshal"; export interface IVmConsole { log(...args: any[]): void; info(...args: any[]): void; warn(...args: any[]): void; error(...args: any[]): void; } /** * 定义 vm 中的 console api * @param vm * @param logger */ export function defineConsole(vm: QuickJSVm, logger: IVmConsole) { const fields = ["log", "info", "warn", "error"] as const; const dump = vm.dump.bind(vm); const { value, dispose } = marshal(vm)( fields.reduce((res, k) => { res[k] = (...args: any[]) => { logger[k](...args.map(dump)); }; return res; }, {} as Record<string, Function>) ); vm.setProp(vm.global, "console", value); dispose(); } export class BasicVmConsole implements IVmConsole { error(...args: any[]): void { console.error(...args); } info(...args: any[]): void { console.info(...args); } log(...args: any[]): void { console.log(...args); } warn(...args: any[]): void { console.warn(...args); } }
使用
defineConsole(vm, new BasicVmConsole());
3.2 实现 setTimeout
基本思路:
基于 quickjs 实现 setTimeout 与 clearTimeout
为 vm 注入全局 setTimeout/clearTimeout
函数
setTimeout
- 将传过来的
callbackFunc
注册为 vm 全局变量 - 在系统层执行
setTimeout
- 将
clearTimeoutId => timeoutId
写到 map,返回一个clearTimeoutId
- 执行刚刚注册的全局 vm 变量,并清除回调
clearTimeout: 根据 clearTimeoutId
在系统层调用真实的 clearTimeout
不直接返回 setTimeout 返回值的原因在于在 nodejs 中返回值是一个对象而非一个数字,所以需要使用 map 兼容
import { QuickJSVm } from "quickjs-emscripten"; import { withScope } from "../util/withScope"; import { VmSetInterval } from "./defineSetInterval"; import { deleteKey } from "../util/deleteKey"; import { CallbackIdGenerator } from "@webos/ipc-main"; /** * 注入 setTimeout 方法 * 需要在注入后调用 {@link defineEventLoop} 让 vm 的事件循环跑起来 * @param vm */ export function defineSetTimeout(vm: QuickJSVm): VmSetInterval { const callbackMap = new Map<string, any>(); function clear(id: string) { withScope(vm, (vm) => { deleteKey( vm, vm.unwrapResult(vm.evalCode(`VM_GLOBAL.setTimeoutCallback`)), id ); }).dispose(); clearInterval(callbackMap.get(id)); callbackMap.delete(id); } withScope(vm, (vm) => { const vmGlobal = vm.getProp(vm.global, "VM_GLOBAL"); if (vm.typeof(vmGlobal) === "undefined") { throw new Error("VM_GLOBAL 不存在,需要先执行 defineVmGlobal"); } vm.setProp(vmGlobal, "setTimeoutCallback", vm.newObject()); vm.setProp( vm.global, "setTimeout", vm.newFunction("setTimeout", (callback, ms) => { const id = CallbackIdGenerator.generate(); //此处已经是异步了,必须再包一层 withScope(vm, (vm) => { const callbacks = vm.unwrapResult( vm.evalCode("VM_GLOBAL.setTimeoutCallback") ); vm.setProp(callbacks, id, callback); //此处还是异步的,必须再包一层 const timeout = setTimeout( () => withScope(vm, (vm) => { const callbacks = vm.unwrapResult( vm.evalCode(`VM_GLOBAL.setTimeoutCallback`) ); const callback = vm.getProp(callbacks, id); vm.callFunction(callback, vm.null); callbackMap.delete(id); }).dispose(), vm.dump(ms) ); callbackMap.set(id, timeout); }).dispose(); return vm.newString(id); }) ); vm.setProp( vm.global, "clearTimeout", vm.newFunction("clearTimeout", (id) => clear(vm.dump(id))) ); }).dispose(); return { callbackMap, clear() { [...callbackMap.keys()].forEach(clear); }, }; }
使用
const vmSetTimeout = defineSetTimeout(vm); withScope(vm, (vm) => { vm.evalCode(` const begin = Date.now() setInterval(() => { console.log(Date.now() - begin) }, 100) `); }).dispose(); vmSetTimeout.clear();
3.3 实现 setInterval
基本上,与实现 setTimeout
流程差不多
import { QuickJSVm } from "quickjs-emscripten"; import { withScope } from "../util/withScope"; import { deleteKey } from "../util/deleteKey"; import { CallbackIdGenerator } from "@webos/ipc-main"; export interface VmSetInterval { callbackMap: Map<string, any>; clear(): void; } /** * 注入 setInterval 方法 * 需要在注入后调用 {@link defineEventLoop} 让 vm 的事件循环跑起来 * @param vm */ export function defineSetInterval(vm: QuickJSVm): VmSetInterval { const callbackMap = new Map<string, any>(); function clear(id: string) { withScope(vm, (vm) => { deleteKey( vm, vm.unwrapResult(vm.evalCode(`VM_GLOBAL.setTimeoutCallback`)), id ); }).dispose(); clearInterval(callbackMap.get(id)); callbackMap.delete(id); } withScope(vm, (vm) => { const vmGlobal = vm.getProp(vm.global, "VM_GLOBAL"); if (vm.typeof(vmGlobal) === "undefined") { throw new Error("VM_GLOBAL 不存在,需要先执行 defineVmGlobal"); } vm.setProp(vmGlobal, "setIntervalCallback", vm.newObject()); vm.setProp( vm.global, "setInterval", vm.newFunction("setInterval", (callback, ms) => { const id = CallbackIdGenerator.generate(); //此处已经是异步了,必须再包一层 withScope(vm, (vm) => { const callbacks = vm.unwrapResult( vm.evalCode("VM_GLOBAL.setIntervalCallback") ); vm.setProp(callbacks, id, callback); const interval = setInterval(() => { withScope(vm, (vm) => { vm.callFunction( vm.unwrapResult( vm.evalCode(`VM_GLOBAL.setIntervalCallback['${id}']`) ), vm.null ); }).dispose(); }, vm.dump(ms)); callbackMap.set(id, interval); }).dispose(); return vm.newString(id); }) ); vm.setProp( vm.global, "clearInterval", vm.newFunction("clearInterval", (id) => clear(vm.dump(id))) ); }).dispose(); return { callbackMap, clear() { [...callbackMap.keys()].forEach(clear); }, }; }
3.4 实现事件循环
但有一点麻烦的是,quickjs-emscripten
不会自动执行事件循环,即 Promise
在 resolve
之后不会自动执行下一步。官方提供了 executePendingJobs
方法让我们手动执行事件循环,如下所示
const { log } = defineMockConsole(vm); withScope(vm, (vm) => { vm.evalCode(`Promise.resolve().then(()=>console.log(1))`); }).dispose(); expect(log.mock.calls.length).toBe(0); vm.executePendingJobs(); expect(log.mock.calls.length).toBe(1);
所以我们实现可以使用一个自动调用 executePendingJobs
的函数
import { QuickJSVm } from "quickjs-emscripten"; export interface VmEventLoop { clear(): void; } /** * 定义 vm 中的事件循环机制,尝试循环执行等待的异步操作 * @param vm */ export function defineEventLoop(vm: QuickJSVm) { const interval = setInterval(() => { vm.executePendingJobs(); }, 100); return { clear() { clearInterval(interval); }, }; }
现在只要调用 defineEventLoop
即会循环执行 executePendingJobs
函数了
const { log } = defineMockConsole(vm); const eventLoop = defineEventLoop(vm); try { withScope(vm, (vm) => { vm.evalCode(`Promise.resolve().then(()=>console.log(1))`); }).dispose(); expect(log.mock.calls.length).toBe(0); await wait(100); expect(log.mock.calls.length).toBe(1); } finally { eventLoop.clear(); }
4、实现沙箱与系统之间的通信
现在,我们沙箱还欠缺的就是通信机制了,下面我们便实现一个 EventEmiiter
。
核心是让系统层和沙箱都实现 EventEmitter
,quickjs
允许我们向沙箱中注入方法,所以我们可以注入一个 Map 和 emitMain
函数。让沙箱既能够向 Map 中注册事件以供系统层调用,也能通过 emitMain
向系统层发送事件。
沙箱与系统之间的通信:
import { QuickJSHandle, QuickJSVm } from "quickjs-emscripten"; import { marshal } from "../util/marshal"; import { withScope } from "../util/withScope"; import { IEventEmitter } from "@webos/ipc-main"; export type VmMessageChannel = IEventEmitter & { listenerMap: Map<string, ((msg: any) => void)[]>; }; /** * 定义消息通信 * @param vm */ export function defineMessageChannel(vm: QuickJSVm): VmMessageChannel { const res = withScope(vm, (vm) => { const vmGlobal = vm.getProp(vm.global, "VM_GLOBAL"); if (vm.typeof(vmGlobal) === "undefined") { throw new Error("VM_GLOBAL 不存在,需要先执行 defineVmGlobal"); } const listenerMap = new Map<string, ((msg: string) => void)[]>(); const messagePort = marshal(vm)({ //region vm 进程回调函数定义 listenerMap: new Map(), //给 vm 进程用的 emitMain(channel: QuickJSHandle, msg: QuickJSHandle) { const key = vm.dump(channel); const value = vm.dump(msg); if (!listenerMap.has(key)) { console.log("主进程没有监听 api: ", key, value); return; } listenerMap.get(key)!.forEach((fn) => { try { fn(value); } catch (e) { console.error("执行回调函数发生错误: ", e); } }); }, //endregion }); vm.setProp(vmGlobal, "MessagePort", messagePort.value); //给主进程用的 function emitVM(channel: string, msg: string) { withScope(vm, (vm) => { const _map = vm.unwrapResult( vm.evalCode("VM_GLOBAL.MessagePort.listenerMap") ); const _get = vm.getProp(_map, "get"); const _array = vm.unwrapResult( vm.callFunction(_get, _map, vm.newString(channel)) ); if (!vm.dump(_array)) { return; } for ( let i = 0, length = vm.dump(vm.getProp(_array, "length")); i < length; i++ ) { vm.callFunction( vm.getProp(_array, vm.newNumber(i)), vm.null, marshal(vm)(msg).value ); } }).dispose(); } return { emit: emitVM, offByChannel(channel: string): void { listenerMap.delete(channel); }, on(channel: string, handle: (data: any) => void): void { if (!listenerMap.has(channel)) { listenerMap.set(channel, []); } listenerMap.get(channel)!.push(handle); }, listenerMap, } as VmMessageChannel; }); res.dispose(); return res.value; }
可以看到,我们除了实现了 IEventEmitter,还额外添加了字段 listenerMap,这主要是希望向上层暴露更多细节,便于在需要的时候(例如清理全部注册的事件)可以直接实现。
使用
defineVmGlobal(vm); const messageChannel = defineMessageChannel(vm); const mockFn = jest.fn(); messageChannel.on("hello", mockFn); withScope(vm, (vm) => { vm.evalCode(` class QuickJSEventEmitter { emit(channel, data) { VM_GLOBAL.MessagePort.emitMain(channel, data); } on(channel, handle) { if (!VM_GLOBAL.MessagePort.listenerMap.has(channel)) { VM_GLOBAL.MessagePort.listenerMap.set(channel, []); } VM_GLOBAL.MessagePort.listenerMap.get(channel).push(handle); } offByChannel(channel) { VM_GLOBAL.MessagePort.listenerMap.delete(channel); } } const em = new QuickJSEventEmitter() em.emit('hello', 'liuli') `); }).dispose(); expect(mockFn.mock.calls[0][0]).toBe("liuli"); messageChannel.listenerMap.clear();
5、实现 IJavaScriptShadowbox
最终,我们以上实现的功能集合起来,便实现了 IJavaScriptShadowbox
import { IJavaScriptShadowbox } from "./IJavaScriptShadowbox"; import { getQuickJS, QuickJS, QuickJSVm } from "quickjs-emscripten"; import { BasicVmConsole, defineConsole, defineEventLoop, defineMessageChannel, defineSetInterval, defineSetTimeout, defineVmGlobal, VmEventLoop, VmMessageChannel, VmSetInterval, withScope, } from "@webos/quickjs-emscripten-utils"; export class QuickJSShadowbox implements IJavaScriptShadowbox { private vmMessageChannel: VmMessageChannel; private vmEventLoop: VmEventLoop; private vmSetInterval: VmSetInterval; private vmSetTimeout: VmSetInterval; private constructor(readonly vm: QuickJSVm) { defineConsole(vm, new BasicVmConsole()); defineVmGlobal(vm); this.vmSetTimeout = defineSetTimeout(vm); this.vmSetInterval = defineSetInterval(vm); this.vmEventLoop = defineEventLoop(vm); this.vmMessageChannel = defineMessageChannel(vm); } destroy(): void { this.vmMessageChannel.listenerMap.clear(); this.vmEventLoop.clear(); this.vmSetInterval.clear(); this.vmSetTimeout.clear(); this.vm.dispose(); } eval(code: string): void { withScope(this.vm, (vm) => { vm.unwrapResult(vm.evalCode(code)); }).dispose(); } emit(channel: string, data?: any): void { this.vmMessageChannel.emit(channel, data); } on(channel: string, handle: (data: any) => void): void { this.vmMessageChannel.on(channel, handle); } offByChannel(channel: string) { this.vmMessageChannel.offByChannel(channel); } private static quickJS: QuickJS; static async create() { if (!QuickJSShadowbox.quickJS) { QuickJSShadowbox.quickJS = await getQuickJS(); } return new QuickJSShadowbox(QuickJSShadowbox.quickJS.createVm()); } static destroy() { QuickJSShadowbox.quickJS = null as any; } }
在系统层使用
const shadowbox = await QuickJSShadowbox.create(); const mockConsole = defineMockConsole(shadowbox.vm); shadowbox.eval(code); shadowbox.emit(AppChannelEnum.Open); expect(mockConsole.log.mock.calls[0][0]).toBe("open"); shadowbox.emit(WindowChannelEnum.AllClose); expect(mockConsole.log.mock.calls[1][0]).toBe("all close"); shadowbox.destroy();
在沙箱使用
const eventEmitter = new QuickJSEventEmitter(); eventEmitter.on(AppChannelEnum.Open, async () => { console.log("open"); }); eventEmitter.on(WindowChannelEnum.AllClose, async () => { console.log("all close"); });
6、目前 quickjs 沙箱的限制
下面是目前实现的一些限制,也是以后可以继续改进的点
console 仅支持常见的 log/info/warn/error 方法
setTimeout/setInterval 事件循环时间没有保证,目前大约在 100ms 调用一次
无法使用 chrome devtool 调试,也不会处理 sourcemap(figma 至今的开发体验仍然如此,后面可能添加开关支持在 web worker 中调试)
vm 中出现错误不会将错误抛出来并打印在控制台
各个 api 调用的顺序与清理顺序必须手动保证是相反的,例如 vm 创建必须在 defineSetTimeout 之前,而 defineSetTimeout 的清理函数调用必须在 vm.dispose 之前
不能在 messageChannel.on 回调中同步调用 vm.dispose,因为是同步调用的
加载全部内容