Tyrex (Tyrex v0.4.0)

Copy Markdown View Source

Embedded Deno JS/TS runtime for Elixir.

Tyrex wraps the Deno runtime as a GenServer, allowing you to evaluate JavaScript and TypeScript code directly from Elixir. Each runtime is an isolated V8 instance with access to Deno APIs, subject to the permissions you grant it.

Quick Start

{:ok, pid} = Tyrex.start(permissions: :none)
{:ok, 3} = Tyrex.eval("1 + 2", pid: pid)
Tyrex.stop(pid: pid)

Named Runtime

# In your supervision tree
{Tyrex, name: MyApp.JS, main_module_path: "priv/js/app.js"}

# Then anywhere
{:ok, result} = Tyrex.eval("processData()", name: MyApp.JS)

Calling Elixir from JavaScript

The Tyrex.apply bridge is a privileged capability and is off by default. When enabled you must name exactly which functions guest JavaScript may reach:

{:ok, pid} = Tyrex.start(apply: [{Enum, :sum, 1}])

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

Anything not on the allowlist rejects the JavaScript promise with a message beginning permission_denied:. With apply: false (the default) the bridge is not installed at all — globalThis.Tyrex is deleted after bootstrap, so guest code has no reference to reach.

:permissions does not govern this

:permissions controls Deno's own I/O — files, network, env, subprocesses. It has never governed which Elixir code the bridge can reach. Before v0.4.0 the bridge was installed unconditionally, so permissions: :none denied Deno.readTextFileSync while still granting File.read! and :os.cmd through Tyrex.apply. That is why the bridge is now opt-in.

Supervision and error handling

Tyrex.start_link/1 follows the standard OTP shape, so a Tyrex runtime (and Tyrex.Pool) can be added directly to a supervisor's child list. Runtime errors are returned as {:error, %Tyrex.Error{}} from the run/eval API; see the Error handling section of the README for a full breakdown of the possible :name values and how to pattern-match them.

A runtime that hits its :timeout deadline or its :max_heap_mb cap is terminated and dead. V8 termination is uncatchable and sticky, so tyrex does not attempt to nurse a poisoned isolate back to health; under a supervisor the child is simply replaced.

Replacement is not free, and guest code chooses how often it happens. Under a Tyrex.Pool the runtimes are supervised :one_for_one, so one guest's deadline does not disturb its siblings — but the caller whose runtime died still has to retry, a call arriving during the restart window gets {:error, %Tyrex.Error{name: :dead_runtime_error}}, and the pool's :max_restarts / :max_seconds ceiling still applies. Tune those if untrusted guests are expected to trip deadlines routinely; see Tyrex.Pool.start_link/1.

Summary

Functions

Returns a specification to start this module under a supervisor.

Same as eval/2, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

Run the given JavaScript code and return the result. If a promise is returned, it will be awaited.

Same as eval/1, but raises Tyrex.Error if the result isn't successful.

Same as eval/2, but raises Tyrex.Error if the result isn't successful.

Same as kill/1, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

Interrupt whatever the runtime is executing and shut it down, immediately.

Start a Tyrex process without any main module.

Start a Tyrex process.

Start a Tyrex process linked to the current process.

Same as stop/1, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

Stop a Tyrex process.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

eval(code)

@spec eval(binary()) :: {:ok, term()} | {:error, Tyrex.Error.t()}

Same as eval/2, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

Examples

iex> Tyrex.eval("1 + 2")
{:ok, 3}

eval(code, opts)

@spec eval(binary(), Keyword.t()) :: {:ok, term()} | {:error, Tyrex.Error.t()}

Run the given JavaScript code and return the result. If a promise is returned, it will be awaited.

Options

  • :blocking - Indicates whether the NIF call should block until the JavaScript execution finishes or not. Blocking is more performant, but it cannot be combined with the :apply bridge — the GenServer would be parked in the NIF while the bridge needs that same GenServer to service the call, which deadlocks. The default is false.
  • :name - The name of the Tyrex process. The default is Tyrex. Can't be provided if :pid is provided.
  • :pid - The pid of the Tyrex process. Can't be provided if :name is provided.
  • :timeout - Wall-clock deadline in milliseconds for the JavaScript to finish. Defaults to 5_000. On expiry the V8 isolate is terminated and {:error, %Tyrex.Error{name: :timeout}} is returned; the runtime is dead afterwards.

:timeout rejects two different ways, deliberately, and the difference is whether the value is malformed or merely unsupported:

  • Malformed raises. Anything that is not a positive integer within the BEAM's timer range raises ArgumentError in the calling process. That is a bug in the caller, in the same class as a bad :max_heap_mb, and it raises rather than returning a tuple so it cannot be pattern-matched past and ignored. It also keeps the failure with the caller: timeout: -1 used to reach the server and raise inside Process.send_after/3, killing the runtime.
  • :infinity returns an error tuple. {:error, %Tyrex.Error{name: :unsupported_option}}, on both the default and the blocking: true path. It is a well-formed value that tyrex refuses on policy, not a mistake in the shape of the argument, and the blocking path already answered this way before v0.4.0 — so the tuple is the compatible answer. An unbounded deadline arms no timer, so a runaway guest burns a per-runtime OS thread at 100% for the life of the VM — invisible to BEAM scheduler-utilization monitoring because it is not a dirty scheduler — and through Tyrex.Pool.eval/3 it permanently consumes a pool slot. Before v0.4.0 :timeout was only a GenServer.call/3 timeout, which had that effect by default: the caller gave up and the JavaScript kept running.

Evaluating against a runtime that has already terminated — the window every deadline, heap trip and kill/1 opens — returns {:error, %Tyrex.Error{name: :dead_runtime_error}} rather than exiting, so the @spec holds. Every other exit reason still propagates: a :timeout exit from GenServer.call/3 itself means the server-side deadline lost its race, which is a bug and must not be swallowed.

Examples

iex> Tyrex.eval("1 + 2")
{:ok, 3}

iex> Tyrex.eval("1 + 2", blocking: true)
{:ok, 3}

iex> {:ok, pid} = Tyrex.start(permissions: :none)
iex> Tyrex.eval("1 + 2", pid: pid)
{:ok, 3}

eval!(code)

@spec eval!(binary()) :: term() | no_return()

Same as eval/1, but raises Tyrex.Error if the result isn't successful.

Use this when you'd rather treat runtime errors as exceptions than handle them in a case block. See Tyrex.Error for the possible :name values.

eval!(code, opts)

@spec eval!(binary(), Keyword.t()) :: term() | no_return()

Same as eval/2, but raises Tyrex.Error if the result isn't successful.

Use this when you'd rather treat runtime errors as exceptions than handle them in a case block. See Tyrex.Error for the possible :name values.

kill()

@spec kill() :: :ok

Same as kill/1, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

kill(opts)

@spec kill(Keyword.t()) :: :ok

Interrupt whatever the runtime is executing and shut it down, immediately.

Unlike stop/1 this works on a runtime wedged inside a guest that never yields — while (true) {} cannot be stopped cooperatively, only terminated — and it does not wait for a graceful shutdown first. Any in-flight eval callers receive {:error, %Tyrex.Error{name: :dead_runtime_error}}.

Termination is one-way: the runtime is dead afterwards and must be replaced.

Options

  • :name - The name of the Tyrex process. The default is Tyrex. Can't be provided if :pid is provided.
  • :pid - The pid of the Tyrex process. Can't be provided if :name is provided.
  • :timeout - How long to wait for the process to actually be gone. Defaults to 5_000. An untrappable exit does not need a deadline, so reaching it would mean something is very wrong.

start()

@spec start() :: GenServer.on_start()

Start a Tyrex process without any main module.

See start/1 for more information.

Examples

iex> {:ok, pid} = Tyrex.start(permissions: :none)
iex> Tyrex.eval("1 + 2", pid: pid)
{:ok, 3}

start(opts)

@spec start(Keyword.t()) :: GenServer.on_start()

Start a Tyrex process.

Options

  • :main_module_path - Path to the main JavaScript module. The default is to start the runtime without a main module.
  • :permissions - Runtime permissions. Defaults to :none. See "Permissions" below.
  • :apply - Whether guest JavaScript may call Elixir through Tyrex.apply. Defaults to false. See "The apply bridge" below.
  • :max_heap_mb - Cap the V8 heap, in megabytes. Unset by default, in which case a guest that exhausts memory abort()s the entire BEAM. The minimum is 32; a smaller cap raises ArgumentError, because deno's bootstrap allocates before the near-heap-limit callback can be installed and so would abort() the BEAM at start/1 — the outcome this option exists to prevent. Note the cap converts incremental heap growth into :heap_limit_error; it cannot save the node from a single allocation far larger than the cap, because V8 termination only takes effect at an interrupt check and a builtin never reaches one. See the README.
  • :startup_timeout - Maximum time in milliseconds to wait for the NIF to acknowledge runtime startup. Defaults to 30_000. If the NIF does not respond in time, init/1 returns {:stop, :nif_startup_timeout}.

Permissions

Control what Deno I/O the JavaScript runtime can perform:

  • :none — No permissioned Deno I/O (default). JavaScript can compute, but not read files, open sockets, read env, or spawn processes. Two caveats it does not cover, both about the host's standard streams: guest console.log reaches the host's stdout directly through deno's op_print, bypassing the permission model entirely, so guest code can write to your logs and no permission prevents it. Guest stdin, by contrast, is closed — it is pointed at the null device, so Deno.stdin.readSync returns EOF instead of reading the host's stdin.
  • :allow_all — Full access to everything.
  • Keyword list — Granular control per permission type.

Each permission key accepts true, false, or a list of strings. The same literal means opposite things depending on the key's direction, so the two directions are stated separately rather than together:

  • allow_x: true grants the permission without restriction. allow_x: false grants nothing. allow_x: [] also grants nothing — an empty allowlist names zero paths, hosts or variables. allow_x: ["a", "b"] grants exactly those.
  • deny_x: true denies the permission outright. deny_x: false denies nothing; it is not "deny all", and deny_read: false leaves the file readable. deny_x: [] likewise denies nothing. deny_x: ["a", "b"] denies exactly those.

The keys:

  • :allow_net / :deny_net — Network access (true, false, or ["host:port", ...])
  • :allow_read / :deny_read — File read access (true, false, or ["/path", ...])
  • :allow_write / :deny_write — File write access
  • :allow_env / :deny_env — Environment variables (true, false, or ["VAR", ...])
  • :allow_run / :deny_run — Subprocess execution
  • :allow_ffi / :deny_ffi — Foreign function interface
  • :allow_sys / :deny_sys — System info (hostname, OS, etc.)
  • :allow_import / :deny_import — Dynamic import() of non-file: specifiers. Deny-only in practice: the module loader reads file: URLs only, so a remote import fails regardless of permissions and allow_import cannot make one succeed. deny_import: true is still worthwhile — it turns a confusing "is not a file URL" into an explicit permission denial. A dynamic import() of a file: specifier is governed by the read permissions above instead; the main module and its static import graph are operator-supplied and exempt from both. Vendor remote dependencies to disk if you need them.

Parsing fails closed. An unknown key raises ArgumentError rather than being silently dropped, an empty allow_x list grants nothing rather than everything, and an explicit allow_x: false still denies under allow_all: true.

The apply bridge

:apply takes false (the default) or a list of {Module, :function, arity} tuples. Only those exact MFAs are callable from JavaScript; every entry must be exported at start time or ArgumentError is raised. Enforcement lives in this GenServer, not in JavaScript — a guard inside the isolate would be inside the blast radius.

Examples

iex> Tyrex.start(main_module_path: "path/to/main.js")

# No Deno I/O (the default)
iex> Tyrex.start(permissions: :none)

# Only allow network and reading from /tmp
iex> Tyrex.start(permissions: [allow_net: true, allow_read: ["/tmp"]])

# Let JavaScript call exactly two Elixir functions
iex> Tyrex.start(apply: [{Enum, :sum, 1}, {String, :upcase, 1}])

start_link(opts)

@spec start_link(Keyword.t()) :: GenServer.on_start()

Start a Tyrex process linked to the current process.

Options

  • :name - The name of the process. The default is Tyrex.

See start/1 for more options.

Examples

iex> Tyrex.start_link(name: MyApp.Tyrex, permissions: :none)
iex> Tyrex.eval("1 + 2", name: MyApp.Tyrex)
{:ok, 3}

stop()

@spec stop() :: :ok

Same as stop/1, but it assumes that there is a process with the name Tyrex (the default if you don't provide a name to start_link/1).

stop(opts)

@spec stop(Keyword.t()) :: :ok

Stop a Tyrex process.

Options

  • :name - The name of the Tyrex process. The default is Tyrex. Can't be provided if :pid is provided.
  • :pid - The pid of the Tyrex process. Can't be provided if :name is provided.
  • :reason - See GenServer.stop/3.
  • :timeout - Milliseconds to wait for a graceful stop. Defaults to 5_000. On expiry the runtime is killed rather than waited on forever.

The default was :infinity before v0.4.0, which meant a caller who did not override it hung permanently on a wedged runtime.