Skip to content

Commit 16d0bee

Browse files
HTTP/2 support (rfc8441) (#9)
* draft connection workflow with H2 on IEx with ruby server * switch out ruby server for nodejs server * tout h2 support in readme * add websocket-stream node package * log request headers in node h2 websocket fixture * ping the h2 websocket server with a message, get a reply * allow new/5 to accept Mint.HTTP2 conns * todo note about removing .iex.exs * depend on fork with git SHA * add upgrade/4, remove build_request_headers/0 * use new upgrade/4 in fixtures and tests * add example websocket server from cowboy * enable connect protocol in cowboy ws server fixture * set cowboy server port to 7070 * switch node server for cowboy container in docker-compose * ensure we ignore the initial message * use a docker build for the cowboy websocket server * delete http2 test * include sec-websocket-version header in http1+2 * add test case for cowboy HTTP/2 echo server * add host of cowboy server to docker-compose ENV * add h2 cowboy host to CI env vars * build the h2 cowboy container in CI * separate build and up steps for docker-compose stuff * move h2cowboy implementation into test fixtures * remove otp24 container * start websocket server in test helper * point at localhost for cowboy h2 server * add dependency on cowboy in test * compile the cowboy fixture in test mode * remove old cowboy steps from CI * show usage of upgrade/4 in readme * remove unused websocket_info/2 callback * format * remove .iex.exs * fixup link to rfc6455 * remove old h2cowboy host env var from docker-compose * remove h2cowboy dir * remove old node http2 server * use new upgrade/4 function in phoenix-chat example * use elixir 1.12 * use elixir 1.12.1 container in docker-compose * use elixir-mint/mint on latest commit PRs have been merged in
1 parent 6c7eab3 commit 16d0bee

15 files changed

Lines changed: 246 additions & 49 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
run: docker-compose up --detach
2020

2121
- name: Determine the elixir version
22-
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV
22+
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }' | awk -F - '{print $1}')" >> $GITHUB_ENV
2323

2424
- name: Determine the otp version
2525
run: echo "OTP_VERSION=$(grep -h erlang .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV
@@ -101,7 +101,7 @@ jobs:
101101
uses: actions/checkout@v2
102102

103103
- name: Determine the elixir version
104-
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV
104+
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }' | awk -F - '{print $1}')" >> $GITHUB_ENV
105105

106106
- name: Determine the otp version
107107
run: echo "OTP_VERSION=$(grep -h erlang .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV

.github/workflows/refresh-dev-cache.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
uses: actions/checkout@v2
4141

4242
- name: Determine the elixir version
43-
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV
43+
run: echo "ELIXIR_VERSION=$(grep -h elixir .tool-versions | awk '{ print $2 }' | awk -F - '{print $1}')" >> $GITHUB_ENV
4444

4545
- name: Determine the otp version
4646
run: echo "OTP_VERSION=$(grep -h erlang .tool-versions | awk '{ print $2 }')" >> $GITHUB_ENV

.tool-versions

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
elixir 1.12.0-rc.1
2-
erlang 23.2
1+
elixir 1.12.0-otp-24
2+
erlang 24.0

README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
# Mint.WebSocket
22

3-
WebSocket support for Mint 🌱
4-
5-
> This repo is not complete: it only tests WebSockets over HTTP/1.
6-
> Issues and PRs are welcome :slightly_smiling_face:
3+
HTTP/1 and HTTP/2 WebSocket support for Mint 🌱
74

85
## Spec conformance
96

10-
This library aims to follow [RFC6455](https://tools.ietf.org/html/rfc6455)
11-
as closely as possible and uses
12-
[Autobahn|Testsuite](https://github.com/crossbario/autobahn-testsuite)
7+
This library aims to follow
8+
[rfc6455](https://datatracker.ietf.org/doc/html/rfc6455) and
9+
[rfc8441](https://datatracker.ietf.org/doc/html/rfc8441) as closely as possible
10+
and uses [Autobahn|Testsuite](https://github.com/crossbario/autobahn-testsuite)
1311
to check conformance with every run of tests/CI. The auto-generated report
1412
produced by the Autobahn|Testsuite is uploaded on each push to main.
1513

@@ -25,10 +23,10 @@ WebSocket server which echos our frames:
2523
```elixir
2624
# bootstrap
2725
{:ok, conn} = Mint.HTTP.connect(:http, "echo", 9000)
28-
req_headers = Mint.WebSocket.build_request_headers()
29-
{:ok, conn, ref} = Mint.HTTP.request(conn, "GET", "/", req_headers, nil)
30-
http_get_message = receive(do: (message -> message))
3126

27+
{:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", [])
28+
29+
http_get_message = receive(do: (message -> message))
3230
{:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
3331
Mint.HTTP.stream(conn, http_get_message)
3432

examples/phoenixchat_herokuapp.exs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# N.B. this is a phoenix v1.3 server that sends pings periodically
22
# see https://phoenixchat.herokuapp.com for the in-browser version
33
{:ok, conn} = Mint.HTTP.connect(:https, "phoenixchat.herokuapp.com", 443)
4-
req_headers = Mint.WebSocket.build_request_headers()
5-
{:ok, conn, ref} = Mint.HTTP.request(conn, "GET", "/ws", req_headers, nil)
4+
5+
{:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/ws", [])
6+
67
http_get_message = receive(do: (message -> message))
78
{:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
89
Mint.HTTP.stream(conn, http_get_message)

lib/mint/web_socket.ex

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ defmodule Mint.WebSocket do
55

66
require __MODULE__.Frame, as: Frame
77
alias __MODULE__.Utils
8+
alias Mint.WebSocketError
89

910
@type t :: %__MODULE__{}
1011
defstruct extensions: MapSet.new(),
1112
fragments: [],
1213
private: %{},
1314
buffer: <<>>
1415

16+
@type error :: Mint.Types.error() | WebSocketError.t()
17+
1518
defguardp is_frame(frame)
1619
when frame in [:ping, :pong, :close] or
1720
(is_tuple(frame) and elem(frame, 0) in [:text, :binary, :ping, :pong] and
@@ -29,32 +32,81 @@ defmodule Mint.WebSocket do
2932
| :close
3033
| {:close, code :: non_neg_integer(), reason :: binary()}
3134

32-
@spec build_request_headers(Keyword.t()) :: Mint.Types.headers()
33-
def build_request_headers(_opts \\ []) do
34-
Utils.random_nonce()
35-
|> Utils.headers()
35+
@doc """
36+
Requests that a connection be upgraded to the WebSocket protocol
37+
38+
This function wraps `Mint.HTTP.request/5` to provide a single interface
39+
for bootstrapping an upgrade for HTTP/1 and HTTP/2 connections.
40+
41+
For HTTP/1 connections, this function performs a GET request with
42+
WebSocket-specific headers. For HTTP/2 connections, this function performs
43+
an extended CONNECT request which opens a stream to be used for the WebSocket
44+
connection.
45+
"""
46+
@spec upgrade(
47+
conn :: Mint.HTTP.t(),
48+
path :: String.t(),
49+
headers :: Mint.Types.headers(),
50+
# maybe t:Keyword.t/0, will hold extensions in the future
51+
opts :: list()
52+
) :: {:ok, Mint.HTTP.t(), Mint.Types.request_ref()} | {:error, Mint.HTTP.t(), error()}
53+
def upgrade(conn, path, headers, opts \\ [])
54+
55+
def upgrade(%Mint.HTTP1{} = conn, path, headers, _opts) do
56+
nonce = Utils.random_nonce()
57+
headers = Utils.headers({:http1, nonce}) ++ headers
58+
conn = put_in(conn.private[:sec_websocket_key], nonce)
59+
60+
Mint.HTTP.request(conn, "GET", path, headers, nil)
3661
end
3762

38-
@spec new(Mint.HTTP.t(), reference(), pos_integer(), Mint.Types.headers(), Mint.Types.headers()) ::
39-
{:ok, Mint.HTTP.t(), t(), [Mint.Types.response()]} | {:error, Mint.HTTP.t(), any()}
40-
def new(conn, _request_ref, status, _request_headers, _response_headers) when status != 101 do
41-
{:error, conn, :connection_not_upgraded}
63+
def upgrade(
64+
%Mint.HTTP2{server_settings: %{enable_connect_protocol: true}} = conn,
65+
path,
66+
headers,
67+
_opts
68+
) do
69+
headers =
70+
[
71+
{":scheme", conn.scheme},
72+
{":path", path},
73+
{":protocol", "websocket"}
74+
| headers
75+
] ++ Utils.headers(:http2)
76+
77+
Mint.HTTP.request(conn, "CONNECT", path, headers, :stream)
4278
end
4379

44-
def new(conn, request_ref, _status, request_headers, response_headers) do
45-
with :ok <- Utils.check_accept_nonce(request_headers, response_headers) do
46-
conn = re_open_request(conn, request_ref)
80+
def upgrade(%Mint.HTTP2{} = conn, _path, _headers, _opts) do
81+
{:error, conn, %WebSocketError{reason: :extended_connect_disabled}}
82+
end
83+
84+
@spec new(Mint.HTTP.t(), reference(), pos_integer(), Mint.Types.headers()) ::
85+
{:ok, Mint.HTTP.t(), t(), [Mint.Types.response()]} | {:error, Mint.HTTP.t(), error()}
86+
def new(%Mint.HTTP1{} = conn, _request_ref, status, _response_headers)
87+
when status != 101 do
88+
{:error, conn, %WebSocketError{reason: :connection_not_upgraded}}
89+
end
4790

48-
websocket = %__MODULE__{
49-
extensions: Utils.common_extensions(request_headers, response_headers)
50-
}
91+
def new(%Mint.HTTP1{} = conn, request_ref, _status, response_headers) do
92+
with :ok <- Utils.check_accept_nonce(conn.private[:sec_websocket_key], response_headers) do
93+
conn = re_open_request(conn, request_ref)
5194

52-
{:ok, conn, websocket}
95+
{:ok, conn, %__MODULE__{}}
5396
else
5497
{:error, reason} -> {:error, conn, reason}
5598
end
5699
end
57100

101+
def new(%Mint.HTTP2{} = conn, _request_ref, status, _response_headers)
102+
when status in 200..299 do
103+
{:ok, conn, %__MODULE__{}}
104+
end
105+
106+
def new(%Mint.HTTP2{} = conn, _request_ref, _status, _response_headers) do
107+
{:error, conn, %WebSocketError{reason: :connection_not_upgraded}}
108+
end
109+
58110
@spec encode(t(), frame()) :: {:ok, t(), binary()} | {:error, t(), any()}
59111
def encode(%__MODULE__{} = websocket, frame) when is_frame(frame) do
60112
case frame |> Frame.translate() |> Frame.encode() do

lib/mint/web_socket/utils.ex

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,29 @@ defmodule Mint.WebSocket.Utils do
77
:crypto.strong_rand_bytes(16) |> Base.encode64()
88
end
99

10-
def headers(nonce) when is_binary(nonce) do
10+
def headers({:http1, nonce}) when is_binary(nonce) do
1111
[
1212
{"upgrade", "websocket"},
1313
{"connection", "upgrade"},
1414
{"sec-websocket-version", "13"},
1515
{"sec-websocket-key", nonce}
16-
# {"sec-websocket-extensions", "permessage-deflate"}
1716
]
1817
end
1918

20-
@spec check_accept_nonce(Mint.Types.headers(), Mint.Types.headers()) ::
19+
def headers(:http2) do
20+
[
21+
{"sec-websocket-version", "13"}
22+
]
23+
end
24+
25+
@spec check_accept_nonce(binary() | nil, Mint.Types.headers()) ::
2126
:ok | {:error, :invalid_nonce}
22-
def check_accept_nonce(request_headers, response_headers) do
23-
with {:ok, request_nonce} <- fetch_header(request_headers, "sec-websocket-key"),
24-
{:ok, response_nonce} <- fetch_header(response_headers, "sec-websocket-accept"),
27+
def check_accept_nonce(nil, _response_headers) do
28+
{:error, :invalid_nonce}
29+
end
30+
31+
def check_accept_nonce(request_nonce, response_headers) do
32+
with {:ok, response_nonce} <- fetch_header(response_headers, "sec-websocket-accept"),
2533
true <- valid_accept_nonce?(request_nonce, response_nonce) do
2634
:ok
2735
else

lib/mint/web_socket_error.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule Mint.WebSocketError do
2+
@moduledoc """
3+
Represents an error in the WebSocket protocol
4+
5+
The `Mint.WebSocketError` struct is an exception, so it can be raised as
6+
any other exception.
7+
"""
8+
9+
reason_type =
10+
quote do
11+
:extended_connect_disabled
12+
| :connection_not_upgraded
13+
end
14+
15+
@type t :: %__MODULE__{reason: unquote(reason_type) | term()}
16+
17+
defexception [:reason]
18+
19+
@impl Exception
20+
def message(%__MODULE__{reason: reason}) do
21+
format_reason(reason)
22+
end
23+
24+
defp format_reason(:extended_connect_disabled) do
25+
"extended CONNECT method not enabled"
26+
end
27+
28+
defp format_reason(:connection_not_upgraded) do
29+
"connection not upgraded by remote"
30+
end
31+
end

mix.exs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ defmodule MintWebSocket.MixProject do
77
version: "0.1.0",
88
elixir: "~> 1.8",
99
elixirc_paths: elixirc_paths(Mix.env()),
10+
erlc_paths: erlc_paths(Mix.env()),
1011
start_permanent: Mix.env() == :prod,
1112
deps: deps(),
1213
test_coverage: [tool: ExCoveralls],
@@ -26,13 +27,19 @@ defmodule MintWebSocket.MixProject do
2627

2728
defp deps do
2829
[
29-
{:mint, "~> 1.0"},
30+
{:mint,
31+
git: "https://github.com/elixir-mint/mint.git",
32+
ref: "488a6ba5fd418a52f697a8d5f377c629ea96af92"},
3033
{:castore, ">= 0.0.0", only: [:dev]},
3134
{:jason, ">= 0.0.0", only: [:dev, :test]},
35+
{:cowboy, "~> 2.9", only: [:test]},
3236
{:excoveralls, "~> 0.14", only: [:test]}
3337
]
3438
end
3539

3640
defp elixirc_paths(:test), do: ["lib", "test/fixtures"]
3741
defp elixirc_paths(_), do: ["lib"]
42+
43+
defp erlc_paths(:test), do: ["src", "test/fixtures"]
44+
defp erlc_paths(_), do: ["src"]
3845
end

mix.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
%{
22
"castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"},
33
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
4+
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
5+
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
46
"excoveralls": {:hex, :excoveralls, "0.14.0", "4b562d2acd87def01a3d1621e40037fdbf99f495ed3a8570dfcf1ab24e15f76d", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "94f17478b0cca020bcd85ce7eafea82d2856f7ed022be777734a2f864d36091a"},
57
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
68
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
79
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
810
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
911
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
10-
"mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"},
12+
"mint": {:git, "https://github.com/elixir-mint/mint.git", "488a6ba5fd418a52f697a8d5f377c629ea96af92", [ref: "488a6ba5fd418a52f697a8d5f377c629ea96af92"]},
1113
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
14+
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
1215
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
1316
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
1417
}

0 commit comments

Comments
 (0)