场景

在前文 JavaScript 沙箱探索 中声明了沙箱的接口,并且给出了一些简单的执行任意第三方 js 脚本的代码,但并未实现完整的 IJavaScriptShadowbox,下面便讲一下如何基于 quickjs 实现它。

quickjs 在 js 的封装库是 quickjs-emscriptenopen in new window,基本原理是将 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>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

下面是一段官方的代码示例

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();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

可以看到,创建 vm 中的变量后还必须留意调用 dispose,有点像是后端连接数据库时必须注意关闭连接,而这其实是比较繁琐的,尤其是在复杂的情况下。简而言之,它的 api 太过于底层了。在 github issue 中有人创建了 quickjs-emscripten-syncopen in new window,这给了吾辈很多灵感,所以吾辈基于 quickjs-emscripten 封装了一些工具函数,辅助而非替代它。

简化底层 api

主要目的有两个

  • 自动调用 dispose
  • 提供更好的创建 vm 值的方法

自动调用 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 };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

使用

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();
1
2
3
4
5
6
7
8

甚至支持嵌套调用,而且仅需要在最外层统一调用 dispose 即可

withScope(vm, (vm) =>
  withScope(vm, (vm) => {
    console.log(vm.dump(vm.unwrapResult(vm.evalCode("1+1"))));
  })
).dispose();
1
2
3
4
5

提供更好的创建 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

使用

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");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

目前支持的类型与 JavaScript 结构化克隆算法open in new window 对比,后者在很多地方(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 等常见 api

由于 console/setTimeout/setInterval 均不是 js 语言级别的 api(但是浏览器、nodejs 均实现了),所以吾辈必须手动实现并注入它们。

实现 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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

使用

defineConsole(vm, new BasicVmConsole());
1

实现 setTimeout

基本思路

基于 quickjs 实现 setTimeout 与 clearTimeout.drawio.svg

  1. 为 vm 注入全局 setTimeout/clearTimeout 函数
  2. setTimeout
    1. 将传过来的 callbackFunc 注册为 vm 全局变量
    2. 在系统层执行 setTimeout
    3. clearTimeoutId => timeoutId 写到 map,返回一个 clearTimeoutId
    4. 执行刚刚注册的全局 vm 变量,并清除回调
  3. 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);
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

使用

const vmSetTimeout = defineSetTimeout(vm);
withScope(vm, (vm) => {
  vm.evalCode(`
      const begin = Date.now()
      setInterval(() => {
        console.log(Date.now() - begin)
      }, 100)
    `);
}).dispose();
vmSetTimeout.clear();
1
2
3
4
5
6
7
8
9
10

实现 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);
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

实现事件循环

但有一点麻烦的是,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);
1
2
3
4
5
6
7

所以我们实现可以使用一个自动调用 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);
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

现在只要调用 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();
}
1
2
3
4
5
6
7
8
9
10
11
12

实现沙箱与系统之间的通信

现在,我们沙箱还欠缺的就是通信机制了,下面我们便实现一个 EventEmiiter

核心是让系统层和沙箱都实现 EventEmitter,quickjs 允许我们向沙箱中注入方法,所以我们可以注入一个 Map 和 emitMain 函数。让沙箱既能够向 Map 中注册事件以供系统层调用,也能通过 emitMain 向系统层发送事件。

沙箱与系统之间的通信.drawio.svg

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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

可以看到,我们除了实现了 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();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

实现 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;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

在系统层使用

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();
1
2
3
4
5
6
7
8

在沙箱使用

const eventEmitter = new QuickJSEventEmitter();
eventEmitter.on(AppChannelEnum.Open, async () => {
  console.log("open");
});
eventEmitter.on(WindowChannelEnum.AllClose, async () => {
  console.log("all close");
});
1
2
3
4
5
6
7

目前 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,因为是同步调用的