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
Returns a specification to start this module under a supervisor.
See Supervisor.
@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}
@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:applybridge — the GenServer would be parked in the NIF while the bridge needs that same GenServer to service the call, which deadlocks. The default isfalse.:name- The name of the Tyrex process. The default isTyrex. Can't be provided if:pidis provided.:pid- The pid of the Tyrex process. Can't be provided if:nameis provided.:timeout- Wall-clock deadline in milliseconds for the JavaScript to finish. Defaults to5_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
ArgumentErrorin 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: -1used to reach the server and raise insideProcess.send_after/3, killing the runtime. :infinityreturns an error tuple.{:error, %Tyrex.Error{name: :unsupported_option}}, on both the default and theblocking: truepath. 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 throughTyrex.Pool.eval/3it permanently consumes a pool slot. Before v0.4.0:timeoutwas only aGenServer.call/3timeout, 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}
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.
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.
@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).
@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 isTyrex. Can't be provided if:pidis provided.:pid- The pid of the Tyrex process. Can't be provided if:nameis provided.:timeout- How long to wait for the process to actually be gone. Defaults to5_000. An untrappable exit does not need a deadline, so reaching it would mean something is very wrong.
@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}
@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 throughTyrex.apply. Defaults tofalse. See "The apply bridge" below.:max_heap_mb- Cap the V8 heap, in megabytes. Unset by default, in which case a guest that exhausts memoryabort()s the entire BEAM. The minimum is 32; a smaller cap raisesArgumentError, because deno's bootstrap allocates before the near-heap-limit callback can be installed and so wouldabort()the BEAM atstart/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 to30_000. If the NIF does not respond in time,init/1returns{: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: guestconsole.logreaches the host's stdout directly through deno'sop_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, soDeno.stdin.readSyncreturns 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: truegrants the permission without restriction.allow_x: falsegrants nothing.allow_x: []also grants nothing — an empty allowlist names zero paths, hosts or variables.allow_x: ["a", "b"]grants exactly those.deny_x: truedenies the permission outright.deny_x: falsedenies nothing; it is not "deny all", anddeny_read: falseleaves 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— Dynamicimport()of non-file:specifiers. Deny-only in practice: the module loader readsfile:URLs only, so a remote import fails regardless of permissions andallow_importcannot make one succeed.deny_import: trueis still worthwhile — it turns a confusing "is not a file URL" into an explicit permission denial. A dynamicimport()of afile: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}])
@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 isTyrex.
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}
@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).
@spec stop(Keyword.t()) :: :ok
Stop a Tyrex process.
Options
:name- The name of the Tyrex process. The default isTyrex. Can't be provided if:pidis provided.:pid- The pid of the Tyrex process. Can't be provided if:nameis provided.:reason- SeeGenServer.stop/3.:timeout- Milliseconds to wait for a graceful stop. Defaults to5_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.