Tyrex

Hex.pmDocs

Embedded Deno JavaScript runtime for Elixir via Rustler NIFs.

Execute JavaScript directly from Elixir — no external processes, no shelling out. Tyrex embeds the full Deno runtime as a native extension, giving you fetch, Deno.* APIs, Node.js compatibility, ES modules, and more.

Features

Installation

Add tyrex to your dependencies in mix.exs:

def deps do
  [
    {:tyrex, "~> 0.2.1"}
  ]
end

To build from source (instead of using precompiled binaries):

export TYREX_BUILD=true
mix deps.get && mix compile

Quick Start

# Start a runtime
{:ok, pid} = Tyrex.start()

# Evaluate JavaScript
{:ok, 3} = Tyrex.eval("1 + 2", pid: pid)
{:ok, "HELLO"} = Tyrex.eval("'hello'.toUpperCase()", pid: pid)

# Promises are awaited automatically
{:ok, "done"} = Tyrex.eval("Promise.resolve('done')", pid: pid)

# Deno APIs
{:ok, version} = Tyrex.eval("Deno.version", pid: pid)

# Stop when done
Tyrex.stop(pid: pid)

Error handling

The Tyrex.eval/1,2 API returns {:error, %Tyrex.Error{}} on failure. The error's :name field tags what went wrong; :message is human-readable and :value carries any associated payload (e.g. the rejected promise value).

case Tyrex.eval(code, pid: pid) do
  {:ok, result} ->
    result

  {:error, %Tyrex.Error{name: :execution_error, message: msg}} ->
    # JS/TS syntax error or thrown exception during synchronous code
    Logger.warning("JS execution failed: #{msg}")

  {:error, %Tyrex.Error{name: :promise_rejection, value: reason}} ->
    # A returned promise rejected; `reason` is the decoded rejection value
    {:error, {:js_rejected, reason}}

  {:error, %Tyrex.Error{name: :conversion_error, message: msg}} ->
    # A value could not round-trip between Elixir and JS
    {:error, {:bad_value, msg}}

  {:error, %Tyrex.Error{name: :dead_runtime_error}} ->
    # The runtime is gone (crashed or stopped mid-call) — restart and retry
    {:error, :runtime_down}
end

For exception-style flow, the Tyrex.eval!/1,2 (and Tyrex.Pool.eval!/2,3) variants raise the Tyrex.Error directly on failure.

Inline ~JS Sigil

Write JavaScript directly in Elixir with the ~JS sigil. Since ~JS is a raw sigil (no Elixir interpolation), JS template literals work naturally:

import Tyrex.Sigil

{:ok, pid} = Tyrex.start()
Tyrex.Inline.set_runtime(pid)

{:ok, 3} = ~JS"1 + 2"
{:ok, "Value: 42"} = ~JS"`Value: ${40 + 2}`"

# Multi-line
{:ok, [2, 4, 6]} = ~JS"""
const arr = [1, 2, 3];
arr.map(n => n * 2)
"""

To pass Elixir values into JavaScript, use Tyrex.Inline.eval/1 with standard string interpolation:

x = 10
{:ok, 15} = Tyrex.Inline.eval("#{x} + 5")

name = "world"
{:ok, "Hello, world!"} = Tyrex.Inline.eval("'Hello, #{name}!'")

Use with_runtime/2 for scoped runtime binding:

Tyrex.Inline.with_runtime(pid, fn ->
  {:ok, 42} = ~JS"21 * 2"
end)
# runtime binding is restored after the block

Permissions & Security

By default, Tyrex runtimes have full access to everything (like running deno run -A). You can restrict what JavaScript can do by passing a :permissions option.

Permission Presets

# Full access (default) — equivalent to deno run -A
Tyrex.start(permissions: :allow_all)

# No I/O at all — pure computation only (safe for untrusted code)
Tyrex.start(permissions: :none)

Granular Permissions

Each permission accepts true (allow all), false (deny all), or a list of specific allowed values:

# Allow network and file reads only
Tyrex.start(permissions: [
  allow_net: true,
  allow_read: true
])

# Restrict to specific hosts and paths
Tyrex.start(permissions: [
  allow_net: ["api.example.com:443", "cdn.example.com:443"],
  allow_read: ["/app/priv", "/tmp"],
  allow_write: ["/tmp"],
  allow_env: ["HOME", "PATH", "NODE_ENV"]
])

# Allow everything except subprocess execution and FFI
Tyrex.start(permissions: [
  allow_all: true,
  deny_run: true,
  deny_ffi: true
])

Available Permission Keys

Allow Deny Controls
allow_netdeny_net Network access (fetch, Deno.connect, etc.)
allow_readdeny_read File system reads (Deno.readTextFile, etc.)
allow_writedeny_write File system writes (Deno.writeTextFile, etc.)
allow_envdeny_env Environment variables (Deno.env)
allow_rundeny_run Subprocess execution (Deno.Command)
allow_ffideny_ffi Foreign function interface
allow_sysdeny_sys System info (hostname, OS, memory, etc.)
allow_importdeny_import Dynamic ES module imports

Pool with Permissions

Permissions apply to all runtimes in a pool:

# Sandboxed SSR pool — only allow reading templates
{Tyrex.Pool,
  name: :ssr,
  size: 4,
  permissions: [allow_read: ["priv/templates"]],
  main_module_path: "priv/js/ssr.js"}

Security Recommendations

Named Runtimes

Add Tyrex to your supervision tree:

# application.ex
children = [
  {Tyrex, name: MyApp.JS, main_module_path: "priv/js/app.js"}
]

# Anywhere in your app
{:ok, result} = Tyrex.eval("processData()", name: MyApp.JS)

Bidirectional: Calling Elixir from JavaScript

JavaScript code can call any Elixir function via Tyrex.apply():

{:ok, pid} = Tyrex.start()

# Enum.sum([1, 2, 3])
{:ok, 6} = Tyrex.eval(~s"""
(async () => await Tyrex.apply("Enum", "sum", [[1, 2, 3]]))()
""", pid: pid)

# String.upcase("hello")
{:ok, "HELLO"} = Tyrex.eval(~s"""
(async () => await Tyrex.apply("String", "upcase", ["hello"]))()
""", pid: pid)

# Erlang modules use colon prefix — :erlang.length([1, 2, 3])
{:ok, 3} = Tyrex.eval(~s"""
(async () => await Tyrex.apply(":erlang", "length", [[1, 2, 3]]))()
""", pid: pid)

Module Loading

// priv/js/math.js
export function fibonacci(n) {
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) [a, b] = [b, a + b];
  return b;
}
// priv/js/app.js
import { fibonacci } from "./math.js";
globalThis.fib = fibonacci;
{:ok, pid} = Tyrex.start(main_module_path: "priv/js/app.js")
{:ok, 55} = Tyrex.eval("fib(10)", pid: pid)

Runtime Pool

Tyrex.Pool manages multiple isolated runtimes and distributes work across them with pluggable strategies.

# In your supervision tree
children = [
  {Tyrex.Pool, name: :js_pool, size: 4}
]

# Evaluate — distributed via round-robin by default
{:ok, result} = Tyrex.Pool.eval(:js_pool, "1 + 1")

Tyrex.Pool cleans up its :persistent_term entry and any strategy-owned ETS tables on supervisor shutdown, so it is safe to start and stop pools dynamically (e.g. one pool per tenant) without leaking VM state.

Strategies

Round-Robin (default) — cycles sequentially, lock-free via ETS atomic counters:

{Tyrex.Pool, name: :pool, size: 4}

Random — picks a random runtime, good for bursty workloads:

{Tyrex.Pool, name: :pool, size: 4, strategy: Tyrex.Pool.Strategy.Random}

Hash — same key always hits the same runtime, for stateful JS sessions:

{Tyrex.Pool, name: :pool, size: 4, strategy: Tyrex.Pool.Strategy.Hash}

# Same user always hits the same runtime
Tyrex.Pool.eval(:pool, "getCart()", key: user_id)

Custom — implement the Tyrex.Pool.Strategy behaviour:

defmodule MyApp.LeastLoaded do
  @behaviour Tyrex.Pool.Strategy

  def init(pool_name, size), do: {pool_name, size}

  def select({pool_name, size}, _opts) do
    0..(size - 1)
    |> Enum.min_by(fn i ->
      :"#{pool_name}.Runtime.#{i}"
      |> Process.whereis()
      |> Process.info(:message_queue_len)
      |> elem(1)
    end)
  end
end

Pool with Shared Module

All runtimes load the same main module:

# SSR example
{Tyrex.Pool, name: :ssr, size: 4, main_module_path: "priv/js/ssr/server.js"}

{:ok, html} = Tyrex.Pool.eval(:ssr, "renderToString(#{Jason.encode!(props)})")

Examples

Run any example with TYREX_BUILD=true mix run examples/<file>:

Example Description
examples/basic.exs Arithmetic, strings, Deno APIs, async, bidirectional calls
examples/pool.exs Round-robin, hash strategy, concurrent runs
examples/data_processing.exs CSV parsing, statistics, URL parsing, HTML sanitization
examples/error_handling.exs Pattern-matching Tyrex.Error for execution, rejection, permission, and dead-runtime failures
examples/least_loaded.exs Custom Tyrex.Pool.Strategy that routes to the runtime with the shortest mailbox
examples/phoenix_ssr/ssr_example.exs SSR-like template rendering with a pool
examples/ink_tui/tui_example.exs Terminal UI rendering with ANSI colors, tables, and progress bars

API Reference

Core

Function Description
Tyrex.start/0,1 Start an unlinked runtime
Tyrex.start_link/1 Start a linked/named runtime (for supervision trees)
Tyrex.stop/0,1 Stop a runtime
Tyrex.eval/1,2 Evaluate JS, returns {:ok, result} or {:error, %Tyrex.Error{}}
Tyrex.eval!/1,2 Same as eval, raises Tyrex.Error on error

Inline

Function Description
~JS"code" Evaluate raw JS (no interpolation) on the process-local runtime
~JS"code"b Same, but in blocking mode
Tyrex.Inline.eval/1,2 Evaluate JS string (supports interpolation)
Tyrex.Inline.set_runtime/1 Set runtime for current process
Tyrex.Inline.with_runtime/2 Scoped runtime binding

Pool

Function Description
Tyrex.Pool.start_link/1 Start a pool supervisor
Tyrex.Pool.eval/2,3 Evaluate on a pool-selected runtime
Tyrex.Pool.eval!/2,3 Same as eval, raises Tyrex.Error on error

Options

Option evalPool.eval Description
:pid x Target runtime PID
:name x Target runtime name
:blocking x x Use blocking NIF call (fast, <1ms only)
:timeout x x GenServer call timeout (default: 5000ms)
:key x Dispatch key (for hash strategy)

Precompiled Binaries

Tyrex ships precompiled NIFs for these platforms — no Rust toolchain needed:

Platform Target
macOS Apple Silicon aarch64-apple-darwin
macOS Intel x86_64-apple-darwin
Linux x86_64 (glibc) x86_64-unknown-linux-gnu
Linux ARM64 (glibc) aarch64-unknown-linux-gnu

Precompiled binaries require OTP 27+ (NIF version 2.16).

Platforms requiring source build

If your platform is not listed above, you'll need to build from source:

Building from Source

Requires Rust 1.92+ and LLVM 20:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
export TYREX_BUILD=true
mix deps.get
mix compile

Note: The first build takes ~30-60 minutes because V8 is compiled from source.

On macOS, the system libffi is used automatically. On Linux, install build dependencies:

# Ubuntu/Debian
sudo apt-get install libffi-dev pkg-config libglib2.0-dev

# Fedora
sudo dnf install libffi-devel

Acknowledgements

Tyrex is inspired by deno_rider, which pioneered the approach of embedding the Deno runtime in Elixir via Rustler NIFs. Tyrex builds on the same proven architecture while adding a runtime pool with pluggable dispatch strategies, an inline ~JS sigil, granular permissions, and bidirectional Elixir/JS calls.

License

Apache-2.0 — see LICENSE for details.