Hex.pm Docs

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

  • Full Deno runtimefetch, Deno.readTextFile, setTimeout, Promises, etc.
  • Inline ~JS sigil — Write JavaScript directly in your Elixir code
  • Bidirectional calls — Call Elixir functions from JavaScript via Tyrex.apply()
  • Module loading — Import ES modules with import/export
  • Runtime pool — Pool of Deno runtimes with pluggable dispatch strategies
  • Blocking & async modes — Choose between NIF-blocking (fast, <1ms) or async eval
  • Node.js APIsnode:path, node:buffer, node:crypto, etc.

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

AllowDenyControls
allow_netdeny_netNetwork access (fetch, Deno.connect, etc.)
allow_readdeny_readFile system reads (Deno.readTextFile, etc.)
allow_writedeny_writeFile system writes (Deno.writeTextFile, etc.)
allow_envdeny_envEnvironment variables (Deno.env)
allow_rundeny_runSubprocess execution (Deno.Command)
allow_ffideny_ffiForeign function interface
allow_sysdeny_sysSystem info (hostname, OS, memory, etc.)
allow_importdeny_importDynamic 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

  • Untrusted code: Use permissions: :none for user-submitted JavaScript
  • SSR / templating: Allow only allow_read for template directories
  • API proxying: Allow only allow_net with specific hosts
  • Always deny allow_run and allow_ffi unless you specifically need subprocess or FFI access

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>:

ExampleDescription
examples/basic.exsArithmetic, strings, Deno APIs, async, bidirectional calls
examples/pool.exsRound-robin, hash strategy, concurrent runs
examples/data_processing.exsCSV parsing, statistics, URL parsing, HTML sanitization
examples/error_handling.exsPattern-matching Tyrex.Error for execution, rejection, permission, and dead-runtime failures
examples/least_loaded.exsCustom Tyrex.Pool.Strategy that routes to the runtime with the shortest mailbox
examples/phoenix_ssr/ssr_example.exsSSR-like template rendering with a pool
examples/ink_tui/tui_example.exsTerminal UI rendering with ANSI colors, tables, and progress bars

API Reference

Core

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

Inline

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

Pool

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

Options

OptionevalPool.evalDescription
:pidxTarget runtime PID
:namexTarget runtime name
:blockingxxUse blocking NIF call (fast, <1ms only)
:timeoutxxGenServer call timeout (default: 5000ms)
:keyxDispatch key (for hash strategy)

Precompiled Binaries

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

PlatformTarget
macOS Apple Siliconaarch64-apple-darwin
macOS Intelx86_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:

  • Linux musl (Alpine, NixOS)
  • Windows
  • FreeBSD / OpenBSD
  • Linux 32-bit, RISC-V, or other architectures

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.