Running JavaScript Engine in WebAssembly

發表於
分類於 javascript

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

I believe many people would think I'm crazy when they see this title, and I think so too = =

Anyway, I was bored and had a wild idea about running a small JS engine in WebAssembly, and I impulsively went ahead with it. But the good thing is I learned how to use Emscripten.

The current project I came up with is this: wasm-jseval, which offers two engines to choose from: Duktape and QuickJS.

As for how to use it, you can just check the README, so this article is just to document how I did it.

Environment Setup

I did this on Ubuntu 18.04 under WSL. Basically, you just need make and Emscripten's toolchain.

For installing Emscripten, you can refer to the official tutorial: https://emscripten.org/docs/getting\_started/downloads.html But for convenience, I'll also include the commands I used below.

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest # 下載和安裝
./emsdk activate latest # 啟用,這個會寫入 ~/.emscripten
source ./emsdk_env.sh # 幫你把 PATH 設定好
emcc # 測試有沒有安裝成功

Duktape's eval

Download

Go to Duktape's download page, choose the version you want, and download and extract it to your desired location. For example, I used version 2.5.0.

wget https://duktape.org/duktape-2.5.0.tar.xz
tar xf duktape-2.5.0.tar.xz
cd duktape-2.5.0

Write a simple eval binding

My goal is to create a simple eval function that can be called from the JS side, so we need to write a simple binding in C. As for how to write it, you can refer to the tutorials and documents on the Duktape website.

#include "./src/duktape.h" // 你這個檔案到 duktape.h 的相對路徑

const char* eval(const char* js_code) {
	duk_context* ctx = duk_create_heap_default();
	duk_push_string(ctx, js_code);
	duk_int_t rc = duk_peval(ctx);
	if (rc != 0) { // 如果執行失敗
		duk_safe_to_stacktrace(ctx, -1);
		const char* stacktrace = duk_get_string(ctx, -1);
		duk_destroy_heap(ctx);
		return stacktrace; // 取得 stacktrace 後傳回去
	}
	const char* json = duk_json_encode(ctx, -1);
	duk_destroy_heap(ctx);
	return json; // 成功就把值用 json encode 起來傳回去
}

Compile

Since Duktape itself is very simple to compile, and emcc's usage is similar to gcc, the simplest way to compile it is like this. The output format is set to html for easy testing.

# eval.c 是你上面那個 binding 檔案的名稱
emcc eval.c ./src/duktape.c -lm -o eval.html

Then use a simple web server like httpsrv or Python's built-in server to create a server in the current directory, and browse to eval.html on the server with your browser. Open the browser's devtool console and type Module to see if it works.

However, we haven't exported the necessary functions yet, so we need to add the required parameters to export them for JS usage.

emcc eval.c ./src/duktape.c -o eval.html -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

Then refresh the page and go back to the console, type Module.cwrap('eval', 'string', ['string'])('1+1'), and it should return a string "2", indicating success. The cwrap function is a convenient way to call C functions from JS, handling type conversion for you.

However, this results in two files, eval.js and eval.wasm, which is inconvenient as a library. But Emscripten supports converting it to base64 and embedding it in JS with the -s SINGLE_FILE=1 option, though this makes the file larger. So, add some optimization parameters like -Oz for better results.

The following is my final compile command, which produces a single eval.js file in UMD format, allowing it to be imported in node.js. As for how to package it as a library, you can check the code yourself.

emcc -o eval.js eval.c ../duktape/src/duktape.c -lm -Oz --closure 1 -s WASM=1 -s SINGLE_FILE=1 -s AGGRESSIVE_VARIABLE_ELIMINATION -s MODULARIZE=1 -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

The EVALJS at the top of src/index.js will be automatically replaced by the bundler, think of it as the eval.js we just compiled.

QuickJS's eval

Download

No need to elaborate here, download it from here. I used the 2019-12-21 version.

wget https://bellard.org/quickjs/quickjs-2019-12-21.tar.xz
tar xf quickjs-2019-12-21.tar.gz
cd quickjs-2019-12-21

Write a simple eval binding

Since its documentation is sparse, many functions need to be looked up in the provided quickjs.h, but it's actually easier to use than Duktape.

#include <string.h>
#include "./quickjs.h"

const char* eval(const char* str) {
	JSRuntime* runtime = JS_NewRuntime();
	JSContext* ctx = JS_NewContext(runtime);
	JSValue result = JS_Eval(ctx, str, strlen(str), "<evalScript>", JS_EVAL_TYPE_GLOBAL); // 執行
	if (JS_IsException(result)) {
		JSValue realException = JS_GetException(ctx);
		return JS_ToCString(ctx, realException); // 如果是 Exception 就取得 string 回傳
	}
	JSValue json = JS_JSONStringify(ctx, result, JS_UNDEFINED, JS_UNDEFINED);
	JS_FreeValue(ctx, result);
	return JS_ToCString(ctx, json); // 成功就一樣 json encode 回傳
}

Compile

Since it's not a single file, if you encounter missing symbols, just search for which file they are in and add them to the compile command. One thing to note is that you need to define the CONFIG_VERSION macro yourself, otherwise, it won't compile because it's used internally.

emcc -o eval.html eval.c ./quickjs.c ./cutils.c ./libregexp.c ./libbf.c ./libunicode.c -DCONFIG_VERSION="\"1.0.0\"" -lm -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

Again, start a server and use a browser to go to eval.html, then try Module.cwrap('eval', 'string', ['string'])('1+1') in the console, and it should return "2".

Next, package it into a single file, optimize it, etc., just like above. The final compile command looks like this:

emcc -o eval.js eval.c ./quickjs.c ./cutils.c ./libregexp.c ./libbf.c ./libunicode.c -DCONFIG_VERSION="\"1.0.0\"" -s WASM=1 -s SINGLE_FILE -s MODULARIZE=1 -lm -Oz -s EXPORTED_FUNCTIONS='["_eval"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' --llvm-lto 3 -s AGGRESSIVE_VARIABLE_ELIMINATION=1 --closure 1

The code to package it as a library is actually identical to the code for packaging the Duktape version, as I designed the binding interface exactly the same.