asobi_rpc (asobi v0.84.0)
View SourceThe extension RPC dispatcher: rpc.call in, rpc.ok or rpc.error out.
rpc/0 in an extension's manifest declares Method => {M, F, A}. This is
what reads it. Without this module the declaration is validated and then
nothing calls it, which is the state the first real extension reported.
The frame
{"type": "rpc.call", "cid": "c-1",
"payload": {"protocol": 1, "method": "quests.claim", "params": {"quest_id": "q-1"}}}{"type": "rpc.ok", "cid": "c-1", "payload": {"result": {"reward": 100}}}
{"type": "rpc.error", "cid": "c-1", "payload": {"error": {"code": "...", "message": "...", "details": {}}}}cidis required and validated here, unlike the rest of the socket where it is an optional echo. An RPC reply is useless without correlation - it is the only way a client pairsrpc.okwith the call it made - and the value is echoed into a frame, so it is bounded to 1-64 printable ASCII bytes rather than reflected unchecked. A rejectedcidis not echoed back; there is nothing trustworthy to echo.paramsandresultare always objects. A bare scalar cannot grow a field later without breaking every client, and this is the one wire shape seven SDKs must agree on.protocolis a number, currently1. Version the payload, not the frame type, so a future version is a rejection a client can read rather than anunknown_type.
The handler contract
-spec claim(asobi_rpc:params(), asobi_rpc:ctx()) -> asobi_rpc:reply().
claim(#{~"quest_id" := QuestId}, #{player_id := PlayerId}) ->
case asobi_quests:claim(PlayerId, QuestId) of
{ok, Reward} -> {ok, #{reward => Reward}};
{error, already_done} -> {error, ~"quests.already_claimed"}
end.(Params, Ctx) -> {ok, map()} | {error, Code} | {error, Code, Details}, and
the arity in rpc/0 is therefore always 2.
The failure half is a code, not a status and an object. Both of those are
derivable from the code - asobi_error:status/1 and asobi_error:object/2 -
and an extension mints its own codes in its own domain (codes/0), so
returning a status would let two call sites answer the same code differently
and would put object construction in every extension. It is also the dialect
core's own controllers already speak ({asobi_error, Code, Details}), so
there is one shape to learn rather than two.
Ctx is #{player_id, session, method, transport} and may gain keys. Match
the ones you need with :=; never match it exhaustively.
Ctx.session is a process, but which process depends on Ctx.transport. Over
ws it is the durable player-session pid. Over http
(POST /api/v1/rpc/<method>, asobi_rpc_controller) it is the request
process, alive only for the one call. A handler that must reach the player
after it returns checks transport =:= ws before touching session, and
otherwise keys on player_id through presence (the module.event path) -
never the session pid, which over HTTP is dead the moment the reply is sent.
An extension error surfaces as its own code, provided the extension
declared it in codes/0. A handler that raises, returns outside the contract,
or returns a code nobody declared becomes internal, each logged as the
extension defect it is. The last of those is what keeps the code set closed on
this surface: every other call site's code is a binary literal checked at build
time, and a handler's is a runtime term that could have been built from
params.
Readiness
The route table compiles inside nova_sup:init/1; migrations run afterwards
from asobi_app:start/2. A socket is therefore reachable before an
extension's tables exist, so every dispatch passes asobi_readiness:guard/0
first and answers not_ready (503) until migrations have finished.
Authorisation
Every declared method is player-scoped: the caller is the authenticated
player on that socket, and an unauthenticated socket gets unauthenticated
before anything is looked up. There is deliberately no per-method capability
class. read | player_data | config (ADR 0007) is an operator
vocabulary, minted by asobi_ops_auth for the ops plane and never held by
a player, so tagging a socket method with one would make it deniable for
every caller this dispatcher has - another declaration nothing can reach.
The reachable home for an operator-only extension method is the ops plane,
which is read-only today by assertion, so opening it is a decision about that
plane rather than about this manifest.
Transports
Two transports reach this dispatcher and share dispatch/2 and envelope/1,
so the reply envelope is byte-identical between them. What they do NOT share is
everything in front of the dispatcher, and these divergences are frozen:
- The dispatcher enforces no auth, no rate limit and no size cap. Each
transport enforces its own, separately, before calling in. A
wsframe is gated by socket auth, a per-connection 60/s message cap and a 64 KiB frame cap; anhttpPOSTis gated byasobi_auth_plugin(401), the api rate limiter (300/s per client IP) and the 1 MiBasobi_body_cap_plugin. A third transport must supply its own; it must not assume the dispatcher does. - The request-size caps differ and do not converge - 64 KiB over
ws, 1 MiB overhttp. The accepted request size is a property of the transport; only the reply envelope is shared. - Protocol versioning differs. The
wspayload carries aprotocolfield, so a foreign version answersrpc.unsupported_protocol. The HTTP controller injectsprotocolserver-side and versions through the URL (/api/v1/...) instead, sorpc.unsupported_protocolcannot fire overhttpby design.
Summary
Types
The authenticated player behind the transport, or unauthenticated.
What a handler is told about its caller. Additive; never match it exhaustively.
The params object, exactly as the client sent it. Keys are binaries.
What a handler returns.
Functions
Dispatch one call, without a transport.
The rpc.ok / rpc.error wire payload for a dispatch outcome.
Dispatch one rpc.call.
The RPC protocol version this node speaks.
Types
-type caller() :: #{player_id := binary(), session := pid(), transport => ws | http} | unauthenticated.
The authenticated player behind the transport, or unauthenticated.
transport marks which one so a handler can branch on Ctx.transport; it is
optional here and defaults to ws in invoke/5, so a caller built without it
stays safe.
-type ctx() :: #{player_id := binary(), session := pid(), method := binary(), transport := ws | http}.
What a handler is told about its caller. Additive; never match it exhaustively.
The params object, exactly as the client sent it. Keys are binaries.
-type reply() :: {ok, map()} | {error, asobi_error:code()} | {error, asobi_error:code(), asobi_error:details()}.
What a handler returns.
Functions
-spec dispatch(term(), caller()) -> {ok, map()} | {error, asobi_error:object()}.
Dispatch one call, without a transport.
handle/3 is this behind the socket's cid validation;
asobi_rpc_controller is this behind HTTP request parsing. Both hand the
same #{"protocol", "method", "params"} payload and the same caller(), and
both encode the outcome through envelope/1, so a given call answers
byte-identically on either transport below the transport itself.
-spec envelope({ok, map()} | {error, asobi_error:object()}) -> {binary(), map()}.
The rpc.ok / rpc.error wire payload for a dispatch outcome.
The single source of truth for the pair, shared by the socket encoder
(asobi_ws_handler:encode_rpc/2) and the HTTP controller so a frozen payload
cannot drift between them: {ok, Result} becomes
{~"rpc.ok", #{~"result" => Result}}, and {error, Object} becomes
{~"rpc.error", Object} - the error object alone, never wrapped again.
-spec handle(term(), term(), caller()) -> {binary() | undefined, {ok, map()} | {error, asobi_error:object()}}.
Dispatch one rpc.call.
Returns the cid to echo - undefined when the client's own cid was
rejected - and the outcome to encode.
-spec protocol() -> pos_integer().
The RPC protocol version this node speaks.