How do you let users input and run JavaScript code reliably in your Go web server? Motivated by a memory hog issue that I spent two days debugging, I looked into how someone can run JavaScript code in a Go web server, with the possibility of tracking resources.

Letting users write code that will be run by your server imposes security risks and can affect the reliability of a service. Someone can write code that exhausts your server resources (e.g. infinite loops, allocating too much memory) or can inject malicious code. Having this in mind, I want a solution that:

First, I thought about simply writing a complete new service in JavaScript where I already have some nice libraries to run JS in isolation, e.g. isolated-vm (with some caveats as usual). The services can be connected through an API. However, I did not go ahead with this solution because there is more work and maintenance to be done going forward. A completely different service written in another language is needed, which involves different dependencies, different tooling and expertise for a production ready service.

Next, I took into consideration using a JavaScript engine such as V8 and using wrapper Go code. That is what the v8go library does. It has prebuilt binaries packed with the library, so there’s no need to do any more work to embed the V8 engine. It also supports profiling and exposes statistics about resource usage. The downside is that it uses cgo and the library also has an old version of V8 embedded (at the time of this writing, April 2021).

Another approach I found was using goja, a ECMAScript 5.1 implementation written in pure Go. It is a stable JS engine and the only dependencies are Go packages and it doesn’t use cgo. Going through the docs and a few GitHub issues I found out that it there aren’t enough features to limit and observe resource usage. A solution to this would be to run it in a container and limit resources through the container runtime, but I found a more appealing solution for my requirements, and that is WebAssembly modules.

How can JavaScript code run in WebAssembly? Well, there isn’t any compiler that can output reliable wasm from JS (at least not a production ready one - Porffor) because of the dynamic nature of the language. However, a workaround is to compile a JavaScript runtime (which are usually written in C/C++) down to a WebAssembly module and then pass the JavaScript code to the WebAssembly module to be executed. A lightweight engine that found for this is QuickJS. The QuickJS engine is well-maintained and is small compared to other engines such as V8 or SpiderMonkey which pack a lot of functionalities that are not needed for small scripts at least. Compiling the QuickJS engine to WebAssembly approach is used by Figma to run plugins securely so there is a solid production use case to validate this idea.

For executing the WebAssembly module, there are a few solid WebAssembly runtimes that can be used. I decided to go with the wazero Go package. It is a WebAssembly runtime written in Go, thus dependencies are kept to a minimum. No CGO involved, maintains cross-compilation (at least for interpretation mode) and it has enough options to expose metrics. Also, QuickJS itself exposes some levers that can be used to control resource usage.

To sum up:

A quick example on how this would look in Go is:

 1package main
 2
 3import (
 4	"context"
 5	"fmt"
 6	"log"
 7	"os"
 8
 9	"github.com/tetratelabs/wazero"
10	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
11)
12
13func main() {
14	wasmPath := "./wasm/qjs.wasm"
15	wasm, err := os.ReadFile(wasmPath)
16	if err != nil {
17		log.Fatalf("read qjs.wasm: %v", err)
18	}
19
20	rtc := wazero.NewRuntimeConfig().
21		WithDebugInfoEnabled(true)
22
23	ctx := context.Background()
24	rt := wazero.NewRuntimeWithConfig(ctx, rtc)
25	defer rt.Close(ctx)
26
27	wasi_snapshot_preview1.MustInstantiate(ctx, rt)
28
29	jsCode, err := os.ReadFile("./js/console-hello.js")
30	if err != nil {
31		log.Fatalf("read js code: %v", err)
32	}
33
34	qjsModConfig := wazero.NewModuleConfig().
35		WithName("qjs").
36		WithStdout(os.Stdout).
37		WithStderr(os.Stderr).
38		WithStdin(os.Stdin).
39		WithArgs([]string{"qjs", "-e", string(jsCode)}...)
40
41	qjsMod, err := rt.CompileModule(ctx, wasm)
42	if err != nil {
43		fmt.Fprintf(os.Stderr, "error compiling quickjs wasm: %v\n", err)
44		return
45	}
46	defer qjsMod.Close(ctx)
47
48	qjs, err := rt.InstantiateModule(ctx, qjsMod, qjsModConfig)
49	if err != nil {
50		fmt.Fprintf(os.Stderr, "error instantiating quickjs wasm: %v\n", err)
51		return
52	}
53	defer qjs.Close(ctx)
54}

Compiling QuickJS down to a WebAssembly module

There are two widely used flavors of QuickJS - the original one written by Bellard and a fork from the original called QuickJS Next Generation (QuickJS-NG). The fork has better support for WebAssembly and they even release a wasm module. The simple way is to just download it from their website.

If you want to build it yourself, you need to fetch the wasi-sdk for your platform. WASI is a layer for translating the OS-specific calls (such as memory allocation or file system handling operations). Without it, you cannot use WebAssembly outside the browser because the runtime doesn’t know what to do with those calls.

You cannot just get the C code from an application and wasi-sdk then compile the app to wasm. Some OS-specific calls do not have a 1:1 mapping in WASI and you’ll need to make the binding yourself in the C code. That’s why QuickJS-NG is easier to compile than the original QuickJS, because it handles these cases and you don’t have to modify the code yourself.

If you want to build the wasm module yourself, download the wasi-sdk in your project path for your machine. Mine is an arm64 macos:

1export WASI_SDK_VERSION=27 WASI_SDK_ARCH=arm64 WASI_SDK_OS=macos
2curl -sSL https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${WASI_SDK_ARCH}-${WASI_SDK_OS}.tar.gz -o wasi-sdk.tar.gz
3tar -xzf wasi-sdk.tar.gz

Clone the quickjs-ng repository:

1git clone https://github.com/quickjs-ng/quickjs.git

Build the wasm modules using the wasi-sdk toolchain. I'm using the p1 interface for WASI because that is what wazero supports at the time of this writing.

1cd quickjs
2cmake -DCMAKE_TOOLCHAIN_FILE="../wasi-sdk-27.0-arm64-macos/share/cmake/wasi-sdk-p1.cmake"
3make qjs qjsc qjs_exe

Check if the output files are wasm modules:

1file qjs
2> qjs: WebAssembly (wasm) binary module version 0x1 (MVP)

Install wazero in your project path to test the wasm module:

1cd ..
2curl -fsSL https://wazero.io/install.sh | sh

Run the qjs wasm module using wazero, you should see the qjs shell:

1./bin/wazero run ./quickjs/qjs
2QuickJS-ng - Type ".help" for help
3qjs > 

Write a sample JS script and save it as a hello.js file:

1echo 'console.log("it works!")' > hello.js

To run the script, mount the current directory so the wasm runtime can read it:

1./bin/wazero run -mount=. ./wasm/qjs.wasm -e hello.js

That’s it! Check out the wazero docs to see how you can use this in your go code.

Resource limiting and monitoring

There are multiple options that can be used to limit resources. First, you can set memory limits using WithMemoryLimitPages andWithMemoryCapacityFromMaxexposed by wazero. Use context timeouts and interrupts to control CPU limiting. Second, you have the memory limits you can set for QuickJS:

1--memory-limit n       limit the memory usage to 'n' Kbytes
2--stack-size n         limit the stack size to 'n' Kbytes

For tracing, QuickJS has a few options too:

1-T  --trace        trace memory allocation
2-d  --dump         dump the memory usage stats
3-D  --dump-flags   flags for dumping debug data (see DUMP_* defines)

Final notes

This is not suitable for large JS apps. There is a cold start up cost that happens when loading the wasm module and I/O is limited given that the runtime is sandboxed. QuickJS is a limited JS environment compared to SpiderMonkey or v8.

References: