Embed the Deno TypeScript/JavaScript runtime in Elixir via a Rustler NIF.
Denox gives Elixir applications first-class access to the JS/TS ecosystem — evaluate JavaScript, transpile and run TypeScript, load ES modules, import from CDNs, and manage npm/jsr dependencies — all in-process, no external services required.
- JavaScript evaluation — sub-millisecond V8 eval via
deno_runtime - TypeScript transpilation — native swc/deno_ast, no type-checking overhead
- ES module loading —
import/exportbetween.ts/.jsfiles - Async evaluation —
await, dynamicimport(), Promise resolution - CDN imports — fetch from esm.sh, esm.run, etc. with in-memory + disk caching
- Dependency management —
deno.json+deno installfor npm/jsr packages - Pre-bundling —
deno bundlefor self-contained JS files - Runtime pool — round-robin across N V8 isolates for concurrent workloads
- Import maps — bare specifier resolution via
import_mapoption - JS → Elixir callbacks — call Elixir functions from JavaScript
- V8 snapshots — pre-initialize global state for faster cold starts
- Telemetry — built-in
:telemetryevents for eval timing Denox.Run— NIF-backed long-lived runtime with bidirectional stdio, OTP supervision, no externaldenobinary neededDenox.CLI— downloads and caches the official Deno binary for the current platform
Add denox to your list of dependencies in mix.exs:
def deps do
[
{:denox, "~> 0.5"}
]
endThe first compile takes ~20-30 minutes because V8 compiles from source. Subsequent compiles are fast.
- Rust (stable) — install via rustup
- Elixir 1.18+ / OTP 27+
To force a local build (instead of using precompiled binaries):
DENOX_BUILD=true mix compile# Create a runtime
{:ok, rt} = Denox.runtime()
# Evaluate JavaScript
{:ok, "3"} = Denox.eval(rt, "1 + 2")
# Evaluate TypeScript (transpiled via swc, no type-checking)
{:ok, "42"} = Denox.eval_ts(rt, "const x: number = 42; x")
# Async evaluation (Promises, dynamic import)
{:ok, "99"} = Denox.eval_async(rt, "return await Promise.resolve(99)")
# Decode JSON results to Elixir terms
{:ok, %{"a" => 1}} = Denox.eval_decode(rt, "({a: 1})")
# Call JavaScript functions
Denox.exec(rt, "globalThis.double = (n) => n * 2")
{:ok, "10"} = Denox.call(rt, "double", [5])
# Load and evaluate files
{:ok, result} = Denox.eval_file(rt, "path/to/script.ts")
# Load ES modules with import/export
{:ok, _} = Denox.eval_module(rt, "path/to/module.ts")For concurrent workloads, use a pool of V8 runtimes:
# In your supervision tree
children = [
{Denox.Pool, name: :js_pool, size: 4}
]
# Use the pool (round-robin across runtimes)
{:ok, result} = Denox.Pool.eval(:js_pool, "1 + 2")
{:ok, result} = Denox.Pool.eval_ts(:js_pool, "const x: number = 42; x")
{:ok, result} = Denox.Pool.eval_async(:js_pool, "return await Promise.resolve(99)")Run full Deno programs as managed OTP processes with bidirectional stdio. Uses an in-process deno_runtime MainWorker via NIF — no external deno binary required. Ideal for running MCP servers, CLI tools, or any npm/jsr package.
# Run an MCP server from npm
{:ok, mcp} = Denox.Run.start_link(
package: "@modelcontextprotocol/server-github",
permissions: :all,
env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => System.fetch_env!("GITHUB_TOKEN")}
)
# Send JSON-RPC initialize request via stdin
Denox.Run.send(mcp, Jason.encode!(%{
jsonrpc: "2.0",
method: "initialize",
id: 1,
params: %{
protocolVersion: "2024-11-05",
capabilities: %{},
clientInfo: %{name: "denox", version: "0.5.0"}
}
}))
# Read the response from stdout
{:ok, response} = Denox.Run.recv(mcp, timeout: 10_000)
%{"result" => %{"capabilities" => capabilities}} = Jason.decode!(response)# Filesystem MCP server
{:ok, fs} = Denox.Run.start_link(
package: "@modelcontextprotocol/server-filesystem",
permissions: :all,
args: ["/path/to/allowed/directory"]
)
# Any npm package that provides a CLI
{:ok, pid} = Denox.Run.start_link(
package: "cowsay",
permissions: :all,
args: ["Hello from Elixir!"]
){:ok, mcp} = Denox.Run.start_link(
package: "@modelcontextprotocol/server-github",
permissions: :all,
env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}
)
# Subscribe to receive all stdout lines as messages
Denox.Run.subscribe(mcp)
# Messages arrive as:
# {:denox_run_stdout, pid, line}
# {:denox_run_exit, pid, exit_status}# Add to your supervision tree
children = [
{Denox.Run,
package: "@modelcontextprotocol/server-github",
permissions: :all,
env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token},
name: :github_mcp}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Use the named process
Denox.Run.send(:github_mcp, request)
{:ok, response} = Denox.Run.recv(:github_mcp)Download and cache the official Deno binary for the current platform (macOS/Linux, x86_64/aarch64). Similar to how tailwind or esbuild hex packages manage their binaries.
# config/config.exs
config :denox, :cli, version: "2.1.4"# Download the binary
mix denox.cli.install# Use in code
{:ok, path} = Denox.CLI.bin_path() # downloads if needed
Denox.CLI.installed?() # check without downloadingDenox.CLI.Run provides the same send/recv/subscribe API as Denox.Run, but uses the bundled binary as a subprocess:
{:ok, pid} = Denox.CLI.Run.start_link(
file: "scripts/server.ts",
permissions: :all,
env: %{"API_KEY" => key}
)Resolve bare specifiers to file paths or URLs:
{:ok, rt} = Denox.runtime(
base_dir: "/path/to/project",
import_map: %{
"utils" => "file:///path/to/project/utils.js",
"mylib/" => "file:///path/to/project/lib/"
}
)
# JavaScript can now use bare specifiers
{:ok, _} = Denox.eval_async(rt, """
const { helper } = await import("utils");
const { add } = await import("mylib/math.ts");
""")Call Elixir functions from JavaScript:
# Create a runtime with callbacks
{:ok, rt, handler} = Denox.CallbackHandler.runtime(
callbacks: %{
"greet" => fn [name] -> "Hello, #{name}!" end,
"add" => fn [a, b] -> a + b end,
"get_user" => fn [id] -> %{id: id, name: "User #{id}"} end
}
)
# JavaScript calls Elixir functions synchronously
{:ok, result} = Denox.eval(rt, ~s[Denox.callback("greet", "Alice")])
# result => "\"Hello, Alice!\""
{:ok, result} = Denox.eval(rt, ~s[Denox.callback("add", 10, 20)])
# result => "30"Note: Custom snapshots are currently not compatible with the
deno_runtimeMainWorker. Thesnapshot:option is accepted for API compatibility but ignored at runtime. Useeval/2orexec/2for initialization code instead.
create_snapshot/2 can still be used to serialize global JS state:
# Create a snapshot from setup code
{:ok, snapshot} = Denox.create_snapshot("""
globalThis.helper = (x) => x * 2;
""")
assert is_binary(snapshot) and byte_size(snapshot) > 0
# TypeScript snapshots (transpiled before snapshotting)
{:ok, snapshot} = Denox.create_snapshot(
"globalThis.add = (a: number, b: number): number => a + b",
transpile: true
)Import directly from CDNs — no tooling required:
{:ok, rt} = Denox.runtime(cache_dir: "_denox/cache")
{:ok, result} = Denox.eval_async(rt, """
const { z } = await import("https://esm.sh/zod@3.22");
return z.string().parse("hello");
""")Manage npm/jsr packages via deno.json:
{
"imports": {
"zod": "npm:zod@^3.22",
"lodash": "npm:lodash-es@^4.17",
"@std/path": "jsr:@std/path@^1.0"
}
}# Install dependencies (requires deno CLI)
mix denox.install
# Add/remove dependencies
mix denox.add zod "npm:zod@^3.22"
mix denox.remove zod# Create a runtime with installed deps
{:ok, rt} = Denox.Deps.runtime()Bundle npm packages into self-contained JS files:
mix denox.bundle npm:zod@3.22 priv/bundles/zod.js{:ok, rt} = Denox.runtime()
:ok = Denox.Npm.load(rt, "priv/bundles/zod.js")Denox emits telemetry events for all eval operations:
| Event | Measurements | Metadata |
|---|---|---|
[:denox, :eval, :start] |
%{system_time: integer} |
%{type: atom} |
[:denox, :eval, :stop] |
%{duration: integer} |
%{type: atom} |
[:denox, :eval, :exception] |
%{duration: integer} |
%{type: atom, kind: :error, reason: term} |
Types: :eval, :eval_ts, :eval_async, :eval_ts_async, :eval_module, :eval_file
- NIF bridge: Rustler connects Elixir to Rust
- V8 isolate: Each runtime gets a dedicated OS thread (V8 requires LIFO drop ordering)
- TypeScript:
deno_ast/swc transpiles types away without type-checking - Module loading: Custom
ModuleLoaderhandlesfile://andhttps://schemes - Async: Event loop pumping for Promises and dynamic imports
- Safety: All NIFs run on dirty CPU schedulers; V8 runtimes are Mutex-protected
MIT