asobi_rpc (asobi v0.84.0)

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, 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 ws frame is gated by socket auth, a per-connection 60/s message cap and a 64 KiB frame cap; an http POST is gated by asobi_auth_plugin (401), the api rate limiter (300/s per client IP) and the 1 MiB asobi_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 over http. The accepted request size is a property of the transport; only the reply envelope is shared.
  • Protocol versioning differs. The ws payload carries a protocol field, so a foreign version answers rpc.unsupported_protocol. The HTTP controller injects protocol server-side and versions through the URL (/api/v1/...) instead, so rpc.unsupported_protocol cannot fire over http by 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

caller()

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

ctx()

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

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

dispatch/2

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

envelope/1

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

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.