ExSafejs

Sandboxed JavaScript execution for Elixir via QuickJS-NG.

ExSafejs embeds the QuickJS-NG engine as a Rustler NIF, giving you in-process JS evaluation with strict memory and time limits. Each runtime runs on a dedicated OS thread — JS execution never blocks BEAM schedulers.

ExSafejs is a hard fork of lpgauth/quicksand (MIT), started to ship the rquickjs 0.12 fix for a BEAM-killing SIGABRT on timeout-during-a-pending-promise-job (quicksand#2 / PR #3) and to evolve the API independently from there.

What the limits do — and don't — claim

The memory limit, stack limit, and timeout are resource guards: they reliably contain allocation bombs, runaway recursion, and infinite loops in untrusted JS, turning each into a structured error with the runtime (and the BEAM) surviving. What a NIF embedding cannot claim is host-memory isolation: the engine shares the BEAM's address space, so a hypothetical memory-corruption exploit in QuickJS-NG itself is not contained by this library. If your threat model includes engine-exploit-grade adversaries, run the evaluating node as a disposable OS process rather than inside your main application VM. (This distinction is borrowed from the security assessment of langchain-ai/quickjs-rs, which audited exactly this architecture.)

Features

Requirements

Installation

Add to your mix.exs:

def deps do
[
{:ex_safejs, "~> 0.3.1"}
]
end

Precompiled binaries will be downloaded automatically. To build from source instead:

EX_SAFEJS_BUILD=true mix deps.compile ex_safejs

Usage

Basic Evaluation

{:ok, rt} = ExSafejs.start()
{:ok, 3} = ExSafejs.eval(rt, "1 + 2")
{:ok, "hello"} = ExSafejs.eval(rt, "'hello'")
{:ok, %{"a" => 1}} = ExSafejs.eval(rt, "({a: 1})")
# Async code settles before returning
{:ok, 3} = ExSafejs.eval(rt, "(async () => 1 + 2)()")
{:ok, 2} = ExSafejs.eval(rt, "Promise.resolve(1).then(x => x + 1)")
# A promise nothing can settle is reported immediately
{:error, %ExSafejs.Error{kind: :deadlock}} = ExSafejs.eval(rt, "new Promise(() => {})")
:ok = ExSafejs.stop(rt)

Resource Limits

{:ok, rt} = ExSafejs.start(
timeout: 5_000, # 5 seconds max execution time
memory_limit: 10_000_000, # ~10 MB heap limit
max_stack_size: 512_000 # 512 KB stack
)
# Infinite loops are interrupted
{:error, %ExSafejs.Error{kind: :timeout}} = ExSafejs.eval(rt, "while(true) {}")
# Runtime remains usable after timeout
{:ok, 42} = ExSafejs.eval(rt, "42")

Callbacks

Register Elixir functions that JS code can call synchronously:

{:ok, rt} = ExSafejs.start()
callbacks = %{
"fetch_user" => fn [id] ->
user = MyApp.Repo.get!(User, id)
{:ok, %{"name" => user.name, "email" => user.email}}
end,
"log" => fn [message] ->
Logger.info("JS: #{message}")
{:ok, nil}
end
}
{:ok, "Alice"} = ExSafejs.eval(rt, """
const user = fetch_user(1);
log("Found user: " + user.name);
user.name;
""", callbacks)

Callbacks work under await too — the host call blocks the JS thread and is a plain value by the time the guest sees it, so await fetch_user(1) and Promise.all([fetch_user(1), fetch_user(2)]) both work (Promise.all runs the calls serially):

{:ok, "Alice"} = ExSafejs.eval(rt, "(async () => (await fetch_user(1)).name)()", callbacks)

Callbacks must return {:ok, value} or {:error, reason}:

callbacks = %{
"risky" => fn [n] ->
if n > 0, do: {:ok, n * 2}, else: {:error, "must be positive"}
end
}
# JS can catch callback errors
{:ok, "must be positive"} = ExSafejs.eval(rt, """
try { risky(-1); } catch(e) { e.message; }
""", callbacks)

Lifecycle

{:ok, rt} = ExSafejs.start()
ExSafejs.alive?(rt) # true
# Global state persists across evals
{:ok, 42} = ExSafejs.eval(rt, "globalThis.x = 42")
{:ok, 42} = ExSafejs.eval(rt, "x")
# Stop is idempotent
:ok = ExSafejs.stop(rt)
:ok = ExSafejs.stop(rt)
ExSafejs.alive?(rt) # false
# Eval on stopped runtime returns error (doesn't raise)
{:error, %ExSafejs.Error{kind: :dead_runtime}} = ExSafejs.eval(rt, "1")

API

FunctionDescription
ExSafejs.start(opts)Start a new JS runtime on a dedicated OS thread
ExSafejs.eval(runtime, code)Evaluate JS code, return the result
ExSafejs.eval(runtime, code, callbacks)Evaluate with pre-registered Elixir callbacks
ExSafejs.alive?(runtime)Check if a runtime is alive
ExSafejs.stop(runtime)Stop a runtime (idempotent)

Start Options

OptionTypeDefaultDescription
:timeoutinteger (ms)30_000Max JS compute time per eval (host-callback time excluded)
:memory_limitinteger (bytes)268_435_456 (256 MB)Max JS heap allocation
:max_stack_sizeinteger (bytes)1_048_576 (1 MB)Max JS call stack size
:gc_thresholdinteger (bytes)4_194_304 (4 MB)GC trigger threshold

Errors

Failures are {:error, %ExSafejs.Error{kind, message, stack}} where kind is one of :timeout, :deadlock, :memory_limit, :stack_overflow, :js_error, :host_error, :dead_runtime, :start_failed — see the ExSafejs.Error moduledoc. stack carries the JS stack trace when the engine provided one.

No reserved globals

Callback dispatch is captured host-side, so ex_safejs installs nothing on globalThis beyond the callback names you register (and removes those after each eval). Guest code may shadow or delete a callback's global binding, but that only loses its own access — nothing the guest writes is on the dispatch path. A callback reference the guest stashes and calls in a later eval is refused (each dispatch is bound to the eval that installed it), so it can neither wedge the runtime nor message an unrelated process.

Type Conversion

JS to Elixir

JavaScriptElixir
null, undefinednil
true, falsetrue, false
integerinteger
BigIntinteger (exact, arbitrary precision)
floatfloat (integer if no fractional part)
stringbinary string
Uint8Array / ArrayBufferbinary
Arraylist
Objectmap (string keys)
functionnil
NaN, Infinitynil

Elixir to JS (callback results)

ElixirJavaScript
nilnull
true, falsetrue, false
integernumber (up to ±2^53); BigInt beyond (exact)
floatnumber
binary stringstring
atomstring
listArray
mapObject

License

MIT. ExSafejs began as a fork of quicksand, copyright (c) 2026 Louis-Philippe Gauthier, also MIT — see LICENSE for both notices.