-module(livery_service).
-moduledoc """
Service runtime.
Brings up H3 on UDP, H2 on TLS, and H1 on TCP under one
supervisor, sharing one router/middleware/handler. Optionally
advertises Alt-Svc on H1 and H2 responses so clients race up to
H3.
Configuration map:
```
livery:start_service(#{
host => <<"example.com">>,
http3 => #{port => 443, cert => Cert, key => Key},
https => #{port => 443, cert => Cert, key => Key},
http => #{port => 80},
handler => fun handler/1,
middleware => Stack,
alt_svc => advertise
}).
```
Supply exactly one of `handler` (a single catch-all) or `router`
(a compiled `livery_router` the service dispatches through, via
`livery:router_handler/1`).
Returns `{ok, ServicePid}`. The service pid owns the listeners and
shuts them down when stopped via `livery:stop_service/1`. A crash
takes them all down together. For a polite shutdown that lets
in-flight requests finish, use `livery:drain/1,2`.
""".
-behaviour(gen_server).
-include("livery.hrl").
-export([
start_link/1,
stop/1,
stop_accepting/1,
which_listeners/1
]).
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
-export_type([service_opts/0]).
-type service_opts() :: #{
host => binary(),
http => listener_opts(),
https => listener_opts(),
http3 => listener_opts(),
%% Supply exactly one of `handler' (a catch-all) or `router' (a
%% compiled livery_router that the service dispatches through).
handler => livery_middleware:handler(),
router => livery_router:router(),
middleware => livery_middleware:stack(),
%% Shared service config, readable in handlers via livery_req:config/1.
%% A per-listener `config' (in the http/https/http3 map) overrides it.
config => term(),
alt_svc => advertise | none
}.
-type listener_opts() :: #{
%% HTTP/3 listener name (atom); auto-derived from the port if absent.
name => atom(),
port => inet:port_number(),
%% Bind address. An IPv6 8-tuple selects the inet6 family.
ip => inet:ip_address(),
%% Bind the IPv6 wildcard (`::') when no explicit `ip' is given.
inet6 => boolean(),
cert => binary() | string(),
key => binary() | string() | term(),
cacerts => [binary()],
acceptors => pos_integer(),
%% H3 per-SNI certificate selection, forwarded to `quic' (>= 1.6.5).
sni_callback => fun(
(binary() | undefined) ->
{ok, #{cert := binary(), key := term(), cert_chain => [binary()]}}
| {error, term()}
),
settings => map(),
quic_opts => map(),
%% HTTP/1.1 early-response inbound-drain budget (lingering close).
%% See `livery_h1' `listen_opts()'. `lingering_timeout => Ms' is the
%% time-only form. Ignored by the H2/H3 listeners.
early_response_drain => 0 | {non_neg_integer() | infinity, non_neg_integer() | infinity},
lingering_timeout => timeout(),
%% Per-listener config; overrides the service-wide `config'.
config => term()
}.
-record(state, {
h1 :: {livery_h1:listener(), inet:port_number()} | undefined,
h2 :: {livery_h2:listener(), inet:port_number()} | undefined,
h3 :: {livery_h3:listener(), inet:port_number()} | undefined
}).
-define(SERVER, ?MODULE).
%%====================================================================
%% Public API
%%====================================================================
-doc "Start a service from a config map.".
-spec start_link(service_opts()) -> {ok, pid()} | {error, term()}.
start_link(Opts) when is_map(Opts) ->
gen_server:start_link(?MODULE, Opts, []).
-doc "Stop a running service.".
-spec stop(pid()) -> ok.
stop(Pid) when is_pid(Pid) ->
gen_server:stop(Pid).
-doc """
Stop the service's listeners (no new connections) while leaving
the gen_server and any in-flight requests running. Used by
`livery_drain` to begin a graceful shutdown.
""".
-spec stop_accepting(pid()) -> ok.
stop_accepting(Pid) when is_pid(Pid) ->
gen_server:call(Pid, stop_accepting).
-doc """
Return the ports the service is bound to, by protocol. Keys are
present only for protocols that were configured.
""".
-spec which_listeners(pid()) -> #{h1 | h2 | h3 => inet:port_number()}.
which_listeners(Pid) ->
gen_server:call(Pid, which_listeners).
%%====================================================================
%% gen_server callbacks
%%====================================================================
-spec init(service_opts()) -> {ok, #state{}} | {stop, term()}.
init(Opts) ->
process_flag(trap_exit, true),
try
Handler = resolve_handler(Opts),
%% Start H3 first so the bound UDP port is known before
%% building the Alt-Svc value used by H1 and H2.
H3 = maybe_start_h3(Opts, base_stack(Opts), Handler),
Stack = build_stack(Opts, H3),
H1 = maybe_start_h1(Opts, Stack, Handler),
H2 = maybe_start_h2(Opts, Stack, Handler),
{ok, #state{h1 = H1, h2 = H2, h3 = H3}}
catch
throw:Reason ->
{stop, Reason};
Class:Reason ->
{stop, {Class, Reason}}
end.
-spec handle_call(term(), {pid(), term()}, #state{}) ->
{reply, term(), #state{}}.
handle_call(stop_accepting, _From, State) ->
_ = stop_h3(State#state.h3),
_ = stop_h2(State#state.h2),
_ = stop_h1(State#state.h1),
{reply, ok, State#state{h1 = undefined, h2 = undefined, h3 = undefined}};
handle_call(which_listeners, _From, State) ->
{reply, listeners_map(State), State};
handle_call(_, _, State) ->
{reply, {error, unknown_call}, State}.
-spec handle_cast(term(), #state{}) -> {noreply, #state{}}.
handle_cast(_, State) -> {noreply, State}.
-spec handle_info(term(), #state{}) -> {noreply, #state{}}.
handle_info(_, State) -> {noreply, State}.
-spec terminate(term(), #state{}) -> ok.
terminate(_Reason, State) ->
_ = stop_h3(State#state.h3),
_ = stop_h2(State#state.h2),
_ = stop_h1(State#state.h1),
ok.
-spec code_change(term(), #state{}, term()) -> {ok, #state{}}.
code_change(_, State, _) -> {ok, State}.
%%====================================================================
%% Internals
%%====================================================================
%% Resolve the effective handler from the config: a compiled
%% `router' (dispatched via livery:router_handler/1) or a single
%% catch-all `handler'. Exactly one must be given.
-spec resolve_handler(service_opts()) -> livery_middleware:handler().
resolve_handler(Opts) ->
case {maps:find(router, Opts), maps:find(handler, Opts)} of
{{ok, _}, {ok, _}} -> throw(both_router_and_handler);
{{ok, Router}, _} -> livery:router_handler(Router);
{_, {ok, H}} -> H;
{error, error} -> throw(no_handler_or_router)
end.
-spec base_stack(service_opts()) -> livery_middleware:stack().
base_stack(Opts) ->
maps:get(middleware, Opts, []).
-spec build_stack(
service_opts(),
{atom(), inet:port_number()} | undefined
) ->
livery_middleware:stack().
build_stack(Opts, H3) ->
User = base_stack(Opts),
case {maps:get(alt_svc, Opts, none), H3} of
{advertise, {_Name, Port}} ->
[{livery_alt_svc, #{value => alt_svc_header(Port)}} | User];
_ ->
User
end.
-spec alt_svc_header(inet:port_number()) -> binary().
alt_svc_header(Port) ->
iolist_to_binary([
<<"h3=\":">>,
integer_to_binary(Port),
<<"\"; ma=86400">>
]).
maybe_start_h1(Opts, Stack, Handler) ->
case maps:find(http, Opts) of
{ok, ListenOpts} ->
ListenOpts1 = maps:merge(
ListenOpts,
#{stack => Stack, handler => Handler, config => listener_config(Opts, ListenOpts)}
),
{ok, Ref} = livery_h1:start(ListenOpts1),
{Ref, h1:server_port(Ref)};
error ->
undefined
end.
maybe_start_h2(Opts, Stack, Handler) ->
case maps:find(https, Opts) of
{ok, ListenOpts} ->
ListenOpts1 = maps:merge(
ListenOpts,
#{
stack => Stack,
handler => Handler,
config => listener_config(Opts, ListenOpts),
transport => maps:get(
transport,
ListenOpts,
ssl
)
}
),
{ok, Ref} = livery_h2:start(ListenOpts1),
{Ref, h2:server_port(Ref)};
error ->
undefined
end.
maybe_start_h3(Opts, Stack, Handler) ->
case maps:find(http3, Opts) of
{ok, ListenOpts} ->
ListenOpts1 = ensure_h3_name(
maps:merge(
ListenOpts,
#{
stack => Stack,
handler => Handler,
config => listener_config(Opts, ListenOpts)
}
)
),
{ok, Name} = livery_h3:start(ListenOpts1),
{ok, Port} = quic:get_server_port(Name),
{Name, Port};
error ->
undefined
end.
%% A per-listener `config' overrides the service-wide one.
-spec listener_config(service_opts(), listener_opts()) -> term().
listener_config(Opts, ListenOpts) ->
maps:get(config, ListenOpts, maps:get(config, Opts, undefined)).
%% `quic_h3' registers the listener under an atom name. Derive a stable
%% one from the bound port so restarting a service reuses the same
%% (interned) atom instead of leaking a fresh atom each start. A random
%% port (0) keeps the per-start auto-generated name.
-spec ensure_h3_name(map()) -> map().
ensure_h3_name(#{name := _} = Opts) ->
Opts;
ensure_h3_name(#{port := Port} = Opts) when is_integer(Port), Port > 0 ->
Opts#{name => list_to_atom("livery_h3_p" ++ integer_to_list(Port))};
ensure_h3_name(Opts) ->
Opts.
stop_h1(undefined) -> ok;
stop_h1({Ref, _Port}) -> livery_h1:stop(Ref).
stop_h2(undefined) -> ok;
stop_h2({Ref, _Port}) -> livery_h2:stop(Ref).
stop_h3(undefined) -> ok;
stop_h3({Name, _Port}) -> livery_h3:stop(Name).
listeners_map(State) ->
Acc0 = #{},
Acc1 =
case State#state.h1 of
undefined -> Acc0;
{_, P1} -> Acc0#{h1 => P1}
end,
Acc2 =
case State#state.h2 of
undefined -> Acc1;
{_, P2} -> Acc1#{h2 => P2}
end,
case State#state.h3 of
undefined -> Acc2;
{_, P3} -> Acc2#{h3 => P3}
end.