QuickJSEx
Embedded QuickJS-NG JavaScript engine for Elixir via Rustler NIF.
- No external runtime — no Node.js, Bun, or Deno required
- Precompiled binaries — Rust toolchain is not required on most supported platforms
- In-process — runs inside the BEAM via a native extension
- ES2023+ — async/await, Promises, Proxy, Map/Set, destructuring, modules
- Isolated — each runtime runs on a dedicated OS thread with its own JS context
- Persistent state — globals survive across evaluations within a runtime
Installation
Add quickjs_ex to your list of dependencies in mix.exs:
def deps do
[
{:quickjs_ex, "~> 0.3.0"}
]
endOn macOS, Linux, and Windows, precompiled NIFs are downloaded automatically for supported targets. If your platform is unsupported, or if you want to build locally, install Rust via rustup and set:
QUICKJS_EX_BUILD=true
Supported precompiled targets in 0.3.0:
-
macOS:
aarch64-apple-darwin,x86_64-apple-darwin -
Linux:
aarch64-unknown-linux-gnu,x86_64-unknown-linux-gnu,x86_64-unknown-linux-musl -
Windows:
x86_64-pc-windows-gnu,x86_64-pc-windows-msvc
Usage
{:ok, rt} = QuickJSEx.start()
{:ok, 3} = QuickJSEx.eval(rt, "1 + 2")
{:ok, "hello"} = QuickJSEx.eval(rt, "'hello'")
{:ok, %{"a" => 1}} = QuickJSEx.eval(rt, "({a: 1})")
{:ok, _} = QuickJSEx.eval(rt, "globalThis.counter = 0")
{:ok, _} = QuickJSEx.eval(rt, "counter += 1")
{:ok, 1} = QuickJSEx.eval(rt, "counter")
QuickJSEx.stop(rt)Async JavaScript
Top-level await works in eval/2, and call/3 automatically awaits Promise-returning functions:
{:ok, rt} = QuickJSEx.start()
{:ok, values} =
QuickJSEx.eval(rt, """
await Promise.all([
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
])
""")
{:ok, _} =
QuickJSEx.eval(rt, """
async function greet(name) {
return `hi ${name}`;
}
""")
{:ok, [1, 2, 3]} = {:ok, values}
{:ok, "hi world"} = QuickJSEx.call(rt, "greet", ["world"])Calling Functions
Use call/3 to invoke global functions with Elixir values:
{:ok, rt} = QuickJSEx.start()
{:ok, _} =
QuickJSEx.eval(rt, """
function sum(values) {
return values.reduce((acc, n) => acc + n, 0);
}
""")
{:ok, 10} = QuickJSEx.call(rt, "sum", [[1, 2, 3, 4]])ES Modules
Load ES modules built with Vite, esbuild, or Rollup. Named exports are promoted to globalThis, so they can be called with call/3 or accessed with eval/2:
{:ok, rt} = QuickJSEx.start()
:ok = QuickJSEx.load_module(rt, "math", """
export function add(a, b) { return a + b; }
export const PI = 3.14159;
""")
{:ok, 5} = QuickJSEx.call(rt, "add", [2, 3])
{:ok, 3.14159} = QuickJSEx.eval(rt, "PI")eval/2 also accepts code containing export statements and promotes those exports the same way:
{:ok, rt} = QuickJSEx.start()
{:ok, _} =
QuickJSEx.eval(rt, """
const greeting = "hi";
export function greet(name) { return greeting + " " + name; }
export const PI = 3.14;
""")
{:ok, "hi world"} = QuickJSEx.call(rt, "greet", ["world"])
{:ok, 3.14} = QuickJSEx.eval(rt, "PI")SSR and Browser Stubs
For SSR bundles that expect browser-like globals, start a runtime with browser_stubs: true:
{:ok, rt} = QuickJSEx.start(browser_stubs: true)
:ok = QuickJSEx.load_module(rt, "server", File.read!("priv/static/server.js"))
{:ok, html} = QuickJSEx.call(rt, "render", ["MyComponent", %{count: 0}, %{}])When enabled, the runtime installs browser-oriented stubs including:
window,document,navigator,locationlocalStorage,sessionStorageprocess.env.NODE_ENVmatchMedia,MutationObserver,ResizeObserver,IntersectionObserverEvent,CustomEvent,requestAnimationFrame,getComputedStyle
Async render functions are automatically awaited.
Resetting a Runtime
Use reset/1 to clear global state and loaded modules without restarting the GenServer:
{:ok, rt} = QuickJSEx.start()
{:ok, _} = QuickJSEx.eval(rt, "globalThis.answer = 42")
{:ok, 42} = QuickJSEx.eval(rt, "answer")
:ok = QuickJSEx.reset(rt)
{:error, _reason} = QuickJSEx.eval(rt, "answer")Supervision
children = [
{QuickJSEx.Runtime, name: MyApp.JS, browser_stubs: true}
]
Supervisor.start_link(children, strategy: :one_for_one)
{:ok, 3} = QuickJSEx.eval(MyApp.JS, "1 + 2")Architecture
Each QuickJSEx.Runtime owns a dedicated OS thread running a QuickJS-NG context. The BEAM communicates with that thread over channels, so JavaScript execution does not block BEAM schedulers.
flowchart LR
A[BEAM Process\nGenServer] -- mpsc channel --> B[OS Thread\nQuickJS Runtime + Context]
B -- result channel --> A