SuperAPI AI Directory

denox

Coding & development

View repository

Updated 10-15 21:55

About this skill

Denox — Embed Deno JS/TS Runtime in Elixir

Overview

Denox embeds a Deno V8 runtime into Elixir via a Rustler NIF. Evaluate JS/TS, load ES modules, import from CDNs/npm/jsr, call functions across the boundary, and run full Deno programs as OTP-supervised subprocesses — all from Elixir.

Two modes of operation:

  1. In-process eval — sub-millisecond V8 eval via NIF (Denox.eval, Denox.call, etc.)
  2. Subprocess runner — managed deno run process with bidirectional stdio (Denox.Run)

Installation

# mix.exs
{:denox, "~> 0.4.0"}

Precompiled NIFs available for macOS and Linux (x86_64, aarch64). Local build requires Rust (stable); first compile ~20-30 min (V8 builds from source). Force local build with DENOX_BUILD=true mix compile.

Optional JSON config (Elixir 1.18+ has built-in JSON):

config :denox, :json_module, Jason  # default is JSON

Core API

Runtime Creation

{:ok, rt} = Denox.runtime(
  base_dir: "lib/js",           # resolve relative module imports
  sandbox: true,                # disable fs/net extensions
  cache_dir: "_denox/cache",    # disk cache for remote modules
  import_map: %{"utils" => "file:///path/to/utils.js"},
  callback_pid: pid,            # enable JS->Elixir callbacks
  snapshot: snapshot_bytes       # V8 snapshot for fast cold start
)

Evaluation Functions

Function Purpose Event Loop Returns
Denox.eval(rt, js) Eval JS No {:ok, json_string} | {:error, msg}
Denox.eval_ts(rt, ts) Transpile+eval TS No {:ok, json_string} | {:error, msg}
Denox.eval_async(rt, js) Eval with await/import() Yes Task.t()
Denox.eval_ts_async(rt, ts) Async TS eval Yes Task.t()
Denox.exec(rt, code) Eval, discard result No :ok | {:error, msg}
Denox.exec_ts(rt, code) TS eval, discard result No :ok | {:error, msg}
Denox.eval_module(rt, path) Load ES module (import/export) Yes {:ok, _} | {:error, msg}
Denox.eval_file(rt, path, opts) Read+eval file No {:ok, json_string} | {:error, msg}
Denox.eval_file_async(rt, path, opts) Read+eval file async Yes Task.t()
Denox.call(rt, name, args) Call named JS function No {:ok, json_string} | {:error, msg}
Denox.call_async(rt, name, args) Call async JS function Yes Task.t()

Decode variants (parse JSON result into Elixir terms): eval_decode, eval_ts_decode, eval_async_decode, eval_ts_async_decode, eval_file_decode, eval_file_async_decode, call_decode, call_async_decode

Await helper for async functions:

task = Denox.eval_async(rt, "return await Promise.resolve(42)")
{:ok, "42"} = Denox.await(task)        # default 5s timeout
{:ok, "42"} = Denox.await(task, 10_000) # custom timeout

Sync vs Async

Use eval/call for simple expressions. Use eval_async/call_async when code contains:

  • await or Promises
  • Dynamic import()
  • setTimeout/setInterval or event-loop-dependent code
# Sync — fast, no event loop
{:ok, "3"} = Denox.eval(rt, "1 + 2")

# Async — pumps event loop, code wrapped in async IIFE
# Use `return` to get the result back
{:ok, "42"} = Denox.eval_async(rt, "return await Promise.resolve(42)") |> Denox.await()

TypeScript

Transpile-only via deno_ast/swc — no type-checking (same as deno run without --check). Type errors like const x: string = 42 transpile without error.

{:ok, "42"} = Denox.eval_ts(rt, "const x: number = 42; x")

Files with .ts, .tsx, .mts, .cts extensions are auto-transpiled by eval_file.

Function Calls

Define functions in JS, call from Elixir with JSON-serializable args:

Denox.exec(rt, "globalThis.add = (a, b) => a + b")
{:ok, "5"} = Denox.call(rt, "add", [2, 3])
{:ok, 5} = Denox.call_decode(rt, "add", [2, 3])

ES Modules

{:ok, rt} = Denox.runtime(base_dir: "/path/to/project")
{:ok, _} = Denox.eval_module(rt, "/path/to/project/main.ts")

CDN Imports

{: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");
""") |> Denox.await()

Must use eval_async — dynamic import() returns a Promise.

Import Maps

{:ok, rt} = Denox.runtime(
  import_map: %{
    "lodash" => "https://esm.sh/lodash-es@4.17.21",
    "lodash/" => "https://esm.sh/lodash-es@4.17.21/"
  }
)

{:ok, result} = Denox.eval_async(rt, """
  const { default: add } = await import("lodash/add");
  return add(10, 32);
""") |> Denox.await()

Runtime Pool

V8 isolates are single-threaded — one runtime handles one eval at a time. Use Denox.Pool for concurrent workloads:

# Supervision tree
children = [{Denox.Pool, name: :js_pool, size: 4}]

# Usage (round-robin across runtimes)
{:ok, result} = Denox.Pool.eval(:js_pool, "1 + 2")
Denox.Pool.load_npm(:js_pool, "priv/bundles/zod.js")  # loads into ALL runtimes

Pool options: :name (required), :size (default: System.schedulers_online()), plus all runtime options.

JS -> Elixir Callbacks

{:ok, rt, _handler} = Denox.CallbackHandler.runtime(
  callbacks: %{
    "greet" => fn [name] -> "Hello, #{name}!" end,
    "add" => fn [a, b] -> a + b end
  }
)

{:ok, _} = Denox.eval(rt, ~s[Denox.callback("greet", "Alice")])

Callback functions receive a list of decoded JSON arguments. Calls are synchronous — JS blocks until Elixir returns.

V8 Snapshots

Pre-initialize global state for instant startup:

{:ok, snap} = Denox.create_snapshot("globalThis.helper = (x) => x * 2")
{:ok, rt} = Denox.runtime(snapshot: snap)
{:ok, "10"} = Denox.call(rt, "helper", [5])

# TypeScript snapshots
{:ok, snap} = Denox.create_snapshot(
  "globalThis.add = (a: number, b: number) => a + b",
  transpile: true
)

Denox.Run — Managed Deno Subprocesses

Run full Deno programs (MCP servers, CLI tools, scripts) as OTP-supervised subprocesses with bidirectional stdio.

Starting a Subprocess

# Run an npm package
{:ok, pid} = Denox.Run.start_link(
  package: "@modelcontextprotocol/server-github",
  permissions: :all,
  env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}
)

# Run a local file
{:ok, pid} = Denox.Run.start_link(
  file: "scripts/server.ts",
  permissions: [allow_net: true, allow_env: ["API_KEY"]],
  args: ["--port", "3000"]
)

Options:

  • :package or :file — what to run (one required)
  • :permissions — :all for -A, or keyword list
  • :env — map of environment variables
  • :args — extra arguments after the specifier
  • :deno_flags — extra flags before the specifier
  • :name — GenServer registration name

Bare @scope/name specifiers are automatically prefixed with npm:.

Permission Mapping

# Full permissions
permissions: :all  # → deno run -A

# Granular permissions
permissions: [
  allow_net: true,                    # → --allow-net
  allow_net: ["api.github.com"],      # → --allow-net=api.github.com
  allow_env: ["TOKEN", "API_KEY"],    # → --allow-env=TOKEN,API_KEY
  allow_read: ["/data"],              # → --allow-read=/data
  allow_write: ["/tmp"],              # → --allow-write=/tmp
  allow_run: true,                    # → --allow-run
  allow_ffi: true,                    # → --allow-ffi
  allow_sys: true,                    # → --allow-sys
  allow_hrtime: true                  # → --allow-hrtime
]

Communicating via stdio

Request-response (blocking):

:ok = Denox.Run.send(pid, ~s|{"jsonrpc":"2.0","id":1,"method":"initialize"}|)
{:ok, response} = Denox.Run.recv(pid, timeout: 5000)

Pub/sub (message-based):

Denox.Run.subscribe(pid)

receive do
  {:denox_run_stdout, ^pid, line} -> IO.puts("Output: #{line}")
  {:denox_run_exit, ^pid, status} -> IO.puts("Exited: #{status}")
end

Denox.Run.unsubscribe(pid)

Lifecycle

Denox.Run.alive?(pid)                    # → true | false
{:ok, os_pid} = Denox.Run.os_pid(pid)    # → OS process ID
Denox.Run.stop(pid)                       # graceful shutdown

MCP Server Example

{:ok, pid} = Denox.Run.start_link(
  package: "@modelcontextprotocol/server-github",
  permissions: :all,
  env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => System.get_env("GITHUB_TOKEN")}
)

# Initialize the MCP server
init = Jason.encode!(%{
  jsonrpc: "2.0", id: 1, method: "initialize",
  params: %{
    protocolVersion: "2024-11-05",
    capabilities: %{},
    clientInfo: %{name: "my-app", version: "1.0"}
  }
})

:ok = Denox.Run.send(pid, init)
{:ok, response} = Denox.Run.recv(pid, timeout: 5000)
# → {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},...}}

Supervision

children = [
  {Denox.Run,
    name: :mcp_github,
    package: "@modelcontextprotocol/server-github",
    permissions: :all,
    env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}}
]

Supervisor.start_link(children, strategy: :one_for_one)

Dependency Management

Denox.Deps (vendored via deno.json)

Requires deno CLI at build-time only.

# Install from deno.json
Denox.Deps.install()

# Add/remove deps
Denox.Deps.add("zod", "npm:zod@^3.22")
Denox.Deps.remove("zod")

# Create runtime with installed deps
{:ok, rt} = Denox.Deps.runtime()

Mix tasks: mix denox.install, mix denox.add <name> <spec>, mix denox.remove <name>.

Denox.Npm (pre-bundling)

Bundle npm packages into self-contained JS files:

Denox.Npm.bundle!("npm:zod@3.22", "priv/bundles/zod.js")
:ok = Denox.Npm.load(rt, "priv/bundles/zod.js")

Mix task: mix denox.bundle npm:zod@3.22 priv/bundles/zod.js [--minify]

Mix Tasks

Task Purpose
mix denox.install Install deps from deno.json
mix denox.add <name> <spec> Add dependency
mix denox.remove <name> Remove dependency
mix denox.bundle <spec> <out> Bundle package to JS file
mix denox.run [flags] <specifier> Run Deno program with stdio forwarding

mix denox.run

Drop-in replacement for deno run with stdin/stdout forwarding:

mix denox.run -A @modelcontextprotocol/server-github
mix denox.run --allow-net --allow-env=GITHUB_TOKEN npm:some-tool
mix denox.run -A scripts/server.ts -- --port 3000

Telemetry

Events emitted via :telemetry:

Event Measurements Metadata
[:denox, :eval, :start] system_time type
[:denox, :eval, :stop] duration type
[:denox, :eval, :exception] duration type, kind, reason
[:denox, :run, :start] system_time package, file
[:denox, :run, :stop] system_time package, file, exit_status

Eval types: :eval, :eval_ts, :eval_async, :eval_ts_async, :eval_module, :eval_file, :call

Built-in Web/Node.js Globals

Every Denox runtime comes with comprehensive web-standard and Node.js-compatible globals polyfilled out of the box — no extra configuration needed. JS code evaluated via Denox.eval or Denox.eval_async can use these immediately:

  • Console: console.log, console.warn, console.error, console.info, console.debug, console.dir, console.table, console.time/timeEnd, console.count/countReset, console.assert, console.group/groupEnd, console.clear
  • Encoding: atob, btoa, TextEncoder, TextDecoder
  • Timers: setTimeout, setInterval, clearTimeout, clearInterval (require eval_async to execute)
  • Fetch API: fetch, Headers, Request, Response (real HTTP via native ops — fetch requires eval_async)
  • Crypto: crypto.getRandomValues, crypto.randomUUID
  • URL: URL, URLSearchParams
  • Events: Event, EventTarget, AbortController, AbortSignal, DOMException
  • Structured data: structuredClone, queueMicrotask
  • Performance: performance.now(), performance.timeOrigin
  • Navigator: navigator.userAgent ("Denox"), navigator.language ("en"), navigator.hardwareConcurrency
# Base64 encoding — works in sync eval
{:ok, ~s("SGVsbG8=")} = Denox.eval(rt, ~s[btoa("Hello")])

# Fetch — requires async eval (returns Promise)
task = Denox.eval_async(rt, "return (await fetch('https://api.example.com/data')).status")
{:ok, "200"} = Denox.await(task, 10_000)

# Crypto
{:ok, "true"} = Denox.eval(rt, """
  var uuid = crypto.randomUUID();
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(uuid)
""")

Common Mistakes

Mistake Fix
Using eval with await/import() Use eval_async — eval doesn't pump the event loop
Expecting type errors from eval_ts Denox is transpile-only, no type-checking
Sharing one runtime across concurrent tasks Use Denox.Pool — V8 isolates are single-threaded
Forgetting return in eval_async Async wraps code in IIFE; use return for the result
CDN import without cache_dir Set cache_dir to avoid re-fetching on every runtime
Running untrusted code without sandbox Use sandbox: true to disable fs/net extensions
Using bare @scope/pkg with Denox.eval_async CDN imports need full URL; bare specifiers need Denox.Run or import maps