๐ŸŒถ Javex

Run JavaScript inside your Elixir app, sandboxed in WebAssembly.

Hex.pm License: MIT CI

Javex compiles JavaScript to WebAssembly with Javy and runs it on wasmtime, so you can hand a snippet of user-written JS to a Wasm sandbox and get a typed result back. Each call gets a fresh instance โ€” no shared state, nothing leaks between requests.

โœจ Why you might want this

๐Ÿš€ Quick start

In your mix.exs:

def deps do
  [{:javex, "~> 0.1"}]
end

Add a runtime to your supervision tree:

# lib/my_app/application.ex
children = [
  Javex.Runtime
]

Compile and run:

js = ~S"""
Javy.IO.writeSync(1, new TextEncoder().encode(JSON.stringify({hello: "world"})));
"""

{:ok, mod} = Javex.compile(js)
{:ok, %{"hello" => "world"}} = Javex.run(mod, nil)

๐Ÿ’ก Reading input in your JS requires a small stdin helper โ€” see Javy's README for the canonical readInput() / writeOutput() snippet to drop at the top of your script.

โš™๏ธ The bits you'll reach for

You want toโ€ฆ Use
Compile a JS snippet Javex.compile/2
Run a compiled module with JSON I/O Javex.run(mod, input)
Run with raw bytes Javex.run(mod, bytes, encoding: :raw)
Cap fuel, memory, or timeout Javex.run(mod, input, fuel: โ€ฆ, max_memory: โ€ฆ, timeout: โ€ฆ)
Set runtime defaults Javex.Runtime.start_link(name: :strict, default_fuel: โ€ฆ, default_max_memory: โ€ฆ)
Inspect active calls and cache usage Javex.Runtime.stats()

Persisting compiled modules? %Javex.Module{} is a plain struct โ€” :erlang.term_to_binary/1 round-trips it, no helper needed.

๐Ÿ›ก Safety knobs

{:ok, output} =
  Javex.run(mod, input,
    timeout: 250,                 # wall-clock ms
    fuel: 5_000_000,              # wasmtime fuel units
    max_memory: 8 * 1024 * 1024   # bytes
  )

Compilation and execution return tagged results. JavaScript errors preserve the original message and source location. JSON encoding/decoding failures return Jason errors; sandbox failures return Javex.RuntimeError:

{:error, %Javex.RuntimeError{kind: :timeout}}        # caller deadline elapsed
{:error, %Javex.RuntimeError{kind: :fuel_exhausted}}
{:error, %Javex.RuntimeError{kind: :oom}}
{:error, %Javex.RuntimeError{kind: :js_error}}       # uncaught JS exception
{:error, %Javex.RuntimeError{kind: :output_limit}}
{:error, %Javex.RuntimeError{kind: :busy}}           # admission capacity occupied
Budget Default
timeout 5,000 ms, including admission, encoding, JIT, execution, and decoding
fuel 100,000,000 Wasmtime fuel units
max_memory 64 MiB guest linear memory
max_input, max_output 1 MiB each
max_stderr 64 KiB diagnostics
max_module 8 MiB input Wasm
Runtime max_concurrency 4 active calls; excess calls return :busy
Runtime max_cached_modules, max_cache_bytes 64 modules / 64 MiB compiled images, LRU eviction

Javex.compile/2 also has finite timeout, fuel, memory, diagnostic and module budgets, plus max_source (1 MiB). At most 16 in-process native compilations may be active globally; saturation returns a compile error. Source must be valid UTF-8. mode: :static embeds the initialized provider; dynamic mode is the default.

Limits must be finite integers. Negative values and nil do not disable limits. Timeout, memory, source, input and module limits must be positive; zero fuel or output/diagnostic limits are allowed. Runtime default_* options are defaults, not security ceilings: trusted host code can override them per call. Keep options, custom providers, and runtime creation under host control, not under the JS author's control. Zero cache bytes or module count disables caching.

Process isolation for untrusted code (Linux)

children = [
  {Javex.Runtime,
   name: :untrusted_js,
   backend: :isolated,
   max_process_memory: 512 * 1024 * 1024,
   max_concurrency: 4}
]

# Both calls must select the isolated runtime.
{:ok, mod} = Javex.compile(agent_js, runtime: :untrusted_js)
{:ok, result} = Javex.run(mod, input, runtime: :untrusted_js)

Each compilation/execution uses a disposable native child with hard address-space, CPU, descriptor and core-dump limits. max_process_memory defaults to 512 MiB and includes the child's native parser/JIT allocations, stacks and guest memory. It is an address-space limit, not an RSS reservation or a cap on the entire BEAM host. A small trusted supervisor bounds IPC and kills/reaps the child on deadline, cancellation or a closed BEAM port. Parent-death signals also kill the child if the supervisor crashes. A child crash returns :worker_error; it cannot crash the BEAM through a NIF call.

Before reading code, the child clears inherited descriptors, has no inherited environment, disables privilege gain and installs a seccomp syscall allowlist across all its threads. Filesystem opens, networking, process/thread creation, execution, cross-process inspection/signalling, resource-limit changes and io_uring are not permitted. Only the requested WASI environment is supplied to the guest. Protections must succeed or the call fails; there is no in-process fallback. This backend requires Linux with close_range (5.9+) and seccomp TSYNC enabled by the host/container policy. macOS supports only :in_process.

Compilation with runtime: shares that runtime's admission capacity. Standalone Javex.compile(js) remains in-process, even if an isolated runtime is running. Fresh workers do not share a native cache: isolated cache statistics stay zero, and cold compilation costs apply on every call. IPC requests/responses have a 64 MiB hard transport cap in addition to the configured byte quotas.

Sandbox boundary

Javex supplies bounded synchronous computation. Workflow persistence, retries, credentials, tools, permissions and external effects belong in the automation layer above it. Do not execute agent-generated JS as trusted Elixir host code.

๐Ÿง  How it works (in 60 seconds)

Bundled provider provenance

priv/javy_plugin.wasm is the initialized default provider from Javy v8.1.1, using namespace javy-default-plugin-v3. The bundled bytes match the decompressed official plugin.wasm.gz asset exactly. Reproduce or verify them with:

curl -fL https://github.com/bytecodealliance/javy/releases/download/v8.1.1/plugin.wasm.gz -o /tmp/javy-plugin.wasm.gz
echo '52e3c83235f7735cb294e119a1a4b7be0a503323d5b8967eff8c0e516f3da8e1  /tmp/javy-plugin.wasm.gz' | sha256sum -c -
gzip -dc /tmp/javy-plugin.wasm.gz > /tmp/javy-plugin.wasm
echo '9a1f5310999888e39b7712e43384291cdc8cf84eaa409faf5338959d901ecacc  /tmp/javy-plugin.wasm' | sha256sum -c -
cmp /tmp/javy-plugin.wasm priv/javy_plugin.wasm

Provider changes require compatibility tests and recompilation of dynamic modules. Custom providers must be trusted, initialized Javy providers supporting the same cabi_realloc / compile-src / invoke ABI; the namespace alone does not establish compatibility or trust.

Development and verification

mise install
mix deps.get
JAVEX_BUILD=1 mix test --cover
mix format --check-formatted
JAVEX_BUILD=1 mix credo --strict
cargo fmt --manifest-path native/javex_nif/Cargo.toml --check
cargo clippy --manifest-path native/javex_nif/Cargo.toml --all-targets --locked -- -D warnings
cargo test --manifest-path native/javex_nif/Cargo.toml --release --locked

Elixir coverage excludes only the replaced NIF fallback bodies; Rust buffer contracts are tested separately. CI audits dependencies, and release jobs gate publication on CI and load/run each packaged NIF on its target platform.

๐Ÿ“„ License

MIT.