在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
1、场景在前文JavaScript 沙箱探索 中声明了沙箱的接口,并且给出了一些简单的执行任意第三方 js 脚本的代码,但并未实现完整的
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 中的变量后还必须留意调用 2、简化底层 api主要目的有两个:
2.1自动调用 dispose
|
对象类型 | 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,因为是同步调用的
到此这篇关于 quickjs 封装 JavaScript 沙箱详情的文章就介绍到这了,更多相关 quickjs 封装 JavaScript 沙箱内容请搜索极客世界以前的文章或继续浏览下面的相关文章希望大家以后多多支持极客世界!
请发表评论