Skip to content

Commit 65c6394

Browse files
authored
Add support for HTTP/2 server push (#94)
1 parent 08a18e8 commit 65c6394

5 files changed

Lines changed: 289 additions & 52 deletions

File tree

lib/mint/http.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,11 @@ defmodule Mint.HTTP do
494494
and will never be returned by an HTTP/1 connection. See `Mint.HTTP2.ping/2`
495495
for more information.
496496
497+
* `{:push_promise, request_ref, promised_request_ref, headers}` - returned when
498+
the server sends a server push to the client. This response type is HTTP/2 specific
499+
and will never be returned by an HTTP/1 connection. See `Mint.HTTP2` for more
500+
information on server pushes.
501+
497502
## Examples
498503
499504
Let's assume we have a function called `receive_next_and_stream/1` that takes

lib/mint/http2.ex

Lines changed: 164 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,69 @@ defmodule Mint.HTTP2 do
2424
does not mean that the response to request A will come before the response to request B.
2525
This is why we identify each request with a unique reference returned by `request/5`.
2626
See `request/5` for more information.
27+
28+
## Server push
29+
30+
HTTP/2 supports [server push](https://en.wikipedia.org/wiki/HTTP/2_Server_Push), which
31+
is a way for a server to send a response to a client without the client needing to make
32+
the corresponding request. The server sends a `:push_promise` response to a normal request:
33+
this creates a new request reference. Then, the server sends normal responses for the newly
34+
created request reference.
35+
36+
Let's see an example. We will ask the server for `"/index.html"` and the server will
37+
send us a push promise for `"/style.css"`.
38+
39+
{:ok, conn} = Mint.HTTP2.connect(:https, "example.com", 443)
40+
{:ok, conn, request_ref} = Mint.HTTP2.request(conn, "GET", "/index.html", _headers = [])
41+
42+
next_message =
43+
receive do
44+
msg -> msg
45+
end
46+
47+
{:ok, conn, responses} = Mint.HTTP2.stream(conn, next_message)
48+
49+
[
50+
{:push_promise, ^request_ref, promised_request_ref, promised_headers},
51+
{:status, ^request_ref, 200},
52+
{:headers, ^request_ref, []},
53+
{:data, ^request_ref, "<html>..."},
54+
{:done, ^request_ref}
55+
] = responses
56+
57+
promised_headers
58+
#=> [{":method", "GET"}, {":path", "/style.css"}]
59+
60+
As you can see in the example above, when the server sends a push promise then a
61+
`:push_promise` response is returned as a response to a request. The `:push_promise`
62+
response contains a `promised_request_ref` and some `promised_headers`. The
63+
`promised_request_ref` is the new request ref that pushed responses will be tagged with.
64+
`promised_headers` are headers that tell the client *what request* the promised response
65+
will respond to. The idea is that the server tells the client a request the client will
66+
want to make and then preemptively sends a response for that request. Promised headers
67+
will always include `:method`, `:path`, and `:authority`.
68+
69+
next_message =
70+
receive do
71+
msg -> msg
72+
end
73+
74+
{:ok, conn, responses} = Mint.HTTP2.stream(conn, next_message)
75+
76+
[
77+
{:status, ^promised_request_ref, 200},
78+
{:headers, ^promised_request_ref, []},
79+
{:data, ^promised_request_ref, "body { ... }"},
80+
{:done, ^promised_request_ref}
81+
]
82+
83+
The response to a promised request is like a response to any normal request.
84+
85+
### Disabling server pushes
86+
87+
HTTP/2 exposes a boolean setting for enabling or disabling server pushes with `:enable_push`.
88+
You can pass this option when connecting or in `put_settings/2`. By default server push
89+
is enabled.
2790
"""
2891

2992
use Bitwise, skip_operators: true
@@ -39,6 +102,7 @@ defmodule Mint.HTTP2 do
39102
}
40103

41104
require Logger
105+
require Integer
42106

43107
@behaviour Mint.Core.Conn
44108

@@ -65,7 +129,7 @@ defmodule Mint.HTTP2 do
65129
:port,
66130
:scheme,
67131

68-
# Mint.HTTP2ection state (open, closed, and so on).
132+
# Connection state (open, closed, and so on).
69133
:state,
70134

71135
# Fields of the connection.
@@ -95,6 +159,7 @@ defmodule Mint.HTTP2 do
95159
# SETTINGS-related things for client.
96160
client_max_frame_size: @default_max_frame_size,
97161
client_max_concurrent_streams: 100,
162+
client_enable_push: true,
98163

99164
# Headers being processed (when headers are split into multiple frames with CONTINUATIONS, all
100165
# the continuation frames must come one right after the other).
@@ -131,7 +196,7 @@ defmodule Mint.HTTP2 do
131196
server_max_concurrent_streams: non_neg_integer(),
132197
initial_window_size: pos_integer(),
133198
max_frame_size: pos_integer(),
134-
headers_being_processed: {stream_id :: non_neg_integer(), iodata(), boolean()} | nil
199+
headers_being_processed: {stream_id :: non_neg_integer(), iodata(), fun()} | nil
135200
}
136201

137202
## Public interface
@@ -707,16 +772,8 @@ defmodule Mint.HTTP2 do
707772
end
708773

709774
{:enable_push, value} ->
710-
case value do
711-
true ->
712-
raise ArgumentError,
713-
"push promises are not supported yet, so :enable_push must be false"
714-
715-
false ->
716-
:ok
717-
718-
_other ->
719-
raise ArgumentError, ":enable_push must be a boolean, got: #{inspect(value)}"
775+
unless is_boolean(value) do
776+
raise ArgumentError, ":enable_push must be a boolean, got: #{inspect(value)}"
720777
end
721778

722779
{:max_concurrent_streams, value} ->
@@ -847,8 +904,8 @@ defmodule Mint.HTTP2 do
847904

848905
defp handle_frame(conn, settings() = frame, resps), do: handle_settings(conn, frame, resps)
849906

850-
# TODO: implement PUSH_PROMISE
851-
defp handle_frame(_, push_promise(), _resps), do: raise("PUSH_PROMISE handling not implemented")
907+
defp handle_frame(conn, push_promise() = frame, resps),
908+
do: handle_push_promise(conn, frame, resps)
852909

853910
defp handle_frame(conn, Frame.ping() = frame, resps), do: handle_ping(conn, frame, resps)
854911

@@ -892,32 +949,42 @@ defmodule Mint.HTTP2 do
892949
defp handle_headers(conn, frame, responses) do
893950
headers(stream_id: stream_id, flags: flags, hbf: hbf) = frame
894951
stream = fetch_stream!(conn, stream_id)
895-
assert_stream_in_state(conn, stream, [:open, :half_closed_local])
952+
assert_stream_in_state(conn, stream, [:open, :half_closed_local, :reserved_remote])
896953
end_stream? = flag_set?(flags, :headers, :end_stream)
897954

898955
if flag_set?(flags, :headers, :end_headers) do
899956
decode_hbf_and_add_responses(conn, responses, hbf, stream, end_stream?)
900957
else
901-
conn = put_in(conn.headers_being_processed, {stream_id, hbf, end_stream?})
958+
callback = &decode_hbf_and_add_responses(&1, &2, &3, &4, end_stream?)
959+
conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback})
902960
{conn, responses}
903961
end
904962
end
905963

906964
defp decode_hbf_and_add_responses(conn, responses, hbf, stream, end_stream?) do
907965
case decode_hbf(conn, hbf) do
908-
{:ok, status, headers, conn} ->
966+
{:ok, [{":status", status} | headers], conn} ->
967+
status = String.to_integer(status)
909968
responses = [{:headers, stream.ref, headers}, {:status, stream.ref, status} | responses]
910969

911-
if end_stream? do
912-
conn = put_in(conn.streams[stream.id].state, :half_closed_remote)
913-
conn = update_in(conn.open_stream_count, &(&1 - 1))
914-
{conn, [{:done, stream.ref} | responses]}
915-
else
916-
{conn, responses}
970+
cond do
971+
end_stream? ->
972+
conn = put_in(conn.streams[stream.id].state, :half_closed_remote)
973+
conn = update_in(conn.open_stream_count, &(&1 - 1))
974+
{conn, [{:done, stream.ref} | responses]}
975+
976+
# If this was a promised stream, it goes in the :half_closed_local state as
977+
# soon as it receives headers.
978+
stream.state == :reserved_remote ->
979+
conn = put_in(conn.streams[stream.id].state, :half_closed_local)
980+
{conn, responses}
981+
982+
true ->
983+
{conn, responses}
917984
end
918985

919986
# http://httpwg.org/specs/rfc7540.html#rfc.section.8.1.2.6
920-
{:error, :missing_status_header, conn} ->
987+
{:ok, _headers, conn} ->
921988
conn = close_stream!(conn, stream.id, :protocol_error)
922989
reason = {:protocol_error, :missing_status_header}
923990
responses = [{:error, stream.ref, reason} | responses]
@@ -929,11 +996,7 @@ defmodule Mint.HTTP2 do
929996
case HPACK.decode(hbf, conn.decode_table) do
930997
{:ok, headers, decode_table} ->
931998
conn = put_in(conn.decode_table, decode_table)
932-
933-
case headers do
934-
[{":status", status} | headers] -> {:ok, String.to_integer(status), headers, conn}
935-
_other -> {:error, :missing_status_header, conn}
936-
end
999+
{:ok, headers, conn}
9371000

9381001
{:error, reason} ->
9391002
debug_data = "unable to decode headers: #{inspect(reason)}"
@@ -1017,6 +1080,9 @@ defmodule Mint.HTTP2 do
10171080

10181081
{:max_concurrent_streams, value}, conn ->
10191082
put_in(conn.client_max_concurrent_streams, value)
1083+
1084+
{:enable_push, value}, conn ->
1085+
put_in(conn.client_enable_push, value)
10201086
end)
10211087
end
10221088

@@ -1042,6 +1108,71 @@ defmodule Mint.HTTP2 do
10421108
put_in(conn.initial_window_size, new_iws)
10431109
end
10441110

1111+
# PUSH_PROMISE
1112+
1113+
defp handle_push_promise(
1114+
%Mint.HTTP2{client_enable_push: false} = conn,
1115+
push_promise(),
1116+
_responses
1117+
) do
1118+
debug_data = "received PUSH_PROMISE frame when SETTINGS_ENABLE_PUSH was false"
1119+
send_connection_error!(conn, :protocol_error, debug_data)
1120+
end
1121+
1122+
defp handle_push_promise(conn, push_promise() = frame, responses) do
1123+
push_promise(
1124+
stream_id: stream_id,
1125+
flags: flags,
1126+
promised_stream_id: promised_stream_id,
1127+
hbf: hbf
1128+
) = frame
1129+
1130+
# TODO: improve this by checking that the promised_stream_id is not in use or has been used
1131+
# before.
1132+
unless promised_stream_id > 0 and Integer.is_even(promised_stream_id) do
1133+
debug_data = "invalid promised stream ID: #{inspect(promised_stream_id)}"
1134+
send_connection_error!(conn, :protocol_error, debug_data)
1135+
end
1136+
1137+
stream = fetch_stream!(conn, stream_id)
1138+
assert_stream_in_state(conn, stream, [:open, :half_closed_local])
1139+
1140+
if flag_set?(flags, :push_promise, :end_headers) do
1141+
decode_push_promise_headers_and_add_response(
1142+
conn,
1143+
responses,
1144+
hbf,
1145+
stream,
1146+
promised_stream_id
1147+
)
1148+
else
1149+
callback = &decode_push_promise_headers_and_add_response(&1, &2, &3, &4, promised_stream_id)
1150+
conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback})
1151+
{conn, responses}
1152+
end
1153+
end
1154+
1155+
defp decode_push_promise_headers_and_add_response(
1156+
conn,
1157+
responses,
1158+
hbf,
1159+
stream,
1160+
promised_stream_id
1161+
) do
1162+
{:ok, headers, conn} = decode_hbf(conn, hbf)
1163+
1164+
promised_stream = %{
1165+
id: promised_stream_id,
1166+
ref: make_ref(),
1167+
state: :reserved_remote,
1168+
window_size: conn.initial_window_size
1169+
}
1170+
1171+
conn = put_in(conn.streams[promised_stream.id], promised_stream)
1172+
new_response = {:push_promise, stream.ref, promised_stream.ref, headers}
1173+
{conn, [new_response | responses]}
1174+
end
1175+
10451176
# PING
10461177

10471178
defp handle_ping(conn, Frame.ping() = frame, responses) do
@@ -1137,13 +1268,14 @@ defmodule Mint.HTTP2 do
11371268
continuation(stream_id: stream_id, flags: flags, hbf: hbf_chunk) = frame
11381269
stream = fetch_stream!(conn, stream_id)
11391270

1140-
{^stream_id, hbf_acc, end_stream?} = conn.headers_being_processed
1271+
{^stream_id, hbf_acc, callback} = conn.headers_being_processed
11411272

11421273
if flag_set?(flags, :continuation, :end_headers) do
11431274
hbf = IO.iodata_to_binary([hbf_acc, hbf_chunk])
1144-
decode_hbf_and_add_responses(conn, responses, hbf, stream, end_stream?)
1275+
conn = put_in(conn.headers_being_processed, nil)
1276+
callback.(conn, responses, hbf, stream)
11451277
else
1146-
conn = put_in(conn.headers_being_processed, {stream_id, [hbf_acc, hbf_chunk], end_stream?})
1278+
conn = put_in(conn.headers_being_processed, {stream_id, [hbf_acc, hbf_chunk], callback})
11471279
{conn, responses}
11481280
end
11491281
end

lib/mint/types.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ defmodule Mint.Types do
2323
| {:data, request_ref(), body_chunk :: binary()}
2424
| {:done, request_ref()}
2525
| {:pong, request_ref()}
26+
| {:push_promise, request_ref(), promised_request_ref :: request_ref(), headers()}
2627
| {:error, request_ref(), reason :: term()}
2728

2829
@typedoc """

0 commit comments

Comments
 (0)