From 3e05ab8f82ac69da7c55d1b28284c32ea63186c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Sun, 22 Jan 2017 10:50:39 +0100 Subject: Add experimental cowboy_compress_h stream handler Currently marked as experimental because it can't be tweaked (just enabled/disabled) and because it is not documented yet. --- src/cowboy_compress_h.erl | 167 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/cowboy_compress_h.erl (limited to 'src/cowboy_compress_h.erl') diff --git a/src/cowboy_compress_h.erl b/src/cowboy_compress_h.erl new file mode 100644 index 0000000..229e75f --- /dev/null +++ b/src/cowboy_compress_h.erl @@ -0,0 +1,167 @@ +%% Copyright (c) 2017, Loïc Hoguin +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(cowboy_compress_h). +-behavior(cowboy_stream). + +-export([init/3]). +-export([data/4]). +-export([info/3]). +-export([terminate/3]). + +-record(state, { + next :: any(), + compress = undefined :: undefined | gzip, + deflate = undefined :: undefined | zlib:zstream() +}). + +-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts()) + -> {cowboy_stream:commands(), #state{}}. +init(StreamID, Req, Opts) -> + State0 = check_req(Req), + {Commands0, Next} = cowboy_stream:init(StreamID, Req, Opts), + fold(Commands0, State0#state{next=Next}). + +-spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State) + -> {cowboy_stream:commands(), State} when State::#state{}. +data(StreamID, IsFin, Data, State0=#state{next=Next0}) -> + {Commands0, Next} = cowboy_stream:data(StreamID, IsFin, Data, Next0), + fold(Commands0, State0#state{next=Next}). + +-spec info(cowboy_stream:streamid(), any(), State) + -> {cowboy_stream:commands(), State} when State::#state{}. +info(StreamID, Info, State0=#state{next=Next0}) -> + {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0), + fold(Commands0, State0#state{next=Next}). + +-spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), #state{}) -> any(). +terminate(StreamID, Reason, #state{next=Next, deflate=Z}) -> + %% Clean the zlib:stream() in case something went wrong. + %% In the normal scenario the stream is already closed. + case Z of + undefined -> ok; + _ -> zlib:close(Z) + end, + cowboy_stream:terminate(StreamID, Reason, Next). + +%% Internal. + +%% Check if the client supports decoding of gzip responses. +check_req(Req) -> + case cowboy_req:parse_header(<<"accept-encoding">>, Req) of + %% Client doesn't support any compression algorithm. + undefined -> + #state{compress=undefined}; + Encodings -> + %% We only support gzip so look for it specifically. + %% @todo A recipient SHOULD consider "x-gzip" to be + %% equivalent to "gzip". (RFC7230 4.2.3) + case [E || E={<<"gzip">>, Q} <- Encodings, Q =/= 0] of + [] -> + #state{compress=undefined}; + _ -> + #state{compress=gzip} + end + end. + +%% Do not compress responses that contain the content-encoding header. +check_resp_headers(#{<<"content-encoding">> := _}, State) -> + State#state{compress=undefined}; +check_resp_headers(_, State) -> + State. + +fold(Commands, State=#state{compress=undefined}) -> + {Commands, State}; +fold(Commands, State) -> + fold(Commands, State, []). + +fold([], State, Acc) -> + {lists:reverse(Acc), State}; +%% We do not compress sendfile bodies. +fold([Response={response, _, _, {sendfile, _, _, _}}|Tail], State, Acc) -> + fold(Tail, State, [Response|Acc]); +%% We compress full responses directly, unless they are lower than +%% 300 bytes or we find we are not able to by looking at the headers. +%% @todo It might be good to allow this size to be configured? +fold([Response0={response, _, Headers, Body}|Tail], State0, Acc) -> + case check_resp_headers(Headers, State0) of + State=#state{compress=undefined} -> + fold(Tail, State, [Response0|Acc]); + State1 -> + BodyLength = iolist_size(Body), + if + BodyLength =< 300 -> + fold(Tail, State1, [Response0|Acc]); + true -> + {Response, State} = gzip_response(Response0, State1), + fold(Tail, State, [Response|Acc]) + end + end; +%% Check headers and initiate compression... +fold([Response0={headers, _, Headers}|Tail], State0, Acc) -> + case check_resp_headers(Headers, State0) of + State=#state{compress=undefined} -> + fold(Tail, State, [Response0|Acc]); + State1 -> + {Response, State} = gzip_headers(Response0, State1), + fold(Tail, State, [Response|Acc]) + end; +%% then compress each data commands individually. +fold([Data0={data, _, _}|Tail], State0=#state{compress=gzip}, Acc) -> + {Data, State} = gzip_data(Data0, State0), + fold(Tail, State, [Data|Acc]); +%% Otherwise, we either have an unrelated command, or a data command +%% with compression disabled. +fold([Command|Tail], State, Acc) -> + fold(Tail, State, [Command|Acc]). + +gzip_response({response, Status, Headers, Body}, State) -> + %% We can't call zlib:gzip/1 because it does an + %% iolist_to_binary(GzBody) at the end to return + %% a binary(). Therefore the code here is largely + %% a duplicate of the code of that function. + Z = zlib:open(), + GzBody = try + %% 31 = 16+?MAX_WBITS from zlib.erl + %% @todo It might be good to allow them to be configured? + zlib:deflateInit(Z, default, deflated, 31, 8, default), + Gz = zlib:deflate(Z, Body, finish), + zlib:deflateEnd(Z), + Gz + after + zlib:close(Z) + end, + {{response, Status, Headers#{ + <<"content-length">> => integer_to_binary(iolist_size(GzBody)), + <<"content-encoding">> => <<"gzip">> + }, GzBody}, State}. + +gzip_headers({headers, Status, Headers0}, State) -> + Z = zlib:open(), + %% We use the same arguments as when compressing the body fully. + %% @todo It might be good to allow them to be configured? + zlib:deflateInit(Z, default, deflated, 31, 8, default), + Headers = maps:remove(<<"content-length">>, Headers0), + {{headers, Status, Headers#{ + <<"content-encoding">> => <<"gzip">> + }}, State#state{deflate=Z}}. + +gzip_data({data, nofin, Data0}, State=#state{deflate=Z}) -> + Data = zlib:deflate(Z, Data0), + {{data, nofin, Data}, State}; +gzip_data({data, fin, Data0}, State=#state{deflate=Z}) -> + Data = zlib:deflate(Z, Data0, finish), + zlib:deflateEnd(Z), + zlib:close(Z), + {{data, fin, Data}, State#state{deflate=undefined}}. -- cgit v1.2.3