SafeRPC
SafeRPC is a GenServer-like RPC protocol for explicit, capability-scoped APIs over Erlang external term format.
It is intended for boundaries where both sides want BEAM-native terms without HTTP overhead, but where regular Erlang distribution or raw remote MFA would expose too much authority.
Current status
Early prototype.
Implemented:
- Unix socket transport
- transport behaviour
- packet-framed ETF requests
:erlang.binary_to_term(binary, [:safe])decoding- one-shot and persistent client-process
call/cast - persistent server-side connections
- request id tracking for multiple in-flight client requests
- sharded client pools
- Task-like async requests with
async,await,yield, andshutdown use SafeRPC.Servercallback wrapper- per-request capability checks
- optional generic authorizer hook
- request cancellation
- framework-agnostic adapter behaviours and HTTP envelopes
Example
defmodule EchoServer do
use SafeRPC.Server
def init(opts), do: {:ok, %{count: Keyword.get(opts, :count, 0)}}
def handle_call(:echo, payload, state), do: {:reply, {:ok, payload}, state}
def handle_call(:count, _payload, state), do: {:reply, {:ok, state.count}, state}
def handle_cast(:inc, amount, state), do: {:noreply, %{state | count: state.count + amount}}
end
{:ok, server} = EchoServer.start_link(socket: "/tmp/echo.sock")
{:ok, client} = SafeRPC.Client.start_link(socket: "/tmp/echo.sock")
{:ok, %{hello: :world}} = SafeRPC.call(client, :echo, %{hello: :world})
{:ok, :noreply} = SafeRPC.cast(client, :inc, 1)
{:ok, 1} = SafeRPC.call(client, :count)
request = SafeRPC.async(client, :echo, %{hello: :async})
{:ok, %{hello: :async}} = SafeRPC.await(request, 5_000)
long_request = SafeRPC.async(client, :long_operation, %{}, timeout: 30_000)
:ok = SafeRPC.cancel(long_request)
{:ok, pool} = SafeRPC.ClientPool.start_link(socket: "/tmp/echo.sock", shards: 4)
{:ok, 1} = SafeRPC.ClientPool.call(pool, {:tenant, :alice}, :count)
Authorization
SafeRPC has two generic authorization layers:
- token/operation capability checks with
SafeRPC.Capability - an optional authorizer callback
cap = SafeRPC.Capability.new(token: "secret", ops: [:echo])
{:ok, pid} = EchoServer.start_link(socket: "/tmp/echo.sock", capability: cap)
{:ok, :allowed} = SafeRPC.call("/tmp/echo.sock", :echo, :allowed, cap: "secret")
{:error, :unauthorized} = SafeRPC.call("/tmp/echo.sock", :count, %{}, cap: "secret")
For app-specific policy, pass an authorizer module:
defmodule MyAuthorizer do
@behaviour SafeRPC.Authorizer
def authorize(%{op: :status}, _context), do: :ok
def authorize(_request, _context), do: {:error, :forbidden}
end
{:ok, server} = EchoServer.start_link(socket: "/tmp/echo.sock", authorizer: MyAuthorizer)
SafeRPC does not define users, tenants, roles, sessions, or resource semantics. Those belong in the application authorizer.
Comparison with existing options
Erlang distribution / :rpc / :erpc
Erlang distribution is the most native way to talk between BEAM nodes, but it is designed for trusted clusters. Once nodes are connected, the trust boundary is broad: remote process interaction, code loading assumptions, global names, and cookie-based node authentication are intentionally powerful.
SafeRPC is narrower. It uses Erlang terms, but exposes only explicit operations handled by a server callback module.
Use Erlang distribution when all nodes are trusted peers. Use SafeRPC when the remote side should only get a small, auditable API surface.
priestjim/gen_rpc
gen_rpc is a scalable replacement-style library for Erlang rpc. It provides TCP/SSL transports, async calls, multicall, per-key sharding, and module allow/deny lists.
Its public API is remote MFA:
gen_rpc:call(Node, Module, Function, Args).
SafeRPC intentionally does not expose client-selected MFA on the wire. Clients call named operations:
SafeRPC.call(client, :status, %{})
Internally, SafeRPC may route an operation to MFA later, but authorization remains operation/resource-oriented rather than module-oriented.
SafeRPC borrows ideas from gen_rpc—persistent connections, acceptor/connection supervision, async requests, sharding, and fanout—but keeps the protocol smaller and capability-scoped.
HTTP / JSON APIs
HTTP is the best default for public APIs, browser clients, proxies, and language-neutral integrations. It has excellent tooling and operational visibility.
SafeRPC is for BEAM-native or local control-plane APIs where HTTP routing, JSON encoding, headers, and text parsing are unnecessary overhead. Payloads are Erlang external terms, decoded with:
:erlang.binary_to_term(binary, [:safe])
gRPC
gRPC is a strong choice for polyglot service APIs with schema-first contracts, streaming, and mature client generation.
SafeRPC is smaller and BEAM-focused. It does not require protobuf schemas and preserves Elixir/Erlang terms, but it is not intended as a universal cross-language RPC layer.
Adapter layer
SafeRPC includes a small framework-agnostic adapter namespace. The core adapter layer does not depend on Phoenix, Ash, Livery, or any web framework.
Use SafeRPC.Adapter.Service to expose application operations:
defmodule MyService do
@behaviour SafeRPC.Adapter.Service
def init(_opts), do: {:ok, %{}}
def call(:status, _payload, meta, state) do
{:ok, %{status: :ok, meta: meta, state: state}}
end
end
defmodule MyRPCServer do
use SafeRPC.Adapter.Server, service: MyService
end
{:ok, server} = MyRPCServer.start_link(socket: "/tmp/my.sock")
{:ok, %{status: :ok}} = SafeRPC.call("/tmp/my.sock", :status, %{}, meta: %{trace_id: "..."})
For route tables, use SafeRPC.Adapter.Dispatcher with explicit op-to-MFA mappings:
routes = %{
status: {MyAPI, :status, 3},
user_by_id: {MyAPI, :user_by_id, 3}
}
SafeRPC.Adapter.Dispatcher.call(routes, :status, payload, meta, state)
For HTTP bridges, use the neutral envelopes:
%SafeRPC.Adapter.HTTP.Request{}
%SafeRPC.Adapter.HTTP.Response{}
Framework-specific code should mostly live outside SafeRPC:
- xamal_proxy: Livery request/response <-> SafeRPC adapter HTTP envelopes
- Plug/Phoenix: adapter HTTP envelope <-> Plug endpoint via
SafeRPC.Adapter.Plug - Ash: adapter service operation <-> Ash action
The Plug adapter is included because Phoenix endpoints are Plug endpoints and the dependency boundary remains generic Plug, not Phoenix-specific.
Design direction
SafeRPC should borrow scalability patterns from priestjim/gen_rpc without adopting its public remote-MFA trust model:
- persistent client connections
- acceptor/connection supervision
- request-worker supervision for long-running handlers
- per-key sharded client pools
- async call/yield
- multicall/fanout
- transport behaviour for Unix/TCP/TLS/stdio
The wire API should remain explicit operation dispatch, with MFA only as an internal routing implementation detail.