asobi_extensions (asobi v0.84.0)
View SourceThe extension registry: a pure memoised function, not a process.
resolve/0 loads the host's application closure, discovers every application
exporting an <app>_extension module, reads its manifest, validates the set
and writes the result to persistent_term. Whoever calls first pays for it;
everybody after reads a term.
It cannot be a process, and this is the constraint that decides the shape.
nova is in asobi's applications list, so OTP starts Nova first, and
nova_sup:init/1 calls setup_cowboy/1 -> nova_router:compile/1 ->
asobi_router:routes/1 before asobi_app:start/2 ever runs. The router
is therefore the first caller, and at that moment no asobi process exists.
A registry gen_server, a supervised child or a step in an asobi boot sequence
would all be populated after the route table was already compiled.
Its callers, in boot order:
asobi_router:routes/1, inside Nova's boot.asobi_app:start/2, before migrations.asobi_repo:migration_apps/0, when kura discovers migrations.
Discovery walks the OTP application graph, not Nova's nova_apps.
nova_apps only sees Nova apps, so a Lua-only or jobs-only extension would be
invisible, and Nova's own resolve_nova_apps reverses its accumulator at
every nesting level so its output is neither depth-first nor declaration
order. application:get_key(App, applications) is the authoritative start
order, and the closure walk emits each application after everything it
depends on, so the discovered list is already topologically ordered with ties
in the host's declaration order. Nothing sorts it afterwards.
Core loads the closure before discovering. In a release every application is
loaded by the boot script before any start; under rebar3 shell and CT they
load lazily, and without the sweep the discovered set would differ between
dev and prod with no error.
rebar3 asobi check is the primary gate and runs check/0, the same
function, without memoising. Boot validation is a backstop, and it is a
backstop specifically because a failure raised from inside nova_sup:init/1
surfaces with Nova's crash context rather than a legible asobi error.
Summary
Types
One lua/0 binding with the namespace and function name it was declared under.
One resolved extension. sup/0 is deliberately absent; see sup_specs/1.
Functions
Discover, read and validate without memoising.
Renders validation problems as lines a human can act on.
Push a named event to a player from an extension's own Erlang code.
Every error code the installed extensions mint, as Code => {Status, Message}.
Whether a request path (as normalised segments, trim_all) is served by a
mounted security => webhook route.
Every game.<ns>.<fn> binding the installed extensions declare, flattened.
The operator action Extension declares as Action, or undefined.
The installed extensions, in dependency order. Memoised.
Every RPC method the installed extensions declare, as Method => {M, F, A}.
The child specs an extension wants asobi to supervise.
Namespace disjointness across the declared set, and against core's reserved names.
Types
-type binding() :: #{namespace := asobi_extension:lua_namespace(), function := binary(), mfa := mfa(), args := [asobi_extension:lua_arg()], effects := asobi_lua_surface:effect(), vms := [asobi_lua_surface:vm_kind()]}.
One lua/0 binding with the namespace and function name it was declared under.
-type extension() :: #{app := atom(), module := module(), name := asobi_extension:name(), extension_version := pos_integer(), requires := [asobi_extension:name()], rpc := asobi_extension:rpc(), ops := asobi_extension:ops(), lua := asobi_extension:lua(), owns := asobi_extension:owns(), codes := asobi_extension:codes(), routes := [asobi_extension:route()]}.
One resolved extension. sup/0 is deliberately absent; see sup_specs/1.
-type problem() :: {bad_manifest, atom(), module(), term()} | {duplicate_name, asobi_extension:name(), atom(), atom()} | {namespace_conflict, asobi_extension_reserved:kind(), asobi_extension:token(), atom(), atom()} | {reserved_namespace, asobi_extension_reserved:kind(), asobi_extension:token(), atom()} | {undeclared_claim, asobi_extension_reserved:kind(), asobi_extension:token(), atom()} | {route_conflict, {asobi_extension:token(), atom()}, {asobi_extension:token(), atom()}} | {reserved_route, asobi_extension:token(), asobi_extension:token(), atom()} | {reserved_prefix, asobi_extension:token(), asobi_extension:token(), atom()} | {unsatisfied_requirement, atom(), asobi_extension:name()} | {requirement_out_of_order, atom(), asobi_extension:name()} | {self_requirement, atom(), asobi_extension:name()}.
Functions
Discover, read and validate without memoising.
The build-time gate (rebar3 asobi check) and the boot backstop run exactly
this, so the two can never disagree on anything derived from the loaded
application set. The one env-dependent input is the co-mounted http reserved
set, which reads nova_apps: the rebar3_asobi_check plugin sets it from
the host's release sys_config before calling this, so the gate matches boot,
and falls back to reserving nothing extra only when that config cannot be
read - in which case boot, which does load sys.config, remains the backstop
that refuses.
Renders validation problems as lines a human can act on.
-spec emit(atom(), binary(), binary(), map()) -> ok | {error, asobi_error:object()}.
Push a named event to a player from an extension's own Erlang code.
Event is <domain>.<name>: each half is [A-Za-z0-9_-] (the exact charset
asobi_ws_handler:is_event_name_char/1 allows, so an event name and a
game.broadcast name are validated one way), a single dot separates them, and
the whole is at most 64 bytes. domain must be an RPC prefix Extension owns -
derived exactly as an error code's domain is (see asobi_extension). Data
is always a JSON object; a non-map is a caller bug and fails the guard.
The ownership check is namespace hygiene, not an authorization boundary: it
stops an extension minting events in a namespace it does not own, the same
anti-collision rule its error codes obey. Any first-party extension could call
asobi_presence:send/2 directly, so nothing downstream should treat a
successful emit/4 as proof of a privilege.
On success the event reaches the player's live sessions as a module.event
wire frame (asobi_ws_handler). This is an Erlang-only seam: only an
extension's own code calls it, and it has no Lua binding.
Returns {error, asobi_error:object()}, never a crash, for: an event name
outside the charset/shape or over 64 bytes (event.invalid_name); a domain
Extension does not own (event.unowned_domain); Data that is not
JSON-encodable (event.invalid_data); or encoded Data at or over 64 KiB
(event.payload_too_large). Validating encodability here - one extra encode off
the per-tick hot path - is what keeps a bad term from reaching the socket and
dropping every one of the player's sessions.
-spec error_codes() -> #{asobi_error:code() => {pos_integer(), binary()}}.
Every error code the installed extensions mint, as Code => {Status, Message}.
asobi_error reads this rather than resolving, so building an error object
never triggers discovery and never raises: before resolve/0 has run there
are no extension codes, and resolve/0 runs inside Nova's boot, long before
any request can fail.
Whether a request path (as normalised segments, trim_all) is served by a
mounted security => webhook route.
Read by asobi_rate_limit_plugin to pick the webhook bucket, so like
rpc_methods/0 it reads the memoised table and never triggers discovery.
Before resolve/0 has run there are no webhook routes and no traffic.
-spec lua_bindings() -> [binding()].
Every game.<ns>.<fn> binding the installed extensions declare, flattened.
asobi_lua_api filters this by the VM kind it is installing into and
installs what remains. Flat rather than nested because every consumer wants
one pass over bindings, not a walk over namespaces.
-spec ops_action(binary(), binary()) -> asobi_extension:ops_entry() | undefined.
The operator action Extension declares as Action, or undefined.
Read on two request paths - the capability check in asobi_ops_caps and the
dispatch in asobi_ops_extension - so like rpc_methods/0 it reads the
memoised table and never triggers discovery. undefined for an extension that
is not installed and for an action it does not declare, which is one answer
because they are one outcome: nothing to route to, and nothing to authorise.
-spec resolve() -> [extension()].
The installed extensions, in dependency order. Memoised.
Raises {asobi_extensions, Problems} on an invalid set, after logging the
same problems in prose. A node that cannot say which extension owns a
namespace must not serve traffic under either.
-spec rpc_methods() -> #{asobi_extension:method() => mfa()}.
Every RPC method the installed extensions declare, as Method => {M, F, A}.
asobi_rpc reads this rather than resolving, for the same reason
error_codes/0 exists: dispatch is on a request path and must never trigger
discovery. Before resolve/0 has run the table is empty, and resolve/0 runs
inside Nova's boot, long before a socket exists.
Methods cannot collide across extensions. A method's prefix is a derived rpc
claim, and validate/1 refuses two extensions claiming one prefix, so
flattening the declared maps together cannot lose an entry.
-spec sup_specs(module()) -> [supervisor:child_spec()].
The child specs an extension wants asobi to supervise.
Called from asobi_extension_child_sup:init/1 rather than cached, so the
running code always decides.
Namespace disjointness across the declared set, and against core's reserved names.
The claim set per namespace is owns/0 plus what the extension's own code
already implies, and every kind derives:
| Kind | Derived from |
|---|---|
rpc | the prefixes in rpc/0 and the domains in codes/0 |
lua | the namespaces in lua/0 |
tables | table/0 on the extension's kura_schema modules |
queues | queue/0 on the extension's shigoto_worker modules |
http | the paths in routes/0 |
So two extensions installing the same game.quests, or the same job queue,
collide before either has bothered with owns/0.
The http kind alone compares structurally rather than by token equality:
two paths collide when the routing tree cannot serve both - some request
matches both patterns, or the patterns diverge at a binding at any depth, in
which case routing_tree's commit-to-first-sibling lookup lets one swallow
requests meant for the other (see paths_collide/2). Its reserved set is
every co-mounted app's route table - core's own via
asobi_router:core_routes/0, plus nova's and every app in nova_apps, which
is what keeps /health and its siblings unclaimable - and a claim under a
privileged plane prefix (asobi_extension_reserved:route_prefixes/0) is
refused outright, whatever it would resolve to.
Tables and queues derive through asobi_extension_reserved, the same rule
that finds core's own tables and queues, so a name core reserves and a name an
extension claims can never be found two different ways.
That leaves owns/0 doing one job, and it is worth stating because it is no
longer the source of anything: it is the closed-set assertion over what was
derived. Naming a kind at all says "this is the complete set", and anything
derived outside it is undeclared_claim - which is what turns a typo in
owns.queues from an invisible no-op into a build failure. Nothing in
owns/0 is load-bearing for collision detection any more.
requires/0 is checked here too, against the union of core's subsystem names
(asobi_extension_reserved:core_capabilities/0) and the installed extensions'
own names. It reads Extensions in the order given, which is the resolver's
dependency order, so the extension-ordering rule is a position check rather
than a second sort.