Tyrex

Hex.pmDocs

Embedded Deno JavaScript/TypeScript runtime for Elixir via Rustler NIFs.

Execute JavaScript and TypeScript 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.1.0"}
  ]
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)

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_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")

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 eval
examples/data_processing.exs CSV parsing, statistics, URL parsing, HTML sanitization
examples/phoenix_ssr/ssr_example.exs SSR-like template rendering with a pool

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

Building from Source

Requires Rust toolchain (1.70+):

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

On macOS, the system libffi is used automatically. On Linux, install libffi-dev:

sudo apt-get install libffi-dev   # Ubuntu/Debian
sudo dnf install libffi-devel     # Fedora

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.