asobi_rpc (asobi v0.72.5)

View Source

The 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": {}}}}
  • cid is 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 pairs rpc.ok with 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 rejected cid is not echoed back; there is nothing trustworthy to echo.
  • params and result are 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.
  • protocol is a number, currently 1. Version the payload, not the frame type, so a future version is a rejection a client can read rather than an unknown_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} and may gain keys. Match the ones you need with :=; never match it exhaustively.

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.

Summary

Types

The authenticated player behind the socket, 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 rpc.call.

The RPC protocol version this node speaks.

Types

caller()

-type caller() :: #{player_id := binary(), session := pid()} | unauthenticated.

The authenticated player behind the socket, or unauthenticated.

ctx()

-type ctx() :: #{player_id := binary(), session := pid(), method := binary()}.

What a handler is told about its caller. Additive; never match it exhaustively.

params()

-type params() :: #{binary() => term()}.

The params object, exactly as the client sent it. Keys are binaries.

reply()

-type reply() ::
          {ok, map()} | {error, asobi_error:code()} | {error, asobi_error:code(), asobi_error:details()}.

What a handler returns.

Functions

handle(Cid, Payload, Caller)

-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.

protocol()

-spec protocol() -> pos_integer().

The RPC protocol version this node speaks.