elixiroh
Elixir bindings to iroh — dial-by-key p2p networking with QUIC connections, relay fallback and hole punching.
elixiroh wraps the iroh Rust crate in a
Rustler NIF. The API surface mirrors
iroh-ffi (the official Python,
Swift, Kotlin and Node bindings): connections only — blobs and docs are not
exposed until they stabilize upstream.
Installation
Requires Elixir ≥ 1.20, Erlang/OTP ≥ 27 and a Rust toolchain (Rustler
compiles the NIF on first mix compile).
def deps do
[
{:elixiroh, "~> 0.3.0"}
]
end
Quick start: a supervised echo server
defmodule MyApp.Echo do
use Elixiroh.Handler
@impl Elixiroh.Handler
def handle_stream({:bi, send, recv}, _conn, state) do
for chunk <- recv do
:ok = Elixiroh.SendStream.write_all(send, chunk)
end
:ok = Elixiroh.SendStream.finish(send)
{:continue, state}
end
end
# In your supervision tree:
children = [
{Elixiroh.Server,
handler: MyApp.Echo,
alpns: ["my-proto/1"],
bind_opts: [preset: :minimal, relay_mode: :disabled]}
]
Quick start: a client
{:ok, endpoint} = Elixiroh.Endpoint.bind(preset: :minimal, relay_mode: :disabled)
{:ok, conn} = Elixiroh.Endpoint.connect(endpoint, server_addr, "my-proto/1")
{:ok, send, recv} = Elixiroh.Connection.open_bi(conn)
:ok = Elixiroh.SendStream.write_all(send, "hello")
:ok = Elixiroh.SendStream.finish(send)
{:ok, "hello"} = Elixiroh.RecvStream.read_to_end(recv, 1024)
Sharing the address
The quick start runs fully offline — the :minimal preset has no
relays and no lookup service. For real deployments use the default
:n0 preset (production relays plus DNS lookup by endpoint id) and
share a ticket — the connection info in a copyable string:
# Server side: start the supervision tree, then use the server pid
{:ok, server} = Supervisor.start_link(children, strategy: :one_for_one)
ticket = Elixiroh.Server.addr(server) |> Elixiroh.EndpointTicket.from_addr()
# deliver `ticket` to the client out of band
send_it_to_the_client(to_string(ticket))
# Client side:
{:ok, ticket} = Elixiroh.EndpointTicket.from_string(str)
{:ok, conn} = Elixiroh.Endpoint.connect(endpoint, ticket, "my-proto/1")
Configuration
Elixiroh.Endpoint.bind/2 starts from a preset (:n0 for the n0
production network, :minimal for offline use) and overrides from
there: custom relays (:relay_mode), your own DNS discovery origin
(:lookup), both IP families (:bind_addr list), TLS debugging
(:keylog), and a :transport keyword list mapping 1:1 onto iroh's
QUIC transport config — idle timeout, keep-alive, stream limits, flow
windows, MTU. Every key and default is documented in
Elixiroh.Endpoint's moduledoc; Elixiroh.Server forwards all of it
through :bind_opts.
The blocking model
Every wait-heavy function blocks the calling process and returns the
completed result, the way :gen_tcp does. Blocking is a selective receive,
so it holds no scheduler and costs only a suspended process — concurrency
is just process count:
conns =
peers
|> Task.async_stream(&Elixiroh.Endpoint.connect(endpoint, &1, alpn))
|> Enum.map(fn {:ok, result} -> result end)
Waits default to :infinity (accepts, reads, writes — they complete on
their own once the peer acts or the connection goes away); bind/2 and
connect/3 default to 5 000 ms to fail fast on unresponsive peers. Every
blocking function takes a trailing timeout argument for a per-call bound.
Cheap getters (alpn/1, remote_id/1, stream id/1) and queue-only
operations (send_datagram/2, Connection.close/1) return immediately
without blocking.
API map
| Area | Modules |
|---|---|
| Lifecycle | Elixiroh.Endpoint (bind, connect, accept, close), Elixiroh.Connection, Elixiroh.Incoming |
| Streams | Elixiroh.SendStream, Elixiroh.RecvStream (Enumerable/Collectable) |
| Identity | Elixiroh.EndpointId, Elixiroh.SecretKey, Elixiroh.Signature |
| Addressing | Elixiroh.EndpointAddr, Elixiroh.EndpointTicket |
| Server | Elixiroh.Server, Elixiroh.Handler |
| Observability | Elixiroh.Telemetry |
| Subscriptions | Elixiroh.Watcher |
| Errors | Elixiroh.Error |
Semantics worth knowing
- iroh streams are lazy. The peer's
accept_bi/accept_unionly completes after the sender writes data. - Native objects are garbage collected. Endpoints, connections and
streams are released safely when their handles are collected; call
Elixiroh.Endpoint.close/1for a graceful shutdown. Pending operations complete with errors when their subject is collected or closed. - Watchers deliver messages. Subscriptions send
{:elixiroh, event, value}to the owner process and stop withElixiroh.Watcher.stop/1or when their handle is collected.
Observability
Start Elixiroh.Telemetry in your supervision tree and every iroh log line
becomes both a [:elixiroh, :log] telemetry event and a standard Logger
entry — attach handlers like any other BEAM library. The level comes from
config :elixiroh, :log_level, falling back to the Logger level:
config :elixiroh, log_level: :debug
Stress testing
scripts/stress_connections.exs drives echo traffic at scale (--mode connections | streams | concurrent | peers); mix test --only stress runs the ExUnit scenarios. Measured results live in
BENCHMARKS.md.
Contributing
Patches by mail, the sourcehut way:
$ git config sendemail.to ~ebi/elixiroh@lists.sr.ht
$ git send-email -1
License
MIT OR Apache-2.0, like iroh itself. See LICENSE-MIT and
LICENSE-Apache-2.0 in the repository root.