This package implements SkirRPC directly on the BEAM. Generated Elixir code and runtime request handling do not call Gleam or JavaScript.
The implementation follows the current SkirRPC model: schema methods carry stable numeric IDs, an application registers implementations on a transport-neutral service, HTTP POST carries the compact RPC wire form, GET/POST may expose Studio and method reflection, and a generated client serializes through the same schema types.
Generated method API
Given:
// Load one user.
method GetUser(int64): User = 12345;the Elixir generator emits functions equivalent to:
@spec get_user_method() :: Skir.Method.t()
def get_user_method()
@type get_user_handler ::
(integer(), term() ->
{:ok, User.t() | :skir_default} |
{:error, Skir.RPC.ServiceError.t() | Skir.RPC.UnknownError.t()})
@spec add_get_user(Skir.RPC.Service.t(), get_user_handler()) :: Skir.RPC.Service.t()
def add_get_user(service, handler)
@spec get_user(Skir.RPC.ServiceClient.t(), integer(), keyword()) ::
{:ok, User.t() | :skir_default} | {:error, Skir.RPC.RpcError.t()}
def get_user(client, request, opts \\ [])
@spec get_user!(Skir.RPC.ServiceClient.t(), integer(), keyword()) ::
User.t() | :skir_default
def get_user!(client, request, opts \\ [])methods/0 returns every method declared by the source .skir module.
Service
alias MyApp.Protocol.AccountsSkir
alias MyApp.Protocol.AccountsSkir.User
alias Skir.RPC.Service
service =
Service.new()
|> AccountsSkir.add_get_user(fn id, metadata ->
case MyApp.Accounts.fetch_user(id, metadata) do
{:ok, user} -> {:ok, user}
:missing -> {:error, Skir.RPC.error(404, "user not found")}
{:error, reason} -> {:error, Skir.RPC.unknown_error(inspect(reason))}
end
end)A handler receives the decoded request and an application-defined metadata value.
It returns {:ok, response}, a controlled ServiceError, or an UnknownError.
Controlled errors support the HTTP status codes accepted by SkirRPC and return
their text to the client. Unknown errors are HTTP 500 and hide their details by
default.
Service options:
Service.new(
keep_unrecognized_values: false,
can_send_unknown_error_message: fn error_info -> false end,
error_logger: fn error_info -> Logger.error(inspect(error_info)) end,
studio_app_js_url: "https://cdn.jsdelivr.net/npm/skir-studio/dist/skir-studio-standalone.js",
max_request_bytes: 4_194_304
)keep_unrecognized_values: true is intended only for trusted forwarding
boundaries. It can preserve future fields through an older service.
Service.handle_request/3 is independent of any web framework:
raw = Service.handle_request(service, request_body, request_metadata)
raw.status_code
raw.content_type
raw.dataIt supports:
- compact RPC bodies:
GetUser:12345::<dense-json>; readableRPC bodies:GetUser:12345:readable:<readable-json>;- manual JSON calls:
{"method":"GetUser","request":42}; listreflection for RPC Studio and tooling;- empty or
studiopayloads for RPC Studio HTML.
When the compact request contains a method number, dispatch is by that number
even if the value is 0; name lookup is used only when the numeric slot is empty.
This matches the current TypeScript and Dart SkirRPC implementations and makes a
renamed method safe when callers continue sending its stable numeric ID.
Phoenix and Plug
The runtime has no production dependency on Plug. If the host application uses
Phoenix/Plug, mount Skir.RPC.Plug:
# router.ex
forward "/rpc", Skir.RPC.Plug,
service: &MyApp.RPC.service/0,
metadata: &MyApp.RPC.metadata/1A service can also be supplied directly or as an MFA tuple.
A metadata function can use the full Plug.Conn:
def metadata(conn) do
%{
authorization: Plug.Conn.get_req_header(conn, "authorization"),
remote_ip: conn.remote_ip
}
endWithout a custom function the adapter supplies request headers, HTTP method,
request path, and remote_ip.
The adapter accepts GET and POST. POST bodies are bounded before accumulation;
GET passes the percent-decoded query string to Service.handle_request/3.
Client
client =
Skir.RPC.ServiceClient.new!("https://api.example.com/rpc",
headers: [{"authorization", "Bearer ..."}],
transport_opts: [timeout: 15_000, connect_timeout: 5_000]
)
{:ok, user} = AccountsSkir.get_user(client, 42)
user = AccountsSkir.get_user!(client, 42)The built-in transport uses OTP :httpc with peer verification for HTTPS. For
Req, Finch, Tesla, Mint, or application-specific instrumentation, implement
Skir.RPC.HTTPClient.request/5 and pass the module as transport:. An arity-5
function is also accepted, which makes client tests inexpensive.
The TypeScript client also supports GET RPCs. The Elixir client exposes the same
optional transport form with http_method: :get; POST remains the default:
AccountsSkir.get_user(client, 42, http_method: :get)Per-call headers override defaults case-insensitively:
AccountsSkir.get_user(client, 42,
headers: [{"authorization", "Bearer replacement"}],
timeout: 5_000
)Client RPC errors are %Skir.RPC.RpcError{status_code: code, message: message}.
Status 0 means the HTTP request failed or a successful HTTP response could not
be decoded. For non-2xx HTTP responses, the client exposes the response body only
when its content type is text/plain, matching SkirRPC's error-surface rule.
Studio and reflection
Open /rpc?studio in a browser when using the Plug adapter. The returned Studio
page loads the configured Skir Studio standalone script.
/rpc?list returns each method's name, numeric ID, documentation, request type,
and response type. Type descriptors include nested records, enum payloads,
removed field numbers, documentation, and keyed-array extractors. The reflection
format is generated from the same schema metadata used by serialization.
Resource limits and errors
The service bounds the raw request body before parsing and runs JSON through Skir's nesting/size scanner before passing it to the built-in JSON parser. Decoded request values then go through the normal Skir collection/node/type limits. Handler exceptions, throws, and invalid handler returns become unknown HTTP 500 errors and are hidden unless the service explicitly permits disclosure. Response serialization failures are returned as visible server errors, matching the current TypeScript and Dart implementations.
error_logger receives %Skir.RPC.ErrorInfo{} for controlled and unknown handler
failures. Logger and disclosure callbacks are isolated so a callback failure does
not replace the RPC result.
Protocol scope
SkirRPC is unary request/response RPC over HTTP. Streaming, bidirectional
sessions, and a protocol-level cancellation frame are not part of this wire
protocol. In Elixir, concurrency and cancellation are supplied by the web server
request process or by whatever HTTP transport is used by the caller. Transport
timeouts are forwarded through ServiceClient.
Verification
Run mix test --warnings-as-errors from this repository. The standalone suite
covers serialization, validation, RPC service/client behavior, reflection,
Studio, and the Plug adapter.
Generator tests, generated-code integration, upstream golden vectors, and the
TypeScript serialization interoperability remain in the companion
skir-elixir-gen repository. The raw RPC parity harness runs here.