README.md

# Quicksand

Sandboxed JavaScript execution for Elixir via [QuickJS-NG](https://github.com/quickjs-ng/quickjs).

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

- Sandboxed JS with no filesystem, network, or OS access
- Configurable memory limit, execution timeout, and stack size
- Pre-registered Elixir callbacks callable from JS
- 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`:

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

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

```bash
QUICKSAND_BUILD=true mix deps.compile quicksand
```

## Usage

### Basic Evaluation

```elixir
{: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

```elixir
{: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:

```elixir
{: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}`:

- `{:ok, value}` — value is converted to JS and returned to the caller
- `{:error, reason}` — throws a JS exception with the reason as the message

```elixir
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

```elixir
{: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`, `undefined` | `nil` |
| `true`, `false` | `true`, `false` |
| integer | integer |
| float | float (integer if no fractional part) |
| string | binary string |
| 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 |
| float | number |
| binary string | string |
| atom | string |
| list | Array |
| map | Object |

## License

MIT