时间:2022-09-08 09:01:42 | 栏目:JavaScript代码 | 点击:次
在前文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 封装了一些工具函数,辅助而非替代它。
主要目的有两个:
dispose
vm
值的方法主要思路是自动收集所有需要调用 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();
主要思路是判断创建 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 暂未支持。
由于 console/setTimeout/setInterval
均不是 js 语言级别的 api(但是浏览器、nodejs 均实现了),所以吾辈必须手动实现并注入它们。
基本思路:为 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());
基本思路:
基于 quickjs 实现 setTimeout 与 clearTimeout
为 vm 注入全局 setTimeout/clearTimeout
函数
setTimeout
callbackFunc
注册为 vm 全局变量 setTimeout
clearTimeoutId => timeoutId
写到 map,返回一个 clearTimeoutId
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();
基本上,与实现 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); }, }; }
但有一点麻烦的是,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(); }
现在,我们沙箱还欠缺的就是通信机制了,下面我们便实现一个 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();
最终,我们以上实现的功能集合起来,便实现了 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"); });
下面是目前实现的一些限制,也是以后可以继续改进的点
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,因为是同步调用的