Skip to content

elixir-vibe/safe_rpc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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, and shutdown
  • use SafeRPC.Server callback 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:

  1. token/operation capability checks with SafeRPC.Capability
  2. 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 arbitrary client-selected MFA on the wire. Services built with use SafeRPC expose explicitly marked module/function pairs:

SafeRPC.call(client, {MyApp, :status}, %{})

Adapter services may still define their own operation terms, but use SafeRPC keeps operation identity aligned with Elixir modules and functions.

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.

Local binding terms

Local service discovery metadata is a plain Erlang term encoded as ETF:

binary = :erlang.term_to_binary(bindings)
bindings = :erlang.binary_to_term(binary, [:safe])

The standard binding term is a map from service name to connection metadata:

%{
  catalog: %{
    socket: "/run/apps/catalog/rpc.sock",
    modules: [Catalog.API, Catalog.Admin],
    listener: :rpc,
    upstream: "unix:/run/apps/catalog/rpc.sock",
    unit: "app-catalog.service"
  }
}

Only :socket is required by SafeRPC. :modules is the module-level capability metadata exposed by the deployer; exact callable functions still come from SafeRPC.describe/2. Other keys are operational metadata for supervisors, deploy tools, and diagnostics.

A consumer should get the ETF path from its runtime environment or convention and then call SafeRPC directly:

bindings =
  System.fetch_env!("HOSTKIT_RPC_BINDINGS")
  |> File.read!()
  |> :erlang.binary_to_term([:safe])

SafeRPC.call(bindings.catalog.socket, {Catalog.API, :status})

SafeRPC does not require a binding-file loader module; the file is just an ETF-encoded SafeRPC.local_bindings() term.

Elixir-native services

Use SafeRPC directly in an application module when you want a small Erlang-distribution-like API without exposing arbitrary remote MFA. Only functions marked with @rpc are callable; operation identity is {Module, function}.

defmodule MyApp do
  use SafeRPC, service: :my_app

  @rpc true
  @doc "Return available models."
  @spec models(map(), map(), term()) :: {:ok, [map()]} | {:error, term()}
  def models(_payload, _meta, _state), do: {:ok, [%{id: "small"}]}

  @rpc true
  @doc "Return service status."
  @spec status(map(), map(), term()) :: {:ok, map()}
  def status(_payload, _meta, state), do: {:ok, %{state: state}}

  def local_helper, do: :not_exposed
end

Serve it with the normal adapter server wrapper:

defmodule MyApp.RPCServer do
  use SafeRPC.Adapter.Server, service: MyApp
end

Call operations normally:

{:ok, models} = SafeRPC.call(socket, {MyApp, :models})

Discover the exposed service descriptor:

{:ok, descriptor} = SafeRPC.describe(socket)
descriptor.modules[MyApp].ops.models.docs

Descriptors include exposed modules, operation names, docs from @doc, and typespec metadata from @spec. SafeRPC does not define a separate schema language; adapters can translate Elixir typespec metadata to other protocols if needed.

For HTTP forwarding, expose an operation such as :http_request with @rpc the same way as any other operation.

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.

About

Capability-scoped BEAM-native RPC over Erlang external term format.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors