-module(livery_compress). -moduledoc """ Response-compression middleware. Negotiates the client's `Accept-Encoding` against the registered `livery_codec` codecs and compresses eligible responses, setting `Content-Encoding` and a cache-correct `Vary: Accept-Encoding`. gzip and deflate are built in (OTP `zlib`); brotli/zstd become available when their apps register a codec. Configure as a stack entry `{livery_compress, Config}` where every key is optional: - `codecs` — list of codec modules in server-preference order (default `livery_codec:registered()`); restricts/overrides the set. - `min_size` — minimum `{full, _}` body size to compress (default 1024). - `types` — compressible `Content-Type` prefixes (default text and the common structured types). ## Negotiation A coding is acceptable iff its `Accept-Encoding` q-value is `> 0` (by exact name or `*`; `q=0` rejects). Among acceptable codings the SERVER preference order wins; client q-weights are only an accept/reject filter. With no acceptable coding the response is sent uncompressed (identity); `identity;q=0` is deliberately treated as identity, not a `406`. ## Scope Only `{full, _}` (>= `min_size`) and `{chunked, _}` bodies with a compressible `Content-Type` and no existing `Content-Encoding` are eligible. SSE, file, empty, and upgrade bodies pass through unchanged. """. -behaviour(livery_middleware). -export([call/3]). -doc "Compress the downstream response when the client accepts a codec.". -spec call(livery_req:req(), livery_middleware:next(), map() | undefined) -> livery_resp:resp(). call(Req, Next, State) -> Cfg = config(State), Resp = Next(Req), case eligible(Resp, Cfg) of false -> Resp; true -> Resp1 = append_vary(<<"Accept-Encoding">>, Resp), Accept = livery_req:header(<<"accept-encoding">>, Req, <<>>), case choose(Accept, maps:get(codecs, Cfg)) of none -> Resp1; {ok, Codec} -> apply_codec(Codec, Resp1) end end. %%==================================================================== %% Eligibility %%==================================================================== -spec eligible(livery_resp:resp(), map()) -> boolean(). eligible(Resp, Cfg) -> not has_content_encoding(Resp) andalso body_eligible(livery_resp:body(Resp), Cfg) andalso type_compressible(Resp, Cfg). -spec has_content_encoding(livery_resp:resp()) -> boolean(). has_content_encoding(Resp) -> lists:keymember(<<"content-encoding">>, 1, livery_resp:headers(Resp)). -spec body_eligible(livery_resp:body(), map()) -> boolean(). body_eligible({full, Body}, Cfg) -> iolist_size(Body) >= maps:get(min_size, Cfg); body_eligible({chunked, _Producer}, _Cfg) -> true; body_eligible(_Other, _Cfg) -> false. -spec type_compressible(livery_resp:resp(), map()) -> boolean(). type_compressible(Resp, Cfg) -> case content_type(Resp) of undefined -> false; Value -> Norm = normalize_ct(Value), lists:any( fun(Prefix) -> is_prefix(Prefix, Norm) end, maps:get(types, Cfg) ) end. -spec content_type(livery_resp:resp()) -> binary() | undefined. content_type(Resp) -> case lists:keyfind(<<"content-type">>, 1, livery_resp:headers(Resp)) of {_, Value} -> Value; false -> undefined end. -spec normalize_ct(binary()) -> binary(). normalize_ct(Value) -> Lower = iolist_to_binary(string:lowercase(Value)), [Main | _] = binary:split(Lower, <<";">>), iolist_to_binary(string:trim(Main)). -spec is_prefix(binary(), binary()) -> boolean(). is_prefix(Prefix, Bin) when byte_size(Bin) >= byte_size(Prefix) -> PrefixSize = byte_size(Prefix), <> = Bin, Head =:= Prefix; is_prefix(_Prefix, _Bin) -> false. %%==================================================================== %% Negotiation %%==================================================================== -spec choose(binary(), [module()]) -> none | {ok, module()}. choose(Accept, Codecs) -> select(Codecs, parse_accept_encoding(Accept)). -spec select([module()], #{binary() => float()}) -> none | {ok, module()}. select([], _Accepted) -> none; select([Codec | Rest], Accepted) -> case acceptable(normalize(Codec:name()), Accepted) of true -> {ok, Codec}; false -> select(Rest, Accepted) end. -spec acceptable(binary(), #{binary() => float()}) -> boolean(). acceptable(Name, Accepted) -> case maps:find(Name, Accepted) of {ok, Q} -> Q > 0; error -> wildcard_q(Accepted) > 0 end. -spec wildcard_q(#{binary() => float()}) -> float(). wildcard_q(Accepted) -> maps:get(<<"*">>, Accepted, 0.0). -spec parse_accept_encoding(binary()) -> #{binary() => float()}. parse_accept_encoding(Bin) -> lists:foldl( fun parse_entry/2, #{}, binary:split(Bin, <<",">>, [global]) ). -spec parse_entry(binary(), #{binary() => float()}) -> #{binary() => float()}. parse_entry(Part, Acc) -> case iolist_to_binary(string:trim(Part)) of <<>> -> Acc; Trimmed -> {Coding, Q} = split_q(Trimmed), maps:put(normalize(Coding), Q, Acc) end. -spec split_q(binary()) -> {binary(), float()}. split_q(Entry) -> case binary:split(Entry, <<";">>) of [Coding] -> {Coding, 1.0}; [Coding, Params] -> {Coding, parse_q(Params)} end. -spec parse_q(binary()) -> float(). parse_q(Params) -> Lower = iolist_to_binary(string:lowercase(Params)), find_q(binary:split(Lower, <<";">>, [global])). -spec find_q([binary()]) -> float(). find_q([]) -> 1.0; find_q([Token | Rest]) -> case iolist_to_binary(string:trim(Token)) of <<"q=", Value/binary>> -> to_q(Value); _Other -> find_q(Rest) end. -spec to_q(binary()) -> float(). to_q(Value) -> Str = binary_to_list(iolist_to_binary(string:trim(Value))), case string:to_float(Str) of {Float, _Rest} when is_float(Float) -> Float; {error, _} -> case string:to_integer(Str) of {Int, _} when is_integer(Int) -> float(Int); {error, _} -> 0.0 end end. %%==================================================================== %% Apply %%==================================================================== -spec apply_codec(module(), livery_resp:resp()) -> livery_resp:resp(). apply_codec(Codec, Resp) -> case livery_resp:body(Resp) of {full, Body} -> apply_full(Codec, Body, Resp); {chunked, Producer} -> apply_chunked(Codec, Producer, Resp) end. -spec apply_full(module(), iodata(), livery_resp:resp()) -> livery_resp:resp(). apply_full(Codec, Body, Resp) -> Comp = iolist_to_binary(Codec:compress(Body)), R1 = livery_resp:with_body({full, Comp}, Resp), R2 = livery_resp:with_header(<<"content-encoding">>, Codec:name(), R1), case livery_resp:trailers(Resp) of undefined -> livery_resp:with_header( <<"content-length">>, integer_to_binary(byte_size(Comp)), R2 ); _Trailers -> livery_resp:delete_header(<<"content-length">>, R2) end. -spec apply_chunked( module(), fun((term()) -> ok | {error, term()}), livery_resp:resp() ) -> livery_resp:resp(). apply_chunked(Codec, Producer, Resp) -> Wrapped = fun(Emit) -> stream(Codec, Producer, Emit) end, R1 = livery_resp:with_body({chunked, Wrapped}, Resp), R2 = livery_resp:with_header(<<"content-encoding">>, Codec:name(), R1), livery_resp:delete_header(<<"content-length">>, R2). -spec stream( module(), fun((term()) -> ok | {error, term()}), fun((term()) -> ok | {error, term()}) ) -> ok | {error, term()}. stream(Codec, Producer, Emit) -> Ctx = Codec:stream_init(), EmitC = fun(Chunk) -> emit_compressed(Codec:stream_update(Ctx, Chunk), Emit) end, try Producer(EmitC) of {error, _} = Err -> Err; _Ok -> emit_compressed(Codec:stream_finish(Ctx), Emit) after Codec:stream_close(Ctx) end. -spec emit_compressed(iodata(), fun((term()) -> ok | {error, term()})) -> ok | {error, term()}. emit_compressed(Out, Emit) -> case iolist_size(Out) of 0 -> ok; _ -> Emit(Out) end. %%==================================================================== %% Vary %%==================================================================== -spec append_vary(binary(), livery_resp:resp()) -> livery_resp:resp(). append_vary(Token, Resp) -> case vary_present(Token, Resp) of true -> Resp; false -> livery_resp:append_header(<<"vary">>, Token, Resp) end. -spec vary_present(binary(), livery_resp:resp()) -> boolean(). vary_present(Token, Resp) -> LToken = normalize(Token), Existing = [V || {<<"vary">>, V} <- livery_resp:headers(Resp)], lists:any( fun(Value) -> lists:member(LToken, split_tokens(Value)) end, Existing ). -spec split_tokens(binary()) -> [binary()]. split_tokens(Value) -> [normalize(P) || P <- binary:split(Value, <<",">>, [global])]. %%==================================================================== %% Config and helpers %%==================================================================== -spec config(map() | undefined) -> map(). config(undefined) -> config(#{}); config(State) when is_map(State) -> #{ codecs => maps:get(codecs, State, livery_codec:registered()), min_size => maps:get(min_size, State, 1024), types => maps:get(types, State, default_types()) }. -spec default_types() -> [binary()]. default_types() -> [ <<"text/">>, <<"application/json">>, <<"application/javascript">>, <<"application/xml">>, <<"application/xhtml+xml">>, <<"image/svg+xml">>, <<"application/wasm">>, <<"application/x-ndjson">> ]. -spec normalize(binary()) -> binary(). normalize(Bin) -> iolist_to_binary(string:trim(string:lowercase(Bin))).