Denox.Run (denox v0.7.0)

Copy Markdown View Source

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 filesfile: "path/to/script.ts" (absolute or relative path)
  • HTTPS/HTTP modulespackage: "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}

  • [:denox, :run, :stop] — emitted when the runtime exits

    • Measurements: %{system_time: integer}
    • Metadata: %{package: string | nil, file: string | nil, exit_status: integer, backend: :nif}

  • [:denox, :run, :recv] — emitted for each stdout line received

    • Measurements: %{system_time: integer}
    • Metadata: %{line_bytes: integer, backend: :nif}

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 32

For 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

t()

@type t() :: %Denox.Run{
  backend_state: term(),
  exit_status: term(),
  file: term(),
  package: term(),
  recv_waiters: term(),
  stdout_buffer: term(),
  subscribers: term()
}

Functions

alive?(server)

@spec alive?(GenServer.server()) :: boolean()

Check if the runtime is still running.

capture(opts)

@spec capture(keyword()) :: {:ok, [String.t()]} | {:error, term()}

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", ...]

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

os_pid(server)

@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.

recv(server, opts \\ [])

@spec recv(
  GenServer.server(),
  keyword()
) :: {:ok, String.t()} | {:error, :timeout | :closed}

Receive the next line from stdout.

Options

  • :timeout - milliseconds to wait (default: 5000)

send(server, data)

@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.

send_and_recv(server, data, opts \\ [])

@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)

start_link(opts)

@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 (-A in 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 both allow_* and deny_* 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]; 0 or omitted uses the default of 1024 lines.

stop(server)

@spec stop(GenServer.server()) :: :ok

Stop the runtime.

stream(opts)

@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.

stream_from(server, opts \\ [])

@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.

subscribe(server)

@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.

unsubscribe(server)

@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.

with_runtime(opts, fun)

@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)