QNGG is a CGO-Free, modern, secure JavaScript runtime for Go applications, built on the QuickJS-NG engine and the Wasmtime WebAssembly runtime.
QNGG allows you to run JavaScript code safely and efficiently, with full support for ES2023 features, async/await, and Go-JS interoperability.
QNGG is a maintained fork of fastschema/qjs, which is no longer maintained. Compared to upstream it:
- Runs QuickJS on Wasmtime instead of Wazero. Wasmtime's C API is called through purego (a vendored port of wasmtime-go), so builds still need no cgo.
- Tracks the latest QuickJS-NG release (currently v0.17.0), built with wasi-sdk 34.
- Requires Go 1.27 and decodes JSON with
encoding/json/v2. - Is sandboxed by default: no filesystem, no output and interruptible calls unless configured otherwise.
- Has a smaller, redesigned API: one ownership rule for values,
context.Contexton every call that runs JavaScript, errors instead of silent failures and panics, andValue.As[T]()for conversions. - Fixes memory bugs inherited from upstream, such as leaked strings, property names and eval buffers, use-after-free in
JSONStringify, a double free inAwait, heap corruption when converting objects, and Go functions and ProxyValues that were never released. - Crosses the Go/WebAssembly boundary less often: Go functions are native QuickJS closures, type checks and property access take one call each, temporary buffers come from a per-runtime scratch area instead of malloc, and struct layouts are cached per type.
go test -bench . (Apple M1 Ultra) |
QJS on Wazero | QNGG |
|---|---|---|
| RuntimeNew | 17 ms | 0.40 ms |
| EvalFactorial | 97 ms | 52 ms (39 ms
10BC8
with DisableInterruption) |
| GoFunctionCall | 168 ms | 4.8 ms |
| ValueProperties | 503 µs | 88 µs |
Coming from fastschema/qjs? See the migration guide.
- JavaScript ES2023 via QuickJS-NG, including modules, async/await and top-level await.
- Sandboxed: QuickJS runs in its own WebAssembly instance and only gets the capabilities you grant.
- Interruptible: cancel a call's context or give it a deadline to stop runaway scripts, even
for (;;) {}. - Go-JS interoperability: convert structs, maps, slices, functions and channels both ways, or share Go values by reference with ProxyValues.
- No cgo: Wasmtime is loaded at runtime through purego, so builds work with
CGO_ENABLED=0and cross-compile without a C toolchain. - Fast startup: a new runtime takes well under a millisecond, so one per request or per tenant is affordable.
QNGG fits plugin systems, user-defined rules and formulas, templating, and running code you do not trust.
go get github.com/ironpark/qnggQNGG requires Go 1.27 or later. The Wasmtime native library is embedded, so there is nothing else to install (see Wasmtime native library).
The snippets below assume a runtime rt and a context ctx, and return
errors from the enclosing function.
package main
import (
"context"
"fmt"
"log"
"github.com/ironpark/qngg"
)
func main() {
rt, err := qngg.New(qngg.Config{})
if err != nil {
log.Fatal(err)
}
defer rt.Close()
result, err := rt.Eval(context.Background(), "main.js", `
const person = { name: "Alice", age: 30, city: "New York" };
Object.entries(person).map(([k, v]) => k + ": " + v).join(", ");
`)
if err != nil {
log.Fatal(err)
}
defer result.Free()
fmt.Println(result.String()) // name: Alice, age: 30, city: New York
}JavaScript values live in the QuickJS heap, not Go's, so the Go garbage collector cannot release them. One rule covers every method:
- Every
*Valuereturned to you is yours:Freeit when done. - Passing a value to a method never takes it over: you still free it.
- Values you do not own have a no-op
Free:Runtime.Global(), and the arguments andthisof aFunction, which are only valid until it returns.Clonethem to keep them longer. - A value returned from a
Functionis handed to JavaScript. Closefrees everything that is left, and makes further use of the runtime and its values fail withErrRuntimeClosed. Using a freed value fails withErrFreed.
user := rt.NewObject()
defer user.Free()
name := rt.NewString("Alice")
defer name.Free()
if err := user.Set("name", name); err != nil { // name is still yours
return err
}
tags, err := rt.Eval(ctx, "tags.js", `["admin", "editor"]`)
if err != nil {
return err
}
defer tags.Free()
if err := user.Set("tags", tags); err != nil {
return err
}
if err := user.Set("age", 30); err != nil { // Go values are converted
return err
}
keys, err := user.Keys() // [name tags age]
out, err := json.Marshal(user) // {"name":"Alice","tags":["admin","editor"],"age":30}Value.Type() names a value's type ("String", "Array", "Map",
"Object", ...), and the Is* methods test for one. String(), Int32(),
Float64() and friends convert like JavaScript does; use As for strict
conversions that report errors.
Set converts Go values, functions included, and assigns them to globals.
if err := rt.Set("add", func(a, b int) int { return a + b }); err != nil {
return err
}
err := rt.Set("lookup", func(ctx context.Context, id string) (string, error) {
if id == "" {
return "", errors.New("id is required")
}
return "user:" + id, nil
})
if err != nil {
return err
}
result, err := rt.Eval(ctx, "main.js", `lookup(String(add(40, 2)))`)
if err != nil {
return err
}
defer result.Free()
// result.String() == "user:42"How calls are converted:
- Arguments are converted to the parameter types with the same rules as
As. Missing arguments are zero values, extra ones are ignored, and variadic functions take the rest. An argument that does not convert, such as"x"for anint, throws an error into JavaScript. - A leading
context.Contextparameter receives the context of the call that is running the script. - A last
errorresult is thrown into JavaScript when it is not nil. One other result is returned as is, and several are returned as an array.
For direct access to JavaScript values, register a Function instead. Its
arguments belong to the caller; return an *Error to throw a specific error
type.
err := rt.SetFunc("describe", func(call *qngg.FunctionCall) (*qngg.Value, error) {
if len(call.Args()) == 0 {
return nil, &qngg.Error{Name: "TypeError", Message: "describe needs an argument"}
}
return call.Runtime().NewString(call.Arg(0).Type()), nil
})
// describe([1, 2]) === "Array"; describe() throws a TypeErrorA JavaScript exception is returned as an *Error with its name, message and
stack. Evaluating to an Error object without throwing it is not an error.
_, err := rt.Eval(ctx, "main.js", `JSON.parse("{")`)
var jsErr *qngg.Error
if errors.As(err, &jsErr) {
fmt.Println(jsErr.Name) // SyntaxError
fmt.Println(jsErr.Message) // Expected property name or '}' in JSON at position 1 ...
fmt.Println(jsErr.Stack) // the JavaScript stack trace, ending "at <eval> (main.js:1:5)"
}Other errors are sentinels to test with errors.Is: ErrInterrupted when a
call's context was done, ErrRuntimeClosed and ErrFreed when a runtime or
value is used after Close or Free. Methods without an error result, such
as Int32(), panic with these instead, except String(), which returns
<invalid qngg.Value>.
Every call that runs JavaScript takes a context. When it is canceled or its
deadline passes, the script is interrupted wherever it is, and the call fails
with ErrInterrupted. The runtime's state is then undefined, so every later
call fails too and the runtime must be closed; a Pool discards it for you.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := rt.Eval(ctx, "untrusted.js", `for (;;) {}`)
fmt.Println(errors.Is(err, qngg.ErrInterrupted)) // true
fmt.Println(errors.Is(err, context.DeadlineExceeded)) // trueInterruption checks make compute-heavy JavaScript about 30% slower. Set
Config.DisableInterruption if you trust the code and need the speed;
contexts are then only checked before a call starts.
Eval awaits the promise a script returns and runs pending jobs and timers.
Go functions registered with SetAsyncFunc look like async functions to
JavaScript: they return a promise that settles with the Go result or error.
rt.SetAsyncFunc("fetchUser", func(call *qngg.FunctionCall) (*qngg.Value, error) {
return call.Runtime().ToValue(map[string]any{"id": call.Arg(0).Int32(), "name": "Alice"})
})
result, err := rt.Eval(ctx, "main.js", `
const user = await fetchUser(1);
await new Promise(resolve => setTimeout(resolve, 10));
user.name;
`, qngg.TopLevelAwait())
// result.String() == "Alice"Value.Await waits for a promise you got from a call:
fn, _ := rt.Eval(ctx, "main.js", `async (x) => x * 2`)
defer fn.Free()
promise, _ := fn.Call(ctx, nil, 21)
defer promise.Free()
doubled, err := promise.Await(ctx)
// doubled.Int32() == 42calc, err := rt.Eval(ctx, "calc.js", `({
add: (a, b) => a + b,
fail: () => { throw new RangeError("out of range") },
})`)
if err != nil {
return err
}
defer calc.Free()
sum, err := calc.Invoke(ctx, "add", 1, 2) // arguments are converted
if err != nil {
return err
}
defer sum.Free()
fmt.Println(sum.Int32()) // 3
_, err = calc.Invoke(ctx, "fail")
var jsErr *qngg.Error
if errors.As(err, &jsErr) {
fmt.Println(jsErr.Name, jsErr.Message) // RangeError out of range
}
// Or convert a JS function into a typed Go function.
addFn, _ := calc.Get("add")
defer addFn.Free()
add, err := addFn.As[func(int, int) (int, error)]()
n, err := add(40, 2) // 42err := rt.LoadModule(ctx, "math.js", `
export const PI = 3.14159;
export default function square(x) { return x * x; }
`)
if err != nil {
return err
}
result, err := rt.Eval(ctx, "main.js", `
import square, { PI } from "math.js";
export default square(2) * PI;
`, qngg.AsModule())
// result.Float64() == 12.56636EvalFile and LoadModuleFile read files from Config.CWD or Config.FS.
Relative imports resolve against the importing module's path.
Set Config.FS to a qngg.FS to back both module loading and the
qjs:std/qjs:os file APIs with your own filesystem. To use an
afero backend (QNGG itself does not depend on
afero), wrap it with qngg.AdaptFS:
fsys := afero.NewMemMapFs()
if err := afero.WriteFile(fsys, "/input.txt", []byte("hello"), 0o644); err != nil {
return err
}
rt, err := qngg.New(qngg.Config{FS: qngg.AdaptFS(fsys), WritableFS: true})
if err != nil {
return err
}
defer rt.Close()
result, err := rt.Eval(ctx, "main.js", `
const file = std.open("/output.txt", "w");
file.puts(std.loadFile("/input.txt").toUpperCase());
file.close();
std.loadFile("/output.txt");
`)
if err != nil {
return err
}
defer result.Free()
// result.String() == "HELLO"; /output.txt is also visible to Go through fsys.AdaptFS accepts any qngg.FSBackend[F]; backends whose files are already
qngg.File can implement qngg.FS directly.
FSis mounted at/and is read-only unlessWritableFSis true. Backend permissions such asafero.NewReadOnlyFsstill apply.FSandCWDare mutually exclusive; useafero.NewBasePathFsto expose a subtree.- Symbolic links are unsupported and rejected when the backend can detect them.
OS-backed filesystems must not have path components concurrently replaced
with symbolic links; use
CWDwhen OS-enforced confinement is required. - Regular files are always ready for file readiness handlers. Only modification time is stored, so it is reported for all three WASI timestamps.
- Closing a runtime closes its open file handles but leaves the filesystem and
output writers to the caller. A filesystem shared by a
Poolmust support concurrent access, asMemMapFsdoes.
Compile once, then run the bytecode in any runtime, skipping parsing:
bytecode, err := rt.Compile("fib.js", `
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
fib(20);
`)
if err != nil {
return err
}
result, err := other.EvalBytecode(ctx, bytecode)
// result.Int32() == 6765Pass AsModule() to both Compile and EvalBytecode for modules, or load
module bytecode with LoadModuleBytecode.
ToValue (and Set) converts Go values to JavaScript, and Value.As[T]()
converts back to the Go type you ask for:
| Go | JavaScript |
|---|---|
bool, numbers, string |
boolean, number, string |
nil, nil pointers, maps and slices |
null |
time.Time |
Date |
*big.Int |
BigInt (As also converts BigInts to integer types that fit) |
[]byte |
ArrayBuffer (As also reads typed arrays and DataViews) |
| slices and arrays | Array (As also reads Sets) |
| maps | object (As also reads Maps) |
| structs | object with the exported fields, named by their json tags, and methods |
| functions | function, see Go Functions |
error |
Error |
*Value |
the value itself (cloned) |
any value wrapped by NewProxyValue |
an opaque object, see ProxyValue |
As[any] picks Go types for you: objects become map[string]any, arrays
[]any, numbers int or float64, and BigInts *big.Int.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Author User `json:"author"`
Created time.Time `json:"created"`
}
if err := rt.Set("user", User{ID: 1, Name: "Alice"}); err != nil {
return err
}
result, err := rt.Eval(ctx, "main.js", `({
id: 10,
title: "Hello from " + user.name,
author: user,
created: new Date("2026-01-02T03:04:05Z"),
})`)
if err != nil {
return err
}
defer result.Free()
post, err := result.As[Post]()
// post.Author.Name == "Alice", post.Created.Year() == 2026JavaScript functions convert to Go functions with As, as shown in
Calling JavaScript from Go.
ToValue copies: a Go function that receives a converted struct gets a new
copy, not the original. A ProxyValue instead passes a Go value through
JavaScript by reference. JavaScript sees an opaque object it cannot read or
forge, and Go functions and As get the original value back. Use it for
context.Context, connections, or large structs that should not be copied.
type Counter struct{ hits int }
counter := &Counter{}
if err := rt.Set("bump", func(c *Counter) int { c.hits++; return c.hits }); err != nil {
return err
}
proxy := rt.NewProxyValue(counter)
defer proxy.Free()
if err := rt.Global().Set("counter", proxy); err != nil {
return err
}
result, err := rt.Eval(ctx, "main.js", `bump(counter); bump(counter)`)
if err != nil {
return err
}
defer result.Free()
// result.Int32() == 2, counter.hits == 2A Runtime must not be used by several goroutines at once. A Pool gives
each call its own runtime and reuses idle ones. Runtimes that were
interrupted, or whose function panicked, are discarded instead of reused.
pool, err := qngg.NewPool(8, qngg.Config{}, func(rt *qngg.Runtime) error {
// Runs once for each new runtime.
v, err := rt.Eval(context.Background(), "setup.js",
`globalThis.greet = (name) => "Hello, " + name + "!"`)
if err != nil {
return err
}
v.Free()
return nil
})
if err != nil {
log.Fatal(err)
}
defer pool.Close()
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
err := pool.Do(ctx, func(ctx context.Context, rt *qngg.Runtime) error {
greet, err := rt.Get("greet")
if err != nil {
return err
}
defer greet.Free()
msg, err := greet.Call(ctx, nil, r.URL.Query().Get("name"))
if err != nil {
return err
}
defer msg.Free()
_, err = io.WriteString(w, msg.String())
return err
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})Values created inside Do must not be used after it returns.
Go application
│ qngg API: Runtime, Value, Pool
▼
Wasmtime C API, loaded at runtime through purego (no cgo)
│ one store and instance per Runtime
▼
qjs.wasm: QuickJS-NG compiled with wasi-sdk
│ WASI: CWD mount, stdout/stderr, clocks
└─ calls back into Go for Go functions and to release Go references
Each Runtime is a separate WebAssembly instance with its own linear memory,
so runtimes share nothing and a crash in one cannot affect another. The
compiled qjs.wasm is shared between runtimes, and optionally cached on disk
with Config.CacheDir.
See pkg.go.dev for the full documentation.
| Type | Description |
|---|---|
Runtime |
A QuickJS runtime and its global context. Not safe for concurrent use. |
Value |
A JavaScript value. Free the ones you get. |
Function / FunctionCall |
A Go function callable from JavaScript, and its call. |
Error |
A JavaScript exception, returned by calls and throwable from Go. |
Pool |
Runtimes for concurrent use. |
Config |
Runtime configuration. |
// Runtime
rt, err := qngg.New(qngg.Config{}) // Create a runtime
rt.Close() // Free it and all its values
rt.Eval(ctx, name, src, opts...) // Run a script (or module with AsModule)
rt.EvalFile(ctx, path, opts...) // Run a file from Config.FS or Config.CWD
rt.Compile(name, src, opts...) // Compile to bytecode
rt.CompileFile(path, opts...) // Compile a file from Config.FS or Config.CWD
rt.EvalBytecode(ctx, bytecode, opts...) // Run bytecode
rt.LoadModule(ctx, name, src) // Register a module for imports
rt.Set(name, goValue) // Convert and assign a global
rt.Get(name) // Read a global
rt.SetFunc(name, fn), rt.SetAsyncFunc(name, fn)
rt.ToValue(goValue) // Convert Go to JS
rt.NewString(s), rt.NewObject(), rt.NewProxyValue(v), ...
rt.Global() // The global object (owned by the runtime)
// Value
v.Get(name), v.Set(name, val), v.Index(i), v.SetIndex(i, val) // val: *Value or Go value
v.Has(name), v.Delete(name), v.Keys(), v.Len()
v.Call(ctx, this, args...), v.Invoke(ctx, method, args...), v.New(ctx, args...)
v.Await(ctx) // Wait for a promise
v.As[T]() // Convert JS to Go
v.String(), v.Int32(), v.Float64(), v.Bool(), v.Time(), v.Bytes(), json.Marshal(v)
v.IsObject(), v.IsArray(), v.IsFunction(), v.Type(), ...
v.Clone() // A new reference to the same value
v.Free() // Release the value
// Pool
pool, err := qngg.NewPool(size, config, setup)
pool.Do(ctx, func(ctx context.Context, rt *qngg.Runtime) error { ... })
pool.Close()type Config struct {
CWD string // Host directory mounted as "/" (default: no filesystem)
WritableCWD bool // Allow JavaScript to write under CWD (default: read-only)
FS qngg.FS // Filesystem mounted as "/"; mutually exclusive with CWD
WritableFS bool // Allow JavaScript to write to FS (default: read-only)
Stdout, Stderr io.Writer // Output of console.log and friends (default: discarded)
MemoryLimit int // QuickJS heap limit in bytes
MaxStackSize int // QuickJS stack limit in bytes
GCThreshold int // GC trigger threshold in bytes
DisableInterruption bool // ~30% faster compute, but contexts can no longer stop running code
CacheDir string // On-disk cache of the compiled QuickJS WebAssembly module
DisableBuildCache bool // Recompile the QuickJS module for every runtime
}QNGG runs QuickJS-NG on Wasmtime. The Wasmtime C API shared
library for the target platform (macOS, Linux and Windows on amd64/arm64) is
embedded in the binary, zstd-compressed, and extracted on first use into the
user cache directory (os.UserCacheDir()/qngg/wasmtime, falling back to the
system temp directory).
- Set
QNGG_WASMTIME_LIBto the path of a Wasmtime C API shared library to load it instead of the embedded one. Its version must match the bindings (currently Wasmtime v50.0.0). - Build with
-tags wasmtime_noembedto leave the embedded library out of the binary (about 6-7 MB per platform) and rely onQNGG_WASMTIME_LIB. - Linux builds require glibc; musl-based systems such as Alpine need a musl
build of Wasmtime provided through
QNGG_WASMTIME_LIB.
Optimization Tips:
- Use a
Poolfor concurrent applications. - Compile frequently-used scripts to bytecode.
- Use ProxyValue for large objects or shared state to avoid conversion.
- Set
CacheDirso the compiled QuickJS WebAssembly module is reused across processes. - Set
DisableInterruptionfor trusted, compute-heavy code.
Security
- Sandboxed execution: QuickJS runs inside WebAssembly linear memory, so JavaScript cannot touch Go memory.
- Filesystem: none by default. With
CWDset, only that directory is visible, as/, and it is read-only unlessWritableCWDis set. WASI enforces this boundary. Alternatively,FSexposes a custom backend, read-only unlessWritableFSis set; see the virtual filesystem section for its path and symbolic-link constraints. - Environment and output: no environment variables are passed, and output is discarded unless
Stdout/Stderrare set. - No network access: no sockets are exposed to JavaScript.
- Go references: ProxyValues and Go functions are native objects that JavaScript cannot forge, and the Go values behind them are released when JavaScript garbage collects them.
- Interruptible: calls stop when their context is done, even in the middle of an infinite loop.
- Isolation between users: runtimes in a
Poolkeep their global state between calls. Use separate runtimes, or reset the globals you set, when running code for different tenants. - No CGO: the Wasmtime native library is loaded at runtime through purego.
- Go 1.27+
- WASI SDK (for WebAssembly compilation)
- CMake 3.16+
- Make
# Clone with submodules
git clone --recursive https://github.com/ironpark/qngg.git
cd qngg
# Install WASI SDK 34 and binaryen (pick the archives for your platform)
curl -L https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-34/wasi-sdk-34.0-x86_64-linux.tar.gz | tar xz
sudo mv wasi-sdk-34.0-x86_64-linux /opt/wasi-sdk
curl -L https://github.com/WebAssembly/binaryen/releases/download/version_133/binaryen-version_133-x86_64-linux.tar.gz | tar xz
# Build WebAssembly module (QuickJS-NG v0.17.0)
make build WASM_OPT=$PWD/binaryen-version_133/bin/wasm-opt
# Run tests
go test ./...
go -C tests/afero test -race ./... # Optional afero backend integration testsMIT License - see LICENSE file.
QNGG includes third-party code under its own licenses:
- QuickJS-NG, compiled into
qjs.wasm- MIT (LICENSE). - wasmtime-go, via the purego port wasmtime-pure-go - Apache-2.0.
- Wasmtime shared libraries, embedded by wasmtime-pure-go - Apache-2.0 WITH LLVM-exception.
Built on the shoulders of giants:
- fastschema/qjs - The project QNGG is forked from.
- QuickJS by Fabrice Bellard - The elegant JavaScript engine.
- Wasmtime - Fast and secure WebAssembly runtime.
- wasmtime-go - Go bindings for Wasmtime, vendored and ported to purego.
- purego - Calling C from Go without cgo.
- QuickJS-NG - Maintained QuickJS fork.