Run Deno programs as NIF-backed long-lived runtimes.
Uses an in-process deno_runtime MainWorker (no external deno binary
required) wrapped in a GenServer with bidirectional stdio and OTP supervision.
Supported Specifiers
Denox.Run uses a built-in module loader that supports:
- Local files —
file: "path/to/script.ts"(absolute or relative path) - HTTPS/HTTP modules —
package: "https://deno.land/x/pkg/mod.ts"
npm: and jsr: packages are not supported by the NIF backend.
The NIF module loader has no Node.js resolver or npm registry client.
For npm/jsr packages, use Denox.CLI.Run which delegates to the bundled
Deno binary that handles all module schemes natively.
Examples
# Run a local script
{:ok, pid} = Denox.Run.start_link(
file: "scripts/server.ts",
permissions: :all
)
# Send JSON-RPC via stdin
:ok = Denox.Run.send(pid, ~s|{"jsonrpc":"2.0","method":"initialize","id":1}|)
# Receive response from stdout
{:ok, line} = Denox.Run.recv(pid, timeout: 5000)
# Or subscribe to all output
Denox.Run.subscribe(pid)
# => receives {:denox_run_stdout, ^pid, line} messages
# Load an HTTPS module
{:ok, pid} = Denox.Run.start_link(
package: "https://deno.land/x/cowsay/mod.ts",
permissions: :all
)
# For npm/jsr packages, use Denox.CLI.Run instead:
{:ok, pid} = Denox.CLI.Run.start_link(
package: "@modelcontextprotocol/server-github",
permissions: :all,
env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => token}
)
# Stop the process
Denox.Run.stop(pid)Convenience Functions
Several higher-level helpers are available for common patterns:
# One-shot: collect all output and return a list
{:ok, lines} = Denox.Run.capture(file: "scripts/build.ts", permissions: :all)
# Lazy stream: process lines one at a time (early exit supported)
Denox.Run.stream(file: "scripts/generate.ts", permissions: :all)
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.to_list()
# Bracket-style: guaranteed cleanup even on exception
Denox.Run.with_runtime([file: "scripts/server.ts", permissions: :all], fn pid ->
:ok = Denox.Run.send(pid, Jason.encode!(%{method: "init"}))
{:ok, response} = Denox.Run.recv(pid, timeout: 10_000)
Jason.decode!(response)
end)
# Bracket + lazy stream from an existing PID
Denox.Run.with_runtime([file: "scripts/gen.ts", permissions: :all], fn pid ->
:ok = Denox.Run.send(pid, Jason.encode!(request))
Denox.Run.stream_from(pid, timeout: 10_000) |> Enum.to_list()
end)Telemetry Events
Denox.Run emits the following telemetry events:
[:denox, :run, :start]— emitted when the runtime starts- Measurements:
%{system_time: integer} Metadata:
%{package: string | nil, file: string | nil, backend: :nif}
- Measurements:
[:denox, :run, :stop]— emitted when the runtime exits- Measurements:
%{system_time: integer} Metadata:
%{package: string | nil, file: string | nil, exit_status: integer, backend: :nif}
- Measurements:
[:denox, :run, :recv]— emitted for each stdout line received- Measurements:
%{system_time: integer} - Metadata:
%{line_bytes: integer, backend: :nif}
- Measurements:
Environment Variables
The :env option passes environment variables to the Deno runtime via
Deno.env.get(). The variables are set in the OS process environment
before the worker starts. Note: concurrent Denox.Run instances may
see each other's env vars if started simultaneously. For strict isolation,
use Denox.CLI.Run with a subprocess-per-instance model, or ensure env
var names are unique across instances.
Thread Scaling
Each Denox.Run instance occupies 1 OS thread (the event loop thread
running the V8 MainWorker) plus 1 dirty I/O scheduler slot while
runtime_run_recv blocks waiting for stdout.
OTP provides 10 dirty I/O schedulers by default. This limits concurrent
Denox.Run instances to approximately 10 before dirty I/O scheduler
contention occurs. For higher concurrency, increase the dirty I/O scheduler
count via the +SDio N BEAM flag (e.g. in vm.args or rel/vm.args.eex):
## rel/vm.args.eex
+SDio 32For subprocess-based isolation without dirty scheduler limits, use
Denox.CLI.Run instead, which spawns a separate OS process per instance.
Summary
Functions
Check if the runtime is still running.
Run a script and capture all stdout output.
Returns a specification to start this module under a supervisor.
Return the OS PID of the process, if available.
Receive the next line from stdout.
Send data to stdin of the running process.
Send data to stdin and wait for the next stdout line.
Start a managed Deno runtime.
Stop the runtime.
Stream stdout lines from a script as a lazy Stream.
Stream stdout lines from an already-running server as a lazy Stream.
Subscribe the calling process to stdout messages.
Unsubscribe from stdout messages.
Execute a function with a managed runtime, ensuring cleanup on exit.
Types
Functions
@spec alive?(GenServer.server()) :: boolean()
Check if the runtime is still running.
Run a script and capture all stdout output.
Starts a managed runtime, collects all output lines until the script exits or the per-line timeout is reached, then stops the runtime.
Returns {:ok, lines} where lines is a list of stdout lines in order,
or {:error, reason} if the runtime failed to start.
Options
Same as start_link/1, plus:
:timeout- per-line receive timeout in milliseconds (default:30_000). If no new line arrives within this window, collection stops and the lines collected so far are returned.
Example
{:ok, lines} = Denox.Run.capture(
file: "scripts/generate.ts",
permissions: :all
)
# lines => ["line 1", "line 2", ...]
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec os_pid(GenServer.server()) :: {:ok, non_neg_integer()} | {:error, :not_available | :not_running}
Return the OS PID of the process, if available.
Returns {:ok, pid} for CLI-backed runtimes or {:error, :not_available}
for NIF-backed runtimes where no separate OS process exists.
@spec recv( GenServer.server(), keyword() ) :: {:ok, String.t()} | {:error, :timeout | :closed}
Receive the next line from stdout.
Options
:timeout- milliseconds to wait (default: 5000)
@spec send(GenServer.server(), String.t()) :: :ok | {:error, term()}
Send data to stdin of the running process.
A newline (\n) is automatically appended if data does not
already end with one.
Returns :ok on success, or {:error, :closed} if the process
has already exited.
@spec send_and_recv(GenServer.server(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
Send data to stdin and wait for the next stdout line.
A convenience wrapper around send/2 + recv/2 for the common
request-response pattern (e.g. JSON-RPC over stdio, MCP servers).
Returns {:ok, response_line} or {:error, reason} where reason is
:timeout, :closed, or the send error.
Options
:timeout- milliseconds to wait for a response (default: 5000)
Example
request = Jason.encode!(%{jsonrpc: "2.0", method: "ping", id: 1})
{:ok, response_line} = Denox.Run.send_and_recv(pid, request, timeout: 5000)
response = Jason.decode!(response_line)
@spec start_link(keyword()) :: GenServer.on_start()
Start a managed Deno runtime.
Options
:package- JSR/npm package specifier:file- local file path to run:permissions- permission mode; defaults to:none(deny all) when omitted::all— allow all permissions (-Ain Deno CLI):none— deny all permissions (Deno's default behaviour)- keyword list — granular permissions, e.g.
[allow_net: true, allow_read: ["/tmp"], deny_env: true]. Supports bothallow_*anddeny_*keys.
:env- map of environment variables:args- extra arguments after the specifier:name- GenServer name for registration:buffer_size- (NIF backend only) stdout channel capacity in lines; controls how many lines the NIF can buffer before applying backpressure. Range[1, 100_000];0or omitted uses the default of1024lines.
@spec stop(GenServer.server()) :: :ok
Stop the runtime.
@spec stream(keyword()) :: Enumerable.t()
Stream stdout lines from a script as a lazy Stream.
Starts a managed runtime and returns an Enumerable that yields
stdout lines one at a time. The runtime is automatically stopped
when the stream is fully consumed or halted (e.g. via Stream.take/2
or Enum.take/2).
This is useful for processing large outputs without buffering all lines in memory, or for early termination once a condition is met.
Options
Same as start_link/1, plus:
:timeout- per-line receive timeout in milliseconds (default:30_000). If no new line arrives within this window, the stream halts.
Example
# Collect the first 5 lines of a long-running script
Denox.Run.stream(file: "server.ts", permissions: :all)
|> Enum.take(5)
# Filter lines matching a pattern
Denox.Run.stream(file: "generate.ts", permissions: :all)
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.to_list()Errors on start
If the runtime fails to start, the stream raises a RuntimeError
when enumerated. Use capture/1 if you prefer an {:error, reason}
tuple instead.
@spec stream_from( GenServer.server(), keyword() ) :: Enumerable.t()
Stream stdout lines from an already-running server as a lazy Stream.
Unlike stream/1, which starts a new runtime, stream_from/2 works
with an existing server PID. The stream halts when the runtime exits or
a per-line timeout is reached. The server is not stopped when the
stream halts — the caller retains ownership.
This pairs naturally with with_runtime/2 when you want lazy output
enumeration after sending input:
Denox.Run.with_runtime([file: "script.ts", permissions: :all], fn pid ->
:ok = Denox.Run.send(pid, Jason.encode!(request))
Denox.Run.stream_from(pid) |> Enum.to_list()
end)Options
:timeout- per-line receive timeout in milliseconds (default:5000). If no new line arrives within this window, the stream halts.
@spec subscribe(GenServer.server()) :: :ok
Subscribe the calling process to stdout messages.
After subscribing, the calling process will receive:
{:denox_run_stdout, server_pid, line}for each stdout line{:denox_run_exit, server_pid, exit_status}when the process exits
If the subscribing process dies, it is automatically removed from the
subscriber list without needing an explicit unsubscribe/1 call.
@spec unsubscribe(GenServer.server()) :: :ok
Unsubscribe from stdout messages.
The calling process will stop receiving {:denox_run_stdout, ...} and
{:denox_run_exit, ...} messages from this server.
@spec with_runtime( keyword(), (GenServer.server() -> result) ) :: result | {:error, term()} when result: term()
Execute a function with a managed runtime, ensuring cleanup on exit.
Starts a runtime with opts, calls fun with the server PID, then
stops the runtime — even if fun raises an exception.
Returns the value returned by fun. If fun returns an error tuple,
that tuple is passed through as-is. Returns {:error, reason} if the
runtime failed to start.
Example
Denox.Run.with_runtime([file: "scripts/tool.ts", permissions: :all], fn pid ->
:ok = Denox.Run.send(pid, Jason.encode!(%{method: "init"}))
{:ok, line} = Denox.Run.recv(pid, timeout: 10_000)
Jason.decode!(line)
end)