Denox
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.
Features
- 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
Installation
Add denox to your list of dependencies in mix.exs:
def deps do
[
{:denox, "~> 0.5"}
]
endBuild requirements
The 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 compileQuick Start
# 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")Runtime Pool
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)")
Long-lived Deno Runtime (Denox.Run)
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)Running other MCP servers
# 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!"]
)Subscribe to output
{: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}Supervision
# 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)
Bundled Deno Binary (Denox.CLI)
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}
)Import Maps
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");
""")JS → Elixir Callbacks
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"V8 Snapshots
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
)CDN Imports
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");
""")Dependency Management
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()Pre-Bundling
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")Telemetry
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
Architecture
- 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
License
MIT