Skip to main content

src/livery_service.erl

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