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
- Sandboxed JS with no filesystem, network, or OS access
- Async-aware eval: async arrows,
await,.thenchains andPromise.allare driven to settlement; a never-settling promise is detected as a:deadlockerror immediately instead of burning the timeout - Configurable memory limit, execution timeout, stack size, and GC threshold
- The timeout is a JS compute budget: time spent inside Elixir callbacks is excluded, so a slow host call never reads as guest misbehavior
- Structured errors (
%ExSafejs.Error{kind: ...}) a caller — or an LLM repair loop — can branch on - Pre-registered Elixir callbacks callable from JS, installed as real function objects with the dispatch path captured host-side (no reserved globals, nothing the guest writes is on the trust path)
- Raised callback exceptions are sanitized to the guest (
"host function failed") while the real exception reaches the Elixir caller - Direct Erlang term <-> JS value conversion (no JSON serialization)
- Resource-only API (no GenServer overhead)
Requirements
- Elixir >= 1.15
- Precompiled NIF binaries are provided for macOS (ARM/Intel) and Linux (x86/ARM)
- Rust toolchain only needed if building from source
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}:
{:ok, value}— value is converted to JS and returned to the caller{:error, reason}— throws a catchable JSErrorcarryingreasonverbatim — never put a secret in it- a raised exception is different: the guest sees only a generic
"host function failed"exception, and if it goes uncaught the eval returns{:error, %ExSafejs.Error{kind: :host_error}}carrying the real exception message to the Elixir caller only
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
| Function | Description |
|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
:timeout | integer (ms) | 30_000 | Max JS compute time per eval (host-callback time excluded) |
:memory_limit | integer (bytes) | 268_435_456 (256 MB) | Max JS heap allocation |
:max_stack_size | integer (bytes) | 1_048_576 (1 MB) | Max JS call stack size |
:gc_threshold | integer (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
| JavaScript | Elixir |
|---|---|
null, undefined | nil |
true, false | true, false |
| integer | integer |
| BigInt | integer (exact, arbitrary precision) |
| float | float (integer if no fractional part) |
| string | binary string |
| Uint8Array / ArrayBuffer | binary |
| Array | list |
| Object | map (string keys) |
| function | nil |
NaN, Infinity | nil |
Elixir to JS (callback results)
| Elixir | JavaScript |
|---|---|
nil | null |
true, false | true, false |
| integer | number (up to ±2^53); BigInt beyond (exact) |
| float | number |
| binary string | string |
| atom | string |
| list | Array |
| map | Object |
License
MIT. ExSafejs began as a fork of quicksand, copyright (c) 2026 Louis-Philippe Gauthier, also MIT — see LICENSE for both notices.