Packages

macula

0.8.12
7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src macula_client.erl
Raw

src/macula_client.erl

%%%-------------------------------------------------------------------
%%% @doc
%%% Macula SDK - Main API module for HTTP/3 mesh client operations.
%%%
%%% This module provides the primary interface for applications to
%%% connect to Macula mesh networks and perform pub/sub and RPC
%%% operations over HTTP/3 (QUIC) transport.
%%%
%%% == Quick Start ==
%%%
%%% Connect to a mesh, publish events, subscribe to topics, and make RPC calls.
%%% See individual function documentation for detailed examples with code.
%%%
%%% == DHT Network Bootstrap (v0.8.7+) ==
%%%
%%% Macula v0.8.7+ implements platform-level DHT bootstrapping. Each macula node
%%% automatically joins the configured DHT network on startup via the
%%% `MACULA_BOOTSTRAP_PEERS` environment variable.
%%%
%%% <b>Platform Configuration (Recommended):</b>
%%% ```
%%% # Bootstrap node (no peers configured)
%%% MACULA_BOOTSTRAP_PEERS= # empty - this IS a bootstrap peer
%%%
%%% # Other nodes (connect to bootstrap)
%%% MACULA_BOOTSTRAP_PEERS=https://bootstrap-node:4433
%%% '''
%%%
%%% <b>Client SDK Usage:</b>
%%% Applications connect to their LOCAL macula instance, which is already part
%%% of the DHT network:
%%% ```
%%% {ok, Client} = macula_client:connect(&lt;&lt;"https://localhost:4433"&gt;&gt;, #{
%%% realm => &lt;&lt;"my.app"&gt;&gt;
%%% }).
%%% '''
%%%
%%% The platform handles DHT network formation - applications don't need to
%%% manage bootstrap peer URLs.
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula_client).
%% API exports
-export([
connect/2,
connect_local/1,
disconnect/1,
publish/3,
publish/4,
subscribe/3,
unsubscribe/2,
discover_subscribers/2,
call/3,
call/4,
advertise/3,
advertise/4,
unadvertise/2,
get_node_id/1
]).
%% Type exports
-export_type([
client/0,
topic/0,
event_data/0,
procedure/0,
args/0,
options/0,
subscription_ref/0
]).
%%%===================================================================
%%% Types
%%%===================================================================
-type client() :: pid().
%% Reference to a connected Macula mesh client.
-type topic() :: binary().
%% Topic name for pub/sub operations. Topics should describe event types,
%% not entity IDs. Example: `"my.app.user.registered"' (good),
%% not `"my.app.user.123.registered"' (bad - ID belongs in payload).
-type event_data() :: map() | binary().
%% Event payload data. Typically a map that will be JSON-encoded.
-type procedure() :: binary().
%% RPC procedure name. Example: `"my.app.get_user"'.
-type args() :: map() | list() | binary().
%% Arguments for RPC calls.
-type options() :: map().
%% Connection or operation options.
-type subscription_ref() :: reference().
%% Reference to an active subscription for unsubscribe operations.
%%%===================================================================
%%% API Functions
%%%===================================================================
%% @doc Connect to a Macula mesh network.
%%
%% Creates a new HTTP/3 (QUIC) connection to the specified mesh endpoint.
%%
%% == Options ==
%%
%% <ul>
%% <li>`realm' - Required. Binary realm identifier (e.g., `&lt;&lt;"my.app.realm"&gt;&gt;')</li>
%% <li>`auth' - Optional. Authentication map with `api_key' or other auth methods</li>
%% <li>`timeout' - Optional. Connection timeout in milliseconds (default: 5000)</li>
%% <li>`node_id' - Optional. 32-byte node ID (generated if not provided)</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Basic connection
%% {ok, Client} = macula_client:connect(&lt;&lt;"https://mesh.local:443"&gt;&gt;, #{
%% realm => &lt;&lt;"my.realm"&gt;&gt;
%% }).
%%
%% %% With API key authentication
%% {ok, Client} = macula_client:connect(&lt;&lt;"https://mesh.local:443"&gt;&gt;, #{
%% realm => &lt;&lt;"my.realm"&gt;&gt;,
%% auth => #{api_key => &lt;&lt;"secret-key"&gt;&gt;}
%% }).
%% '''
-spec connect(Url :: binary(), Opts :: options()) ->
{ok, client()} | {error, Reason :: term()}.
connect(Url, Opts) when is_binary(Url), is_map(Opts) ->
macula_peer:start_link(Url, Opts);
connect(Url, Opts) when is_list(Url), is_map(Opts) ->
connect(list_to_binary(Url), Opts).
%% @doc Connect to the local Macula gateway (for in-VM workloads).
%%
%% This function is used by applications running in the same BEAM VM as
%% the Macula platform. Instead of creating a QUIC connection to localhost,
%% it connects directly to the local `macula_gateway' process via
%% process-to-process communication.
%%
%% == Architecture ==
%%
%% ```
%% Phoenix/Elixir App → macula_local_client → macula_gateway
%% ↓ (QUIC)
%% Other Peers
%% '''
%%
%% == When to Use ==
%%
%% <ul>
%% <li>✅ Use `connect_local/1' when your application runs in the same VM as Macula</li>
%% <li>✅ Phoenix applications deployed with Macula in the same container</li>
%% <li>❌ Do NOT use `connect/2' with localhost URL - it creates unnecessary QUIC overhead</li>
%% </ul>
%%
%% == Options ==
%%
%% <ul>
%% <li>`realm' - Required. Binary realm identifier (e.g., `&lt;&lt;"my.app.realm"&gt;&gt;')</li>
%% <li>`event_handler' - Optional. PID to receive events (default: caller PID)</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Elixir Phoenix application
%% {:ok, client} = :macula_client.connect_local(%{
%% realm: "macula.arcade.dev"
%% })
%%
%% %% Erlang application
%% {ok, Client} = macula_client:connect_local(#{
%% realm => &lt;&lt;"my.app.realm"&gt;&gt;
%% }).
%% '''
%%
%% @since v0.8.9
-spec connect_local(Opts :: options()) ->
{ok, client()} | {error, Reason :: term()}.
connect_local(Opts) when is_map(Opts) ->
macula_local_client:start_link(Opts).
%% @doc Disconnect from the Macula mesh.
%%
%% Cleanly closes the HTTP/3 connection and cleans up all subscriptions.
-spec disconnect(Client :: client()) -> ok | {error, Reason :: term()}.
disconnect(Client) when is_pid(Client) ->
macula_peer:stop(Client).
%% @doc Publish an event to a topic.
%%
%% Publishes data to the specified topic. All subscribers to this topic
%% will receive the event.
%%
%% == Topic Design ==
%%
%% Topics should describe EVENT TYPES, not entity instances:
%% <ul>
%% <li>Good: `&lt;&lt;"my.app.user.registered"&gt;&gt;' (event type)</li>
%% <li>Bad: `&lt;&lt;"my.app.user.123.registered"&gt;&gt;' (entity ID in topic)</li>
%% </ul>
%%
%% Entity IDs belong in the event payload, not the topic name.
%%
%% == Examples ==
%%
%% ```
%% %% Publish with default options
%% ok = macula_client:publish(Client, &lt;&lt;"my.app.events"&gt;&gt;, #{
%% type => &lt;&lt;"user.registered"&gt;&gt;,
%% user_id => &lt;&lt;"user-123"&gt;&gt;,
%% email => &lt;&lt;"user@example.com"&gt;&gt;
%% }).
%%
%% %% Publish with options
%% ok = macula_client:publish(Client, &lt;&lt;"my.app.events"&gt;&gt;, #{
%% data => &lt;&lt;"important"&gt;&gt;
%% }, #{acknowledge => true}).
%% '''
-spec publish(Client :: client(), Topic :: topic(), Data :: event_data()) ->
ok | {error, Reason :: term()}.
publish(Client, Topic, Data) when is_pid(Client), is_binary(Topic) ->
macula_peer:publish(Client, Topic, Data).
%% @doc Publish an event with options.
-spec publish(Client :: client(), Topic :: topic(), Data :: event_data(),
Opts :: options()) ->
ok | {error, Reason :: term()}.
publish(Client, Topic, Data, Opts) when is_pid(Client), is_binary(Topic), is_map(Opts) ->
macula_peer:publish(Client, Topic, Data, Opts).
%% @doc Subscribe to a topic.
%%
%% Subscribes to events on the specified topic. The callback function
%% will be invoked for each event received.
%%
%% == Callback Function ==
%%
%% The callback receives the event data and should return `ok'.
%%
%% == Examples ==
%%
%% ```
%% %% Simple subscription
%% {ok, SubRef} = macula_client:subscribe(Client, &lt;&lt;"my.app.events"&gt;&gt;,
%% fun(EventData) ->
%% io:format("Event: ~p~n", [EventData]),
%% ok
%% end).
%%
%% %% Unsubscribe later
%% ok = macula_client:unsubscribe(Client, SubRef).
%% '''
-spec subscribe(Client :: client(), Topic :: topic(),
Callback :: fun((event_data()) -> ok)) ->
{ok, subscription_ref()} | {error, Reason :: term()}.
subscribe(Client, Topic, Callback) when is_pid(Client), is_binary(Topic), is_function(Callback, 1) ->
macula_peer:subscribe(Client, Topic, Callback).
%% @doc Unsubscribe from a topic.
%%
%% Removes the subscription identified by the subscription reference.
-spec unsubscribe(Client :: client(), SubRef :: subscription_ref()) ->
ok | {error, Reason :: term()}.
unsubscribe(Client, SubRef) when is_pid(Client), is_reference(SubRef) ->
macula_peer:unsubscribe(Client, SubRef).
%% @doc Discover subscribers to a topic via DHT query.
%%
%% Queries the DHT for all nodes subscribed to the given topic.
%% Returns a list of subscriber nodes with their node IDs and endpoints.
%%
%% This is used for P2P discovery before sending direct messages.
-spec discover_subscribers(Client :: client(), Topic :: topic()) ->
{ok, [#{node_id := binary(), endpoint := binary()}]} | {error, Reason :: term()}.
discover_subscribers(Client, Topic) when is_pid(Client), is_binary(Topic) ->
macula_peer:discover_subscribers(Client, Topic).
%% @doc Get the node ID of this client.
%%
%% Returns the 32-byte node ID assigned to this client.
-spec get_node_id(Client :: client()) -> {ok, binary()} | {error, Reason :: term()}.
get_node_id(Client) when is_pid(Client) ->
macula_peer:get_node_id(Client).
%% @doc Make a synchronous RPC call.
%%
%% Calls a remote procedure and waits for the result.
%%
%% == Examples ==
%%
%% ```
%% %% Simple RPC call
%% {ok, User} = macula_client:call(Client, &lt;&lt;"my.app.get_user"&gt;&gt;, #{
%% user_id => &lt;&lt;"user-123"&gt;&gt;
%% }).
%%
%% %% With timeout
%% {ok, Result} = macula_client:call(Client, &lt;&lt;"my.app.process"&gt;&gt;,
%% #{data => &lt;&lt;"large"&gt;&gt;},
%% #{timeout => 30000}).
%% '''
-spec call(Client :: client(), Procedure :: procedure(), Args :: args()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call(Client, Procedure, Args) when is_pid(Client), is_binary(Procedure) ->
macula_peer:call(Client, Procedure, Args).
%% @doc Make an RPC call with options.
-spec call(Client :: client(), Procedure :: procedure(), Args :: args(),
Opts :: options()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call(Client, Procedure, Args, Opts) when is_pid(Client), is_binary(Procedure), is_map(Opts) ->
macula_peer:call(Client, Procedure, Args, Opts).
%% @doc Advertise a service that this client provides.
%%
%% Registers a handler function for the specified procedure and advertises
%% it to the DHT so other clients can discover and call it.
%%
%% The handler function receives a map of arguments and must return
%% `{ok, Result}' or `{error, Reason}'.
%%
%% == Options ==
%%
%% <ul>
%% <li>`ttl' - Advertisement TTL in seconds (default: 300)</li>
%% <li>`metadata' - Custom metadata map (default: #{})</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Define a handler function
%% Handler = fun(#{user_id := UserId}) ->
%% {ok, #{user_id => UserId, name => &lt;&lt;"Alice"&gt;&gt;}}
%% end.
%%
%% %% Advertise the service
%% {ok, Ref} = macula_client:advertise(
%% Client,
%% &lt;&lt;"my.app.get_user"&gt;&gt;,
%% Handler
%% ).
%%
%% %% Other clients can now call:
%% %% {ok, User} = macula_client:call(OtherClient, &lt;&lt;"my.app.get_user"&gt;&gt;,
%% %% #{user_id => &lt;&lt;"user-123"&gt;&gt;}).
%% '''
-spec advertise(Client :: client(), Procedure :: procedure(),
Handler :: macula_service_registry:handler_fn()) ->
{ok, reference()} | {error, Reason :: term()}.
advertise(Client, Procedure, Handler) when is_pid(Client), is_binary(Procedure), is_function(Handler) ->
advertise(Client, Procedure, Handler, #{}).
%% @doc Advertise a service with options.
-spec advertise(Client :: client(), Procedure :: procedure(),
Handler :: macula_service_registry:handler_fn(),
Opts :: options()) ->
{ok, reference()} | {error, Reason :: term()}.
advertise(Client, Procedure, Handler, Opts) when is_pid(Client), is_binary(Procedure),
is_function(Handler), is_map(Opts) ->
macula_peer:advertise(Client, Procedure, Handler, Opts).
%% @doc Stop advertising a service.
%%
%% Removes the local handler and stops advertising to the DHT.
%%
%% == Examples ==
%%
%% ```
%% ok = macula_client:unadvertise(Client, &lt;&lt;"my.app.get_user"&gt;&gt;).
%% '''
-spec unadvertise(Client :: client(), Procedure :: procedure()) ->
ok | {error, Reason :: term()}.
unadvertise(Client, Procedure) when is_pid(Client), is_binary(Procedure) ->
macula_peer:unadvertise(Client, Procedure).
%%%===================================================================
%%% Internal functions
%%%===================================================================