Quicksand

Sandboxed JavaScript execution for Elixir via QuickJS-NG.

Quicksand 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.

Features

Requirements

Installation

Add to your mix.exs:

def deps do
  [
    {:quicksand, "~> 0.1.0"}
  ]
end

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

QUICKSAND_BUILD=true mix deps.compile quicksand

Usage

Basic Evaluation

{:ok, rt} = Quicksand.start()

{:ok, 3} = Quicksand.eval(rt, "1 + 2")
{:ok, "hello"} = Quicksand.eval(rt, "'hello'")
{:ok, %{"a" => 1}} = Quicksand.eval(rt, "({a: 1})")

:ok = Quicksand.stop(rt)

Resource Limits

{:ok, rt} = Quicksand.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, "timeout"} = Quicksand.eval(rt, "while(true) {}")

# Runtime remains usable after timeout
{:ok, 42} = Quicksand.eval(rt, "42")

Callbacks

Register Elixir functions that JS code can call synchronously:

{:ok, rt} = Quicksand.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"} = Quicksand.eval(rt, """
  const user = fetch_user(1);
  log("Found user: " + user.name);
  user.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"} = Quicksand.eval(rt, """
  try { risky(-1); } catch(e) { e.message; }
""", callbacks)

Lifecycle

{:ok, rt} = Quicksand.start()

Quicksand.alive?(rt)  # true

# Global state persists across evals
{:ok, 42} = Quicksand.eval(rt, "globalThis.x = 42")
{:ok, 42} = Quicksand.eval(rt, "x")

# Stop is idempotent
:ok = Quicksand.stop(rt)
:ok = Quicksand.stop(rt)

Quicksand.alive?(rt)  # false

# Eval on stopped runtime returns error (doesn't raise)
{:error, "dead_runtime"} = Quicksand.eval(rt, "1")

API

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

Start Options

Option Type Default Description
:timeout integer (ms) 30_000 Max JS execution time per eval
: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

Type Conversion

JS to Elixir

JavaScript Elixir
null, undefinednil
true, falsetrue, false
integer integer
float float (integer if no fractional part)
string binary string
Array list
Object map (string keys)
function nil
NaN, Infinitynil

Elixir to JS (callback results)

Elixir JavaScript
nilnull
true, falsetrue, false
integer number
float number
binary string string
atom string
list Array
map Object

License

MIT