├── autobahn
├── reports
│ └── .gitignore
├── config
│ └── fuzzingserver.json
└── favicon.sh
├── .formatter.exs
├── test
├── test_helper.exs
├── fixtures
│ ├── http_handler.ex
│ ├── forbidden_handler.ex
│ ├── test_server.ex
│ ├── websocket_handler.ex
│ └── autobahn_client.ex
├── compare
│ ├── README.md
│ └── gun
│ │ └── gun_autobahn.erl
└── mint
│ ├── web_socket
│ ├── frame_test.exs
│ └── autobahn_test.exs
│ └── web_socket_test.exs
├── coveralls.json
├── lib
└── mint
│ ├── web_socket
│ ├── upgrade_failure_error.ex
│ ├── utils.ex
│ ├── per_message_deflate.ex
│ ├── extension.ex
│ └── frame.ex
│ ├── web_socket_error.ex
│ └── web_socket.ex
├── docker-compose.yml
├── .gitignore
├── examples
├── echo.exs
├── phoenixchat_herokuapp.exs
└── genserver.exs
├── mix.exs
├── .github
└── workflows
│ └── main.yml
├── CHANGELOG.md
├── mix.lock
├── README.md
└── LICENSE
/autobahn/reports/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.configure(assert_receive_timeout: 500, exclude: [compression: :stress, flaky: true])
2 | ExUnit.start()
3 |
--------------------------------------------------------------------------------
/autobahn/config/fuzzingserver.json:
--------------------------------------------------------------------------------
1 | {
2 | "url": "ws://0.0.0.0:9001",
3 | "outdir": "./reports",
4 | "cases": ["*"],
5 | "exclude-cases": [],
6 | "exclude-agent-cases": {}
7 | }
8 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 75,
4 | "treat_no_relevant_lines_as_covered": true
5 | },
6 | "terminal_options": {
7 | "file_column_width": 60
8 | },
9 | "skip_files": ["^deps", "^test/compare/", "^test/fixtures/websocket_"]
10 | }
11 |
--------------------------------------------------------------------------------
/test/fixtures/http_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule HttpHandler do
2 | @moduledoc """
3 | A Cowboy HTTP handler that serves a GET request in the test suite
4 | """
5 |
6 | def init(req, state) do
7 | req = :cowboy_req.reply(200, %{"content_type" => "text/plain"}, "hi!", req)
8 |
9 | {:ok, req, state}
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/autobahn/favicon.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | sed -i 's/
/\n \n Autobahn Report - Mint.WebSocket<\/title>/' $1
4 |
--------------------------------------------------------------------------------
/test/fixtures/forbidden_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule ForbiddenHandler do
2 | @moduledoc """
3 | A Cowboy HTTP handler that serves a GET request in the test suite
4 | and returns a 403 status code.
5 |
6 | See https://http.cat/403 :)
7 | """
8 |
9 | def init(req, state) do
10 | req = :cowboy_req.reply(403, %{"content_type" => "text/plain"}, "Forbidden.", req)
11 |
12 | {:ok, req, state}
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/compare/README.md:
--------------------------------------------------------------------------------
1 | ## Comparisons
2 |
3 | This directory is for comparing other WebSocket clients to Mint.
4 |
5 | ### Gun
6 |
7 | After the merging of
8 | [#17](https://github.com/elixir-mint/mint_web_socket/pull/17),
9 | Mint.WebSocket is pretty comparable performance and conformance-wise to
10 | [`ninenines/gun`](https://github.com/ninenines/gun).
11 |
12 | See the comparison Autobahn|Testsuite report
13 | [here](https://elixir-mint.github.io/mint_web_socket/compare/gun/index.html).
14 |
15 | The gun implementation is included in the `./gun` subdirectory.
16 |
--------------------------------------------------------------------------------
/test/fixtures/test_server.ex:
--------------------------------------------------------------------------------
1 | defmodule TestServer do
2 | @moduledoc """
3 | A supervisor for the WebsocketHandler
4 | """
5 |
6 | def start() do
7 | dispatch =
8 | :cowboy_router.compile([
9 | {:_,
10 | [
11 | {~c"/", WebsocketHandler, []},
12 | {~c"/http_get", HttpHandler, []},
13 | {~c"/forbidden", ForbiddenHandler, []}
14 | ]}
15 | ])
16 |
17 | {:ok, _} =
18 | :cowboy.start_clear(:http, [port: 7070], %{
19 | env: %{dispatch: dispatch},
20 | enable_connect_protocol: true
21 | })
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/mint/web_socket/frame_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.FrameTest do
2 | use ExUnit.Case, async: true
3 |
4 | import Mint.WebSocket.Frame
5 |
6 | test "the fin? guard correctly detects the fin bit in frames" do
7 | assert text(fin?: true, data: "hello") |> is_fin()
8 | refute text(fin?: false, data: "hello") |> is_fin()
9 |
10 | assert ping(fin?: true) |> is_fin()
11 | end
12 |
13 | test "incomplete frames should return error" do
14 | assert {:error, :unexpected_continuation} =
15 | translate({:continuation, <<0x0::size(3)>>, nil, "hello", true})
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/mint/web_socket/upgrade_failure_error.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.UpgradeFailureError do
2 | @moduledoc """
3 | An error representing a failure to upgrade protocols from HTTP to WebSocket
4 | """
5 |
6 | @type t() :: %__MODULE__{
7 | status_code: Mint.Types.status(),
8 | headers: [Mint.Types.headers()]
9 | }
10 | defexception [:status_code, :headers]
11 |
12 | def message(%__MODULE__{} = error) do
13 | """
14 | Could not upgrade from HTTP to WebSocket. The server returned status code #{error.status_code} with headers:
15 | #{inspect(error.headers)}
16 | """
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/fixtures/websocket_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule WebsocketHandler do
2 | @moduledoc """
3 | An example websocket handler for cowboy with compression enabled
4 | """
5 |
6 | @behaviour :cowboy_websocket
7 |
8 | @impl :cowboy_websocket
9 | def init(req, state) do
10 | {:cowboy_websocket, req, state, %{compress: true}}
11 | end
12 |
13 | @impl :cowboy_websocket
14 | def websocket_init(state), do: {[], state}
15 |
16 | @impl :cowboy_websocket
17 | def websocket_handle({:text, msg}, state), do: {[text: msg], state}
18 |
19 | def websocket_handle(_data, state), do: {[], state}
20 |
21 | @impl :cowboy_websocket
22 | def websocket_info(_info, state), do: {[], state}
23 | end
24 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | echo:
5 | image: crossbario/autobahn-testsuite:latest
6 | command: wstest -m echoserver -w ws://0.0.0.0:9000
7 | ports:
8 | - 9000:9000
9 |
10 | fuzzingserver:
11 | image: crossbario/autobahn-testsuite:latest
12 | volumes:
13 | - ./autobahn/config:/config
14 | - ./autobahn/reports:/reports
15 | ports:
16 | - 9001:9001
17 |
18 | app:
19 | image: elixir:1.13.2
20 | environment:
21 | - 'ERL_AFLAGS=-kernel shell_history enabled'
22 | - ECHO_HOST=echo
23 | - FUZZINGSERVER_HOST=fuzzingserver
24 | volumes:
25 | - ./:/app
26 | working_dir: /app
27 | command: bash -c "mix local.hex --force && mix local.rebar --force && tail -f /dev/null"
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | mint_web_socket-*.tar
24 |
25 |
26 | # Temporary files for e.g. tests
27 | /tmp
28 |
29 | # Dialyzer PLTs
30 | /priv/plts/*.plt
31 | /priv/plts/*.plt.hash
32 |
--------------------------------------------------------------------------------
/lib/mint/web_socket_error.ex:
--------------------------------------------------------------------------------
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 | | :payload_too_large
13 | | {:extension_not_negotiated, Mint.WebSocket.Extension.t()}
14 | end
15 |
16 | @type t :: %__MODULE__{reason: unquote(reason_type) | term()}
17 |
18 | defexception [:reason]
19 |
20 | @impl Exception
21 | def message(%__MODULE__{reason: reason}) do
22 | format_reason(reason)
23 | end
24 |
25 | defp format_reason(:extended_connect_disabled) do
26 | "extended CONNECT method not enabled"
27 | end
28 |
29 | defp format_reason(:payload_too_large) do
30 | "frame payload cannot exceed 9,223,372,036,854,775,807 bytes"
31 | end
32 |
33 | defp format_reason({:extension_not_negotiated, extension}) do
34 | "the remote server accepted an extension the client did not offer: #{inspect(extension)}"
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/examples/echo.exs:
--------------------------------------------------------------------------------
1 | Mix.install([:mint_web_socket, :castore])
2 | require Logger
3 |
4 | # see https://websocket.org/echo.html
5 | {:ok, conn} = Mint.HTTP.connect(:https, "echo.websocket.org", 443)
6 | Logger.debug("Connected to https://echo.websocket.org:443")
7 |
8 | Logger.debug("Upgrading to WebSocket protocol on /")
9 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:wss, conn, "/", [])
10 |
11 | message = receive(do: (message -> message))
12 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
13 | Mint.WebSocket.stream(conn, message)
14 | {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers)
15 | Logger.debug("WebSocket established")
16 |
17 | frame = {:text, "Rock it with Mint.WebSocket"}
18 | Logger.debug("Sending frame #{inspect(frame)}")
19 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, frame)
20 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
21 |
22 | message = receive(do: (message -> message))
23 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
24 | {:ok, websocket, frames} = Mint.WebSocket.decode(websocket, data)
25 | Logger.debug("Received frames #{inspect(frames)}")
26 |
27 | frame = :close
28 | Logger.debug("Sending frame #{inspect(frame)}")
29 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, frame)
30 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
31 |
32 | message = receive(do: (message -> message))
33 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
34 | {:ok, websocket, frames} = Mint.WebSocket.decode(websocket, data)
35 | Logger.debug("Received frames #{inspect(frames)}")
36 |
37 | Mint.HTTP.close(conn)
38 |
--------------------------------------------------------------------------------
/examples/phoenixchat_herokuapp.exs:
--------------------------------------------------------------------------------
1 | # this is a phoenix v1.3 server that sends pings periodically
2 | # see https://phoenixchat.herokuapp.com for the in-browser version
3 | {:ok, conn} = Mint.HTTP.connect(:https, "phoenixchat.herokuapp.com", 443)
4 |
5 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:wss, conn, "/ws", [])
6 |
7 | http_get_message = receive(do: (message -> message))
8 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
9 | Mint.WebSocket.stream(conn, http_get_message)
10 | {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers)
11 |
12 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, ~s[{"topic":"rooms:lobby","event":"phx_join","payload":{},"ref":1}]})
13 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
14 |
15 | message = receive(do: (message -> message))
16 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
17 | {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data)
18 | IO.inspect(messages)
19 |
20 | message = receive(do: (message -> message))
21 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
22 | {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data)
23 | IO.inspect(messages)
24 |
25 | message = receive(do: (message -> message))
26 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
27 | {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data)
28 | IO.inspect(messages)
29 |
30 | message = receive(do: (message -> message))
31 | {:ok, _conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message)
32 | {:ok, _websocket, messages} = Mint.WebSocket.decode(websocket, data)
33 | IO.inspect(messages)
34 |
--------------------------------------------------------------------------------
/test/mint/web_socket/autobahn_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.AutobahnTest do
2 | @moduledoc """
3 | A suite of tests against the Autobahn|Testsuite
4 |
5 | See https://github.com/crossbario/autobahn-testsuite
6 | """
7 | use ExUnit.Case, async: true
8 |
9 | @moduletag :autobahn
10 | @moduletag :capture_log
11 |
12 | @extensions [
13 | {~r"^12\.", [Mint.WebSocket.PerMessageDeflate]},
14 | {~r"^13\.",
15 | [
16 | {Mint.WebSocket.PerMessageDeflate,
17 | [client_no_context_takeover: true, client_max_window_bits: true]}
18 | ]},
19 | {~r/.*/, []}
20 | ]
21 |
22 | @test_tags [
23 | {~r"^9\.", :performance},
24 | {~r"^1(2|3)\.\d\.(1|2|3|4)$", compression: :basic},
25 | {~r"^1(2|3)\.", compression: :stress},
26 | {~r"^7\.1\.6$", flaky: true}
27 | ]
28 |
29 | setup_all do
30 | on_exit(&AutobahnClient.update_reports/0)
31 | end
32 |
33 | describe "Autobahn|Testsuite" do
34 | for case_number <- Range.new(1, AutobahnClient.get_case_count()) do
35 | info = Task.await(Task.async(fn -> AutobahnClient.get_case_info(case_number) end))
36 |
37 | if tag =
38 | Enum.find_value(@test_tags, fn {regex, tag} -> Regex.match?(regex, info.id) && tag end) do
39 | @tag tag
40 | end
41 |
42 | test inspect("case #{info.id} (##{case_number}): #{info.description}", printable_limit: 200) do
43 | extensions = extensions_for_case(unquote(info.id))
44 | assert AutobahnClient.run_case(unquote(case_number), extensions) == :ok
45 |
46 | assert AutobahnClient.get_case_status(unquote(case_number)) in ~w[OK NON-STRICT INFORMATIONAL]
47 | end
48 | end
49 | end
50 |
51 | defp extensions_for_case(case_id) do
52 | Enum.find_value(@extensions, fn {id_regex, extensions} ->
53 | Regex.match?(id_regex, case_id) && extensions
54 | end)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule MintWebSocket.MixProject do
2 | use Mix.Project
3 |
4 | @source_url "https://github.com/elixir-mint/mint_web_socket"
5 |
6 | def project do
7 | [
8 | app: :mint_web_socket,
9 | version: "1.0.5",
10 | elixir: "~> 1.6",
11 | elixirc_paths: elixirc_paths(Mix.env()),
12 | erlc_paths: erlc_paths(Mix.env()),
13 | start_permanent: Mix.env() == :prod,
14 | deps: deps(),
15 | test_coverage: [tool: ExCoveralls],
16 | dialyzer: [
17 | plt_local_path: "priv/plts",
18 | plt_add_apps: [:mix]
19 | ],
20 | package: package(),
21 | description: description(),
22 | source_url: @source_url,
23 | name: "MintWebSocket",
24 | docs: docs()
25 | ]
26 | end
27 |
28 | def application do
29 | [
30 | extra_applications: [:logger]
31 | ]
32 | end
33 |
34 | def cli do
35 | [
36 | preferred_envs: [
37 | coveralls: :test,
38 | "coveralls.html": :test,
39 | "coveralls.github": :test,
40 | docs: :dev
41 | ]
42 | ]
43 | end
44 |
45 | defp deps do
46 | [
47 | {:mint, "~> 1.4 and >= 1.4.1"},
48 | {:ex_doc, "~> 0.24", only: [:dev], runtime: false},
49 | {:castore, ">= 0.0.0", only: [:dev]},
50 | {:jason, ">= 0.0.0", only: [:dev, :test]},
51 | {:cowboy, "~> 2.14", only: [:test]},
52 | {:gun, "~> 2.2", only: [:test]},
53 | {:excoveralls, "~> 0.14", only: [:test]},
54 | {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}
55 | ]
56 | end
57 |
58 | defp elixirc_paths(:test), do: ["lib", "test/fixtures"]
59 | defp elixirc_paths(_), do: ["lib"]
60 |
61 | defp erlc_paths(:test), do: ["src", "test/compare"]
62 | defp erlc_paths(_), do: ["src"]
63 |
64 | defp package do
65 | [
66 | name: "mint_web_socket",
67 | files: ~w(lib .formatter.exs mix.exs README.md),
68 | licenses: ["Apache-2.0"],
69 | links: %{
70 | "GitHub" => @source_url,
71 | "Changelog" => @source_url <> "/blob/main/CHANGELOG.md"
72 | }
73 | ]
74 | end
75 |
76 | defp description do
77 | "HTTP/1 and HTTP/2 WebSocket support for Mint"
78 | end
79 |
80 | defp docs do
81 | [
82 | deps: [],
83 | language: "en",
84 | formatters: ["html"],
85 | main: Mint.WebSocket,
86 | extras: [
87 | "CHANGELOG.md"
88 | ],
89 | skip_undefined_reference_warnings_on: [
90 | "CHANGELOG.md"
91 | ]
92 | ]
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/lib/mint/web_socket/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.Utils do
2 | @moduledoc false
3 |
4 | alias Mint.WebSocket.Extension
5 |
6 | @websocket_guid "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
7 |
8 | def random_nonce do
9 | :crypto.strong_rand_bytes(16) |> Base.encode64()
10 | end
11 |
12 | def headers({:http1, nonce}, extensions) when is_binary(nonce) do
13 | [
14 | {"upgrade", "websocket"},
15 | {"connection", "upgrade"},
16 | {"sec-websocket-version", "13"},
17 | {"sec-websocket-key", nonce},
18 | {"sec-websocket-extensions", extension_string(extensions)}
19 | ]
20 | |> Enum.reject(fn {_k, v} -> v == "" end)
21 | end
22 |
23 | def headers(:http2, extensions) do
24 | [
25 | {"sec-websocket-version", "13"},
26 | {"sec-websocket-extensions", extension_string(extensions)}
27 | ]
28 | |> Enum.reject(fn {_k, v} -> v == "" end)
29 | end
30 |
31 | @spec check_accept_nonce(binary() | nil, Mint.Types.headers()) ::
32 | :ok | {:error, :invalid_nonce}
33 | def check_accept_nonce(nil, _response_headers) do
34 | {:error, :invalid_nonce}
35 | end
36 |
37 | def check_accept_nonce(request_nonce, response_headers) do
38 | with {:ok, response_nonce} <- fetch_header(response_headers, "sec-websocket-accept"),
39 | true <- valid_accept_nonce?(request_nonce, response_nonce) do
40 | :ok
41 | else
42 | _header_not_found_or_not_valid_nonce ->
43 | {:error, :invalid_nonce}
44 | end
45 | end
46 |
47 | def valid_accept_nonce?(request_nonce, response_nonce) do
48 | expected_nonce = :crypto.hash(:sha, request_nonce <> @websocket_guid) |> Base.encode64()
49 |
50 | # note that this is not a security measure so we do not need to make this
51 | # a constant-time equality check
52 | response_nonce == expected_nonce
53 | end
54 |
55 | defp fetch_header(headers, key) do
56 | Enum.find_value(headers, :error, fn
57 | {^key, value} -> {:ok, value}
58 | _ -> false
59 | end)
60 | end
61 |
62 | def maybe_concat(<<>>, data), do: data
63 | def maybe_concat(a, b), do: a <> b
64 |
65 | defp extension_string(extensions) when is_list(extensions) do
66 | Enum.map_join(extensions, ", ", &extension_string/1)
67 | end
68 |
69 | defp extension_string(%Extension{name: name, params: []}), do: name
70 |
71 | defp extension_string(%Extension{name: name, params: params}) do
72 | params =
73 | params
74 | |> Enum.map(fn {key, value} ->
75 | if value == "true", do: key, else: "#{key}=#{value}"
76 | end)
77 |
78 | Enum.join([name | params], "; ")
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/compare/gun/gun_autobahn.erl:
--------------------------------------------------------------------------------
1 | %% Copyright (c) 2015-2020, Loïc Hoguin
2 | %%
3 | %% Permission to use, copy, modify, and/or distribute this software for any
4 | %% purpose with or without fee is hereby granted, provided that the above
5 | %% copyright notice and this permission notice appear in all copies.
6 | %%
7 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 | %% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 | %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 |
15 | -module(gun_autobahn).
16 | -compile(export_all).
17 | -compile(nowarn_export_all).
18 |
19 | autobahn_fuzzingserver() ->
20 | N = get_case_count(),
21 | run_cases(0, N),
22 | terminate().
23 |
24 | get_case_count() ->
25 | {Pid, MRef, StreamRef} = connect("/getCaseCount"),
26 | receive
27 | {gun_ws, Pid, StreamRef, {text, N}} ->
28 | close(Pid, MRef),
29 | binary_to_integer(N);
30 | _Msg ->
31 | terminate(),
32 | error(failed)
33 | end.
34 |
35 | run_cases(Total, Total) ->
36 | ok;
37 | run_cases(N, Total) ->
38 | {Pid, MRef, StreamRef} = connect(["/runCase?case=", integer_to_binary(N + 1), "&agent=Gun"]),
39 | loop(Pid, MRef, StreamRef),
40 | update_reports(),
41 | run_cases(N + 1, Total).
42 |
43 | loop(Pid, MRef, StreamRef) ->
44 | receive
45 | {gun_ws, Pid, StreamRef, close} ->
46 | gun:ws_send(Pid, StreamRef, close),
47 | loop(Pid, MRef, StreamRef);
48 | {gun_ws, Pid, StreamRef, {close, Code, _}} ->
49 | gun:ws_send(Pid, StreamRef, {close, Code, <<>>}),
50 | loop(Pid, MRef, StreamRef);
51 | {gun_ws, Pid, StreamRef, Frame} ->
52 | gun:ws_send(Pid, StreamRef, Frame),
53 | loop(Pid, MRef, StreamRef);
54 | {gun_down, Pid, ws, _, _} ->
55 | close(Pid, MRef);
56 | {'DOWN', MRef, process, Pid, normal} ->
57 | close(Pid, MRef);
58 | _Msg ->
59 | close(Pid, MRef)
60 | end.
61 |
62 | update_reports() ->
63 | {Pid, MRef, StreamRef} = connect("/updateReports?agent=Gun"),
64 | receive
65 | {gun_ws, Pid, StreamRef, close} ->
66 | close(Pid, MRef)
67 | after 5000 ->
68 | error(failed)
69 | end.
70 |
71 | connect(Path) ->
72 | {ok, Pid} = gun:open("fuzzingserver", 9001, #{retry => 0}),
73 | {ok, http} = gun:await_up(Pid),
74 | MRef = monitor(process, Pid),
75 | StreamRef = gun:ws_upgrade(Pid, Path, [], #{compress => true}),
76 | receive
77 | {gun_upgrade, Pid, StreamRef, [<<"websocket">>], _} ->
78 | ok;
79 | _Msg ->
80 | terminate(),
81 | error(failed)
82 | end,
83 | {Pid, MRef, StreamRef}.
84 |
85 | close(Pid, MRef) ->
86 | demonitor(MRef),
87 | gun:close(Pid),
88 | gun:flush(Pid).
89 |
90 | terminate() ->
91 | ok.
92 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 |
7 | jobs:
8 | test:
9 | name: Test (Elixir ${{ matrix.elixir }}, OTP ${{ matrix.erlang }})
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | include:
15 | - erlang: "28.1"
16 | elixir: "1.19"
17 | lint: true
18 | coverage: true
19 | report: true
20 | dialyzer: true
21 | # One version down.
22 | - erlang: "27.2"
23 | elixir: "1.18"
24 | # Oldest version. We technically support OTP 23 but hard to test in CI
25 | - erlang: "24.3"
26 | elixir: "1.14"
27 | env:
28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29 | MIX_ENV: test
30 | ECHO_HOST: localhost
31 | FUZZINGSERVER_HOST: localhost
32 |
33 | steps:
34 | - uses: actions/checkout@v4
35 |
36 | - name: Install OTP and Elixir
37 | uses: erlef/setup-beam@v1
38 | with:
39 | otp-version: ${{ matrix.erlang }}
40 | elixir-version: ${{ matrix.elixir }}
41 |
42 | - name: Cache dependencies
43 | id: cache-deps
44 | uses: actions/cache@v4
45 | with:
46 | path: |
47 | deps
48 | _build
49 | key: ${{ runner.os }}-mix-otp${{ matrix.erlang }}-elixir${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
50 |
51 | - name: Install and compile dependencies
52 | if: steps.cache-deps.outputs.cache-hit != 'true'
53 | run: |
54 | mix deps.get --only test
55 | mix deps.compile
56 |
57 | - name: Start docker-compose
58 | run: docker compose up --detach echo fuzzingserver
59 |
60 | - name: Check for unused dependencies
61 | run: mix deps.get && mix deps.unlock --check-unused
62 | if: matrix.lint && steps.cache-deps.outputs.cache-hit != 'true'
63 |
64 | - name: Compile with --warnings-as-errors
65 | run: mix compile --warnings-as-errors
66 | if: matrix.lint
67 |
68 | - name: Restore cached PLTs
69 | uses: actions/cache@v4
70 | id: plt_cache
71 | if: ${{ matrix.dialyzer }}
72 | with:
73 | key: |
74 | ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.erlang }}-plt
75 | restore-keys: |
76 | ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.erlang }}-plt
77 | path: |
78 | priv/plts
79 |
80 | # Create PLTs if no cached PLTs were found
81 | - name: Create PLTs
82 | if: matrix.dialyzer && steps.plt_cache.outputs.cache-hit != 'true'
83 | run: MIX_ENV=test mix dialyzer --plt
84 |
85 | - name: Run dialyzer
86 | run: MIX_ENV=test mix dialyzer
87 | if: matrix.dialyzer
88 |
89 | - name: Run tests with coverage
90 | run: mix coveralls.github
91 | if: matrix.coverage
92 |
93 | - name: Run tests
94 | run: mix test --trace
95 | if: '!matrix.coverage'
96 |
97 | - name: Check mix format
98 | run: mix format --check-formatted
99 | if: matrix.lint
100 |
101 | - name: Add seedling favicon to autobahn report
102 | if: github.ref == 'refs/heads/main' && matrix.report
103 | run: ./autobahn/favicon.sh ./autobahn/reports/index.html
104 |
105 | - name: Checkout gh-pages branch to ./gh-pages
106 | if: github.ref == 'refs/heads/main' && matrix.report
107 | uses: actions/checkout@v4
108 | with:
109 | ref: gh-pages
110 | path: ./gh-pages
111 |
112 | - name: Move autobahn report results
113 | if: github.ref == 'refs/heads/main' && matrix.report
114 | run: mv ./autobahn/reports/* ./gh-pages/
115 |
116 | - name: Commit autobahn report to gh-pages branch
117 | if: github.ref == 'refs/heads/main' && matrix.report
118 | run: |
119 | cd ./gh-pages
120 | git config --local user.email "$(git log --format='%ae' HEAD^!)"
121 | git config --local user.name "$(git log --format='%an' HEAD^!)"
122 | git add *.{html,json}
123 | git commit -m "publish Autobahn|Testsuite report" || true
124 | git push
125 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a
6 | Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8 |
9 | ## 1.0.5 - 2025-11-05
10 |
11 | ### Fixed
12 |
13 | - Fixed a type warning on Elixir 1.19+ about struct updating in the
14 | `Extension` module.
15 |
16 | ## 1.0.4 - 2024-06-14
17 |
18 | ### Fixed
19 |
20 | - Fixed a compilation warning present with Elixir 1.17+ about "`4..1`" not
21 | using a range step.
22 |
23 | ## 1.0.3 - 2023-04-16
24 |
25 | ### Fixed
26 |
27 | - Unexpected continuation frames are now handled.
28 | - Previously, unexpected continuation frames received from a server
29 | would result in a FunctionClauseError.
30 |
31 | ## 1.0.2 - 2022-12-11
32 |
33 | ### Fixed
34 |
35 | - Dialyzer errors have been fixed and specs have been improved.
36 |
37 | ## 1.0.1 - 2022-09-18
38 |
39 | ### Fixed
40 |
41 | - WebSocket frames are now correctly buffered when a partially received
42 | frame is split in the payload-length segment.
43 |
44 | ## 1.0.0 - 2022-04-12
45 |
46 | v1.0.0 is released!
47 |
48 | We've been using `Mint.WebSocket` in production for the better part of a year
49 | without trouble.
50 |
51 | v1.0.0 is code-wise the same as v0.3.0: it represents a stability milestone.
52 | `Mint.WebSocket` will now follow semantic versioning, so any breaking changes
53 | will result in a v2.0.0.
54 |
55 | ## 0.3.0 - 2022-02-27
56 |
57 | ### Changed
58 |
59 | - Failure to upgrade now gives a `Mint.WebSocket.UpgradeFailureError`
60 | as the error when a server returns a status code other than 101 for
61 | HTTP/1 or a status code outside the range 200..299 range for HTTP/2.
62 |
63 | ## 0.2.0 - 2022-02-17
64 |
65 | This release is a breaking change from the 0.1.0 series. This update removes
66 | all instances where Mint.WebSocket would access opaque `t:Mint.HTTP.t/0` fields
67 | or call private functions within `Mint.HTTP1`, so now Mint.WebSocket should be
68 | more compatible with future changes to Mint.
69 |
70 | #### Upgrade guide
71 |
72 | First, add the `scheme` argument to calls to `Mint.WebSocket.upgrade/5`.
73 | For connections formed with `Mint.HTTP.connect(:http, ..)`, use the `:ws`
74 | scheme. For `Mint.HTTP.connect(:https, ..)`, use `:wss`.
75 |
76 |
77 | ```diff
78 | - Mint.WebSocket.upgrade(conn, path, headers)
79 | + Mint.WebSocket.upgrade(scheme, conn, path, headers)
80 | ```
81 |
82 | Then replace calls to `Mint.HTTP.stream/2` and/or `Mint.HTTP.recv/3` and
83 | `Mint.HTTP.stream_request_body/3` with the new `Mint.WebSocket` wrappers.
84 | This is safe to do even when these functions are being used to send and
85 | receive data in normal HTTP requests: the functionality only changes when
86 | the connection is an established HTTP/1 WebSocket.
87 |
88 | ### Added
89 |
90 | - Added `Mint.WebSocket.stream/2` which wraps `Mint.HTTP.stream/2`
91 | - Added `Mint.WebSocket.recv/3` which wraps `Mint.HTTP.recv/3`
92 | - Added `Mint.WebSocket.stream_request_body/3` which wraps `Mint.HTTP.stream_request_body/3`
93 |
94 | ### Changed
95 |
96 | - Changed function signature of `Mint.WebSocket.upgrade/5` to accept the
97 | WebSocket's scheme (`:ws` or `:wss`) as the first argument
98 | - Added an optional `opts` argument to `Mint.WebSocket.new/5` to control
99 | active vs. passive mode on the socket
100 | - Restricted compatible Mint versions to `~> 1.4`
101 | - `Mint.WebSocket` now uses `Mint.HTTP.get_protocol/1` which was
102 | introduced in `1.4.0`.
103 |
104 | ## 0.1.4 - 2021-07-06
105 |
106 | ### Fixed
107 |
108 | - Fixed typespec for `Mint.WebSocket.new/4`
109 |
110 | ## 0.1.3 - 2021-07-02
111 |
112 | ### Fixed
113 |
114 | - Switch from using `Bitwise.bor/2` to `:erlang.bor/2` for compatibility
115 | with Elixir < 1.10
116 |
117 | ## 0.1.2 - 2021-07-02
118 |
119 | ### Fixed
120 |
121 | - Switch from using `Bitwise.bxor/2` to `:erlang.bxor/2` for compatibility
122 | with Elixir < 1.10
123 |
124 | ## 0.1.1 - 2021-07-01
125 |
126 | ### Fixed
127 |
128 | - Close frame codes and reasons are now nillable instead of defaulted
129 | - The WebSocket spec does not require that a code and reason be included
130 | for all close frames
131 |
132 | ## 0.1.0 - 2021-06-30
133 |
134 | ### Added
135 |
136 | - Initial implementation
137 | - includes HTTP/1.1 and HTTP/2 support and extensions
138 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"},
3 | "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
4 | "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
5 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"},
6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
8 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"},
9 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
10 | "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"},
11 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
13 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
14 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
15 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
16 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
18 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
19 | }
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mint.WebSocket
2 |
3 | [![CI][ci-badge]][actions]
4 | [![Coverage Status][coverage-badge]][coverage]
5 | [![hex.pm version][hex-version-badge]][hex-package]
6 | [![hex.pm license][hex-licence-badge]][licence]
7 | [![Last Updated][last-updated-badge]][commits]
8 |
9 | HTTP/1 and HTTP/2 WebSocket support for Mint 🌱
10 |
11 | ## Usage
12 |
13 | `Mint.WebSocket` works together with `Mint.HTTP` API. For example,
14 | this snippet shows sending and receiving a text frame of "hello world" to a
15 | WebSocket server which echos our frames:
16 |
17 | ```elixir
18 | # bootstrap
19 | {:ok, conn} = Mint.HTTP.connect(:http, "echo", 9000)
20 |
21 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", [])
22 |
23 | http_get_message = receive(do: (message -> message))
24 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
25 | Mint.WebSocket.stream(conn, http_get_message)
26 |
27 | {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers)
28 |
29 | # send the hello world frame
30 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"})
31 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
32 |
33 | # receive the hello world reply frame
34 | hello_world_echo_message = receive(do: (message -> message))
35 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, hello_world_echo_message)
36 | {:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data)
37 | ```
38 |
39 | Check out some [examples](./examples) and the online [documentation][hex-docs].
40 |
41 | ## Functional WebSockets
42 |
43 | Mint.WebSocket (like Mint) takes a _functional_ approach.
44 | Other WebSocket implementations like
45 | [`:gun`][gun] / [`:websocket_client`][websocket-client] /
46 | [`Socket`][socket] / [`WebSockex`][websockex] work by spawning and
47 | passing messages among processes. This is a very convenient interface in
48 | Erlang and Elixir but it does not allow the author much control over
49 | the WebSocket connection.
50 |
51 | Instead `Mint.WebSocket` is process-less: the entire HTTP and WebSocket
52 | states are kept in immutable data structures. When you implement a WebSocket
53 | client with `Mint.WebSocket`, runtime behavior and process architecture
54 | are up to you: you decide how to handle things like reconnection and failures.
55 |
56 | For a practical introduction, check out Mint's [usage documentation][mint-usage].
57 |
58 | ## Spec conformance
59 |
60 | This library aims to follow [RFC6455][rfc6455] and [RFC8441][rfc8441] as
61 | closely as possible and uses the [Autobahn|Testsuite][autobahn] to check
62 | conformance with every run of tests/CI. The auto-generated report produced
63 | by the Autobahn|Testsuite is uploaded on each push to main.
64 |
65 | See the report here: https://elixir-mint.github.io/mint_web_socket/
66 |
67 | ## HTTP/2 Support
68 |
69 | HTTP/2 WebSockets are not a built-in feature of HTTP/2. In the current
70 | landscape, very few server libraries support the RFC8441's extended CONNECT
71 | method which bootstraps WebSockets.
72 |
73 | If `Mint.WebSocket.upgrade/4` returns
74 |
75 | ```elixir
76 | {:error, conn, %Mint.WebSocketError{reason: :extended_connect_disabled}}
77 | ```
78 |
79 | Then the server does not support HTTP/2 WebSockets or does not have them
80 | enabled.
81 |
82 | ## Development workflow
83 |
84 | Contributions are very welcome!
85 |
86 | If you're interested in developing `Mint.WebSocket`, you'll need docker-compose
87 | to run the fuzzing test suite. The `docker-compose.yml` sets up an Elixir
88 | container, a simple websocket echo server, and the Autobahn|Testsuite fuzzing
89 | server.
90 |
91 | In host:
92 |
93 | ```sh
94 | docker-compose up -d
95 | docker-compose exec app bash
96 | ```
97 |
98 | In app:
99 |
100 | ```sh
101 | mix deps.get
102 | mix test
103 | iex -S mix
104 | ```
105 |
106 | [ci-badge]: https://github.com/elixir-mint/mint_web_socket/workflows/CI/badge.svg
107 | [actions]: https://github.com/elixir-mint/mint_web_socket/actions/workflows/main.yml
108 | [coverage]: https://coveralls.io/github/elixir-mint/mint_web_socket
109 | [coverage-badge]: https://coveralls.io/repos/github/elixir-mint/mint_web_socket/badge.svg
110 | [hex-version-badge]: https://img.shields.io/hexpm/v/mint_web_socket.svg
111 | [hex-licence-badge]: https://img.shields.io/hexpm/l/mint_web_socket.svg
112 | [hex-package]: https://hex.pm/packages/mint_web_socket
113 | [licence]: https://github.com/elixir-mint/mint_web_socket/blob/main/LICENSE
114 | [last-updated-badge]: https://img.shields.io/github/last-commit/elixir-mint/mint_web_socket.svg
115 | [commits]: https://github.com/elixir-mint/mint_web_socket/commits/main
116 |
117 | [hex-docs]: https://hexdocs.pm/mint_web_socket/Mint.WebSocket.html
118 |
119 | [gun]: https://github.com/ninenines/gun
120 | [websocket-client]: https://github.com/jeremyong/websocket_client
121 | [socket]: https://github.com/meh/elixir-socket
122 | [websockex]: https://github.com/Azolo/websockex
123 | [mint-usage]: https://github.com/elixir-mint/mint#usage
124 |
125 | [rfc6455]: https://datatracker.ietf.org/doc/html/rfc6455
126 | [rfc8441]: https://datatracker.ietf.org/doc/html/rfc8441
127 | [autobahn]: https://github.com/crossbario/autobahn-testsuite
128 |
--------------------------------------------------------------------------------
/lib/mint/web_socket/per_message_deflate.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.PerMessageDeflate do
2 | @moduledoc """
3 | A WebSocket extension which compresses each message before sending it across
4 | the wire
5 |
6 | This extension is defined in
7 | [rfc7692](https://www.rfc-editor.org/rfc/rfc7692.html).
8 |
9 | ## Options
10 |
11 | * `:zlib_level` - (default: `:best_compression`) the compression level to
12 | use for the deflation zstream. See the `:zlib.deflateInit/6` documentation
13 | on the `Level` argument.
14 | * `:zlib_memory_level` - (default: `8`) how much memory to allow for use
15 | during compression. See the `:zlib.deflateInit/6` documentation on the
16 | `MemLevel` argument.
17 | """
18 |
19 | require Mint.WebSocket.Frame, as: Frame
20 | alias Mint.WebSocket.Extension
21 |
22 | @typedoc false
23 | @type t :: %__MODULE__{
24 | inflate: :zlib.zstream(),
25 | deflate: :zlib.zstream(),
26 | inflate_takeover?: boolean(),
27 | deflate_takeover?: boolean()
28 | }
29 |
30 | defstruct [:inflate, :deflate, :inflate_takeover?, :deflate_takeover?]
31 |
32 | @behaviour Extension
33 |
34 | @doc false
35 | @impl Extension
36 | def name, do: "permessage-deflate"
37 |
38 | @doc false
39 | @impl Extension
40 | def init(%Extension{params: params, opts: opts} = this_extension, _other_extensions) do
41 | inflate_window_bits = get_window_bits(params, "server_max_window_bits", 15)
42 | deflate_window_bits = get_window_bits(params, "client_max_window_bits", 15)
43 | inflate_zstream = :zlib.open()
44 | deflate_zstream = :zlib.open()
45 |
46 | :ok = :zlib.inflateInit(inflate_zstream, -inflate_window_bits)
47 |
48 | :ok =
49 | :zlib.deflateInit(
50 | deflate_zstream,
51 | Keyword.get(opts, :zlib_level, :best_compression),
52 | :deflated,
53 | -deflate_window_bits,
54 | Keyword.get(opts, :zlib_memory_level, 8),
55 | :default
56 | )
57 |
58 | state = %__MODULE__{
59 | inflate: inflate_zstream,
60 | deflate: deflate_zstream,
61 | inflate_takeover?: get_takeover(params, "server_no_context_takeover", true),
62 | deflate_takeover?: get_takeover(params, "client_no_context_takeover", true)
63 | }
64 |
65 | {:ok, put_in(this_extension.state, state)}
66 | end
67 |
68 | @doc false
69 | @impl Extension
70 | def decode(frame, state)
71 |
72 | # rfc section 6: "[Per-Message Compression Extensions]s operate only on data
73 | # messages"
74 | for opcode <- [:text, :binary, :continuation] do
75 | def decode(
76 | Frame.unquote(opcode)(
77 | reserved: <<1::size(1), _::bitstring>> = reserved_binary,
78 | data: data
79 | ) = frame,
80 | state
81 | ) do
82 | <> = reserved_binary
83 |
84 | # Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
85 | # payload of the message
86 |
87 | data =
88 | state.inflate
89 | |> :zlib.inflate(<>)
90 | |> IO.iodata_to_binary()
91 |
92 | if state.inflate_takeover? == false do
93 | :zlib.inflateReset(state.inflate)
94 | end
95 |
96 | frame =
97 | Frame.unquote(opcode)(frame,
98 | reserved: <<:erlang.bxor(reserved, 0b100)::size(3)>>,
99 | data: data
100 | )
101 |
102 | {:ok, frame, state}
103 | end
104 | end
105 |
106 | def decode(frame, state), do: {:ok, frame, state}
107 |
108 | @doc false
109 | @impl Extension
110 | def encode(frame, state)
111 |
112 | for opcode <- [:text, :binary, :continuation] do
113 | def encode(
114 | Frame.unquote(opcode)(
115 | reserved: <<0::size(1), _::bitstring>> = reserved_binary,
116 | data: data
117 | ) = frame,
118 | state
119 | ) do
120 | <> = reserved_binary
121 |
122 | data = deflate_data(state.deflate, data)
123 |
124 | if state.deflate_takeover? == false do
125 | :zlib.deflateReset(state.deflate)
126 | end
127 |
128 | frame =
129 | Frame.unquote(opcode)(frame,
130 | reserved: <<:erlang.bor(reserved, 0b100)::size(3)>>,
131 | data: data
132 | )
133 |
134 | {:ok, frame, state}
135 | end
136 | end
137 |
138 | def encode(frame, state), do: {:ok, frame, state}
139 |
140 | defp deflate_data(deflate_zstream, data) do
141 | deflated =
142 | deflate_zstream
143 | |> :zlib.deflate(data, :sync)
144 | |> IO.iodata_to_binary()
145 |
146 | # "Remove 4 octets (that are 0x00 0x00 0xff 0xff) from the tail end"
147 | data_size = byte_size(deflated) - 4
148 |
149 | case deflated do
150 | <> -> deflated
151 | deflated -> deflated
152 | end
153 | end
154 |
155 | defp get_window_bits(params, param_name, default) do
156 | with {:ok, value} <- fetch_param(params, param_name),
157 | {bits, _} <- Integer.parse(value) do
158 | bits
159 | else
160 | _ -> default
161 | end
162 | end
163 |
164 | defp get_takeover(params, param_name, default) when is_boolean(default) do
165 | with {:ok, value} <- fetch_param(params, param_name),
166 | {:ok, no_takeover?} <- parse_boolean(value) do
167 | not no_takeover?
168 | else
169 | _ -> default
170 | end
171 | end
172 |
173 | defp fetch_param(params, param_name) do
174 | with {^param_name, value} <- List.keyfind(params, param_name, 0, :error) do
175 | {:ok, value}
176 | end
177 | end
178 |
179 | defp parse_boolean("true"), do: {:ok, true}
180 | defp parse_boolean("false"), do: {:ok, false}
181 | defp parse_boolean(_), do: :error
182 | end
183 |
--------------------------------------------------------------------------------
/examples/genserver.exs:
--------------------------------------------------------------------------------
1 | Mix.install([:mint_web_socket, :castore])
2 |
3 | # Also see https://github.com/phoenixframework/phoenix/blob/4da71906da970a162c88e165cdd2fdfaf9083ac3/test/support/websocket_client.exs
4 |
5 | defmodule Ws do
6 | use GenServer
7 |
8 | require Logger
9 | require Mint.HTTP
10 |
11 | defstruct [:conn, :websocket, :request_ref, :caller, :status, :resp_headers, :closing?]
12 |
13 | def connect(url) do
14 | with {:ok, socket} <- GenServer.start_link(__MODULE__, []),
15 | {:ok, :connected} <- GenServer.call(socket, {:connect, url}) do
16 | {:ok, socket}
17 | end
18 | end
19 |
20 | def send_message(pid, text) do
21 | GenServer.call(pid, {:send_text, text})
22 | end
23 |
24 | @impl GenServer
25 | def init([]) do
26 | {:ok, %__MODULE__{}}
27 | end
28 |
29 | @impl GenServer
30 | def handle_call({:send_text, text}, _from, state) do
31 | {:ok, state} = send_frame(state, {:text, text})
32 | {:reply, :ok, state}
33 | end
34 |
35 | @impl GenServer
36 | def handle_call({:connect, url}, from, state) do
37 | uri = URI.parse(url)
38 |
39 | http_scheme =
40 | case uri.scheme do
41 | "ws" -> :http
42 | "wss" -> :https
43 | end
44 |
45 | ws_scheme =
46 | case uri.scheme do
47 | "ws" -> :ws
48 | "wss" -> :wss
49 | end
50 |
51 | path =
52 | case uri.query do
53 | nil -> uri.path
54 | query -> uri.path <> "?" <> query
55 | end
56 |
57 | with {:ok, conn} <- Mint.HTTP.connect(http_scheme, uri.host, uri.port),
58 | {:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme, conn, path, []) do
59 | state = %{state | conn: conn, request_ref: ref, caller: from}
60 | {:noreply, state}
61 | else
62 | {:error, reason} ->
63 | {:reply, {:error, reason}, state}
64 |
65 | {:error, conn, reason} ->
66 | {:reply, {:error, reason}, put_in(state.conn, conn)}
67 | end
68 | end
69 |
70 | @impl GenServer
71 | def handle_info(message, state) do
72 | case Mint.WebSocket.stream(state.conn, message) do
73 | {:ok, conn, responses} ->
74 | state = put_in(state.conn, conn) |> handle_responses(responses)
75 | if state.closing?, do: do_close(state), else: {:noreply, state}
76 |
77 | {:error, conn, reason, _responses} ->
78 | state = put_in(state.conn, conn) |> reply({:error, reason})
79 | {:noreply, state}
80 |
81 | :unknown ->
82 | {:noreply, state}
83 | end
84 | end
85 |
86 | defp handle_responses(state, responses)
87 |
88 | defp handle_responses(%{request_ref: ref} = state, [{:status, ref, status} | rest]) do
89 | put_in(state.status, status)
90 | |> handle_responses(rest)
91 | end
92 |
93 | defp handle_responses(%{request_ref: ref} = state, [{:headers, ref, resp_headers} | rest]) do
94 | put_in(state.resp_headers, resp_headers)
95 | |> handle_responses(rest)
96 | end
97 |
98 | defp handle_responses(%{request_ref: ref} = state, [{:done, ref} | rest]) do
99 | case Mint.WebSocket.new(state.conn, ref, state.status, state.resp_headers) do
100 | {:ok, conn, websocket} ->
101 | %{state | conn: conn, websocket: websocket, status: nil, resp_headers: nil}
102 | |> reply({:ok, :connected})
103 | |> handle_responses(rest)
104 |
105 | {:error, conn, reason} ->
106 | put_in(state.conn, conn)
107 | |> reply({:error, reason})
108 | end
109 | end
110 |
111 | defp handle_responses(%{request_ref: ref, websocket: websocket} = state, [
112 | {:data, ref, data} | rest
113 | ])
114 | when websocket != nil do
115 | case Mint.WebSocket.decode(websocket, data) do
116 | {:ok, websocket, frames} ->
117 | put_in(state.websocket, websocket)
118 | |> handle_frames(frames)
119 | |> handle_responses(rest)
120 |
121 | {:error, websocket, reason} ->
122 | put_in(state.websocket, websocket)
123 | |> reply({:error, reason})
124 | end
125 | end
126 |
127 | defp handle_responses(state, [_response | rest]) do
128 | handle_responses(state, rest)
129 | end
130 |
131 | defp handle_responses(state, []), do: state
132 |
133 | defp send_frame(state, frame) do
134 | with {:ok, websocket, data} <- Mint.WebSocket.encode(state.websocket, frame),
135 | state = put_in(state.websocket, websocket),
136 | {:ok, conn} <- Mint.WebSocket.stream_request_body(state.conn, state.request_ref, data) do
137 | {:ok, put_in(state.conn, conn)}
138 | else
139 | {:error, %Mint.WebSocket{} = websocket, reason} ->
140 | {:error, put_in(state.websocket, websocket), reason}
141 |
142 | {:error, conn, reason} ->
143 | {:error, put_in(state.conn, conn), reason}
144 | end
145 | end
146 |
147 | def handle_frames(state, frames) do
148 | Enum.reduce(frames, state, fn
149 | # reply to pings with pongs
150 | {:ping, data}, state ->
151 | {:ok, state} = send_frame(state, {:pong, data})
152 | state
153 |
154 | {:close, _code, reason}, state ->
155 | Logger.debug("Closing connection: #{inspect(reason)}")
156 | %{state | closing?: true}
157 |
158 | {:text, text}, state ->
159 | Logger.debug("Received: #{inspect(text)}, sending back the reverse")
160 | {:ok, state} = send_frame(state, {:text, String.reverse(text)})
161 | state
162 |
163 | frame, state ->
164 | Logger.debug("Unexpected frame received: #{inspect(frame)}")
165 | state
166 | end)
167 | end
168 |
169 | defp do_close(state) do
170 | # Streaming a close frame may fail if the server has already closed
171 | # for writing.
172 | _ = send_frame(state, :close)
173 | Mint.HTTP.close(state.conn)
174 | {:stop, :normal, state}
175 | end
176 |
177 | defp reply(state, response) do
178 | if state.caller, do: GenServer.reply(state.caller, response)
179 | put_in(state.caller, nil)
180 | end
181 | end
182 |
183 | {:ok, pid} = Ws.connect("ws://localhost:1234/")
184 | Ws.send_message(pid, "Hello from WS client")
185 |
--------------------------------------------------------------------------------
/test/fixtures/autobahn_client.ex:
--------------------------------------------------------------------------------
1 | defmodule AutobahnClient do
2 | @moduledoc """
3 | A client that uses Mint.WebSocket to test against the Autobahn|Testsuite
4 | WebSocket testing suite
5 | """
6 |
7 | import Kernel, except: [send: 2]
8 | require Logger
9 |
10 | # Dialyzer incorrectly infers that `Mint.WebSocket.new/4` in
11 | # `connect/1,2` will always return `{error, conn, reason}`.
12 | @dialyzer {:nowarn_function,
13 | connect: 1,
14 | connect: 2,
15 | get_case_count: 0,
16 | get_case_info: 1,
17 | get_case_status: 1,
18 | run_case: 1,
19 | run_case: 2,
20 | update_reports: 0}
21 |
22 | defstruct [:conn, :websocket, :ref, messages: [], next: :cont, sent_close?: false, buffer: <<>>]
23 |
24 | defguardp is_close_frame(frame)
25 | when is_tuple(frame) and elem(frame, 0) == :close
26 |
27 | def get_case_count do
28 | %{messages: [{:text, count} | _]} = connect("/getCaseCount") |> decode_buffer()
29 |
30 | String.to_integer(count)
31 | end
32 |
33 | def run_case(case_number, extensions \\ []) do
34 | _state = connect("/runCase?case=#{case_number}&agent=Mint", extensions) |> loop()
35 |
36 | :ok
37 | end
38 |
39 | def get_case_status(case_number) do
40 | %{messages: [{:text, status} | _]} =
41 | connect("/getCaseStatus?case=#{case_number}&agent=Mint") |> decode_buffer()
42 |
43 | Jason.decode!(status)["behavior"]
44 | end
45 |
46 | def get_case_info(case_number) do
47 | %{messages: [{:text, status} | _]} =
48 | connect("/getCaseInfo?case=#{case_number}&agent=Mint") |> decode_buffer()
49 |
50 | Jason.decode!(status, keys: :atoms)
51 | end
52 |
53 | def update_reports do
54 | _state = connect("/updateReports?agent=Mint") |> loop()
55 |
56 | :ok
57 | end
58 |
59 | def flush do
60 | receive do
61 | _message -> flush()
62 | after
63 | 0 -> :ok
64 | end
65 | end
66 |
67 | def connect(resource, extensions \\ []) do
68 | :ok = flush()
69 | host = System.get_env("FUZZINGSERVER_HOST") || "localhost"
70 | {:ok, conn} = Mint.HTTP.connect(:http, host, 9001)
71 |
72 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, resource, [], extensions: extensions)
73 |
74 | http_get_message = receive(do: (message -> message))
75 |
76 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers} | rest]} =
77 | Mint.WebSocket.stream(conn, http_get_message)
78 |
79 | buffer =
80 | case rest do
81 | [{:data, ^ref, data}, {:done, ^ref}] -> data
82 | [{:done, ^ref}] -> <<>>
83 | end
84 |
85 | {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers)
86 |
87 | %__MODULE__{
88 | next: :cont,
89 | conn: conn,
90 | ref: ref,
91 | websocket: websocket,
92 | buffer: buffer
93 | }
94 | end
95 |
96 | def recv(%__MODULE__{ref: ref} = state) do
97 | {:ok, conn, messages} = Mint.WebSocket.stream(state.conn, receive(do: (message -> message)))
98 |
99 | %{
100 | state
101 | | conn: conn,
102 | buffer: join_data_frames(messages, ref),
103 | next: stop_if_done(messages, ref)
104 | }
105 | end
106 |
107 | def decode_buffer(%__MODULE__{} = state) do
108 | {:ok, websocket, messages} = Mint.WebSocket.decode(state.websocket, state.buffer)
109 |
110 | %{state | messages: messages, buffer: <<>>, websocket: websocket}
111 | end
112 |
113 | def loop(%__MODULE__{} = state) do
114 | case state |> decode_buffer |> handle_messages do
115 | %{next: :cont} = state ->
116 | loop(recv(state))
117 |
118 | state ->
119 | state
120 | end
121 | end
122 |
123 | def handle_messages(%__MODULE__{} = state) do
124 | Enum.reduce(state.messages, state, fn message, state ->
125 | Logger.debug("Handling #{inspect(message, printable_limit: 30)}")
126 | handle_message(message, state)
127 | end)
128 | |> Map.put(:messages, [])
129 | end
130 |
131 | defp handle_message({:close, _code, _reason}, %__MODULE__{} = state) do
132 | close(state, 1000, "")
133 | end
134 |
135 | defp handle_message({:ping, data}, %__MODULE__{} = state) do
136 | send(state, {:pong, data})
137 | end
138 |
139 | # no-op on unsolicited pongs
140 | defp handle_message({:pong, _body}, %__MODULE__{} = state), do: state
141 |
142 | defp handle_message({:error, reason}, %__MODULE__{} = state) do
143 | Logger.debug("Closing the connection because of a protocol error: #{inspect(reason)}")
144 |
145 | code =
146 | case reason do
147 | {:invalid_utf8, _data} -> 1_007
148 | _ -> 1_002
149 | end
150 |
151 | close(state, code, "")
152 | end
153 |
154 | defp handle_message(frame, %__MODULE__{} = state), do: send(state, frame)
155 |
156 | def send(%__MODULE__{sent_close?: true} = state, frame) when is_close_frame(frame) do
157 | Logger.debug("Ignoring send of close")
158 | state
159 | end
160 |
161 | def send(state, frame) do
162 | Logger.debug("Sending #{inspect(frame, printable_limit: 30)}")
163 |
164 | case Mint.WebSocket.encode(state.websocket, frame) do
165 | {:ok, websocket, data} ->
166 | do_send(put_in(state.websocket, websocket), frame, data)
167 |
168 | {:error, websocket, reason} ->
169 | Logger.debug(
170 | "Could not send frame #{inspect(frame, printable_limit: 30)} because #{inspect(reason)}, sending close..."
171 | )
172 |
173 | send(put_in(state.websocket, websocket), {:close, 1002, ""})
174 | end
175 | end
176 |
177 | defp do_send(%__MODULE__{} = state, frame, data) do
178 | case Mint.WebSocket.stream_request_body(state.conn, state.ref, data) do
179 | {:ok, conn} ->
180 | Logger.debug("Sent.")
181 | %{state | conn: conn, sent_close?: is_close_frame(frame)}
182 |
183 | {:error, conn, %Mint.TransportError{reason: :closed}} ->
184 | Logger.debug(
185 | "Could not send frame #{inspect(frame, printable_limit: 30)} because the connection is closed"
186 | )
187 |
188 | {:ok, conn} = Mint.HTTP.close(conn)
189 | %{state | conn: conn, next: :stop}
190 | end
191 | end
192 |
193 | defp close(%__MODULE__{} = state, code, reason) do
194 | state = send(state, {:close, code, reason})
195 | {:ok, conn} = Mint.HTTP.close(state.conn)
196 | %{state | conn: conn, next: :stop}
197 | end
198 |
199 | defp join_data_frames(messages, ref) do
200 | messages
201 | |> Enum.filter(fn
202 | {:data, ^ref, _data} -> true
203 | _ -> false
204 | end)
205 | |> Enum.map_join(<<>>, fn {:data, ^ref, data} -> data end)
206 | end
207 |
208 | defp stop_if_done(messages, ref) do
209 | if Enum.any?(messages, &match?({:done, ^ref}, &1)), do: :stop, else: :cont
210 | end
211 | end
212 |
--------------------------------------------------------------------------------
/test/mint/web_socket_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocketTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Mint.{HTTP1, HTTP2, WebSocket, WebSocket.UpgradeFailureError}
5 |
6 | setup_all do
7 | TestServer.start()
8 | :ok
9 | end
10 |
11 | describe "given an active HTTP/1 connection to an echo server" do
12 | setup do
13 | host = System.get_env("ECHO_HOST") || "localhost"
14 | {:ok, conn} = HTTP1.connect(:http, host, 9000)
15 |
16 | [conn: conn]
17 | end
18 |
19 | test "we can send and hello-world frame and receive an echo reply", %{conn: conn} do
20 | {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/", [])
21 | assert_receive http_get_message
22 |
23 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
24 | WebSocket.stream(conn, http_get_message)
25 |
26 | {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers)
27 |
28 | # send the hello world frame
29 | {:ok, websocket, data} = WebSocket.encode(websocket, {:text, "hello world"})
30 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
31 |
32 | # receive the hello world reply frame
33 | assert_receive hello_world_echo_message
34 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, hello_world_echo_message)
35 | assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data)
36 |
37 | # send a ping frame
38 | {:ok, websocket, data} = WebSocket.encode(websocket, :ping)
39 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
40 |
41 | # receive a pong frame
42 | assert_receive pong_message
43 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, pong_message)
44 | assert {:ok, websocket, [{:pong, ""}]} = WebSocket.decode(websocket, data)
45 |
46 | # send a close frame
47 | {:ok, websocket, data} = WebSocket.encode(websocket, :close)
48 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
49 |
50 | # receive a close frame
51 | assert_receive close_message
52 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, close_message)
53 | assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data)
54 |
55 | {:ok, _conn} = HTTP1.close(conn)
56 | end
57 | end
58 |
59 | describe "given a passive HTTP/1 connection to an echo server" do
60 | setup do
61 | host = System.get_env("ECHO_HOST") || "localhost"
62 | {:ok, conn} = HTTP1.connect(:http, host, 9000, mode: :passive)
63 |
64 | [conn: conn]
65 | end
66 |
67 | test "we can send and receive frames (with recv/3)", %{conn: conn} do
68 | {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/", [])
69 |
70 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
71 | WebSocket.recv(conn, 0, 5_000)
72 |
73 | {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers, mode: :passive)
74 |
75 | # send the hello world frame
76 | {:ok, websocket, data} = WebSocket.encode(websocket, {:text, "hello world"})
77 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
78 |
79 | # receive the hello world reply frame
80 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.recv(conn, 0, 5_000)
81 | assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data)
82 |
83 | # send a close frame
84 | {:ok, websocket, data} = WebSocket.encode(websocket, :close)
85 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
86 |
87 | # receive a close frame
88 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.recv(conn, 0, 5_000)
89 | assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data)
90 |
91 | {:ok, _conn} = HTTP1.close(conn)
92 | end
93 | end
94 |
95 | describe "given a passive HTTP/1 connection to the local cowboy server" do
96 | setup do
97 | {:ok, conn} = HTTP1.connect(:http, "localhost", 7070, mode: :passive)
98 | [conn: conn]
99 | end
100 |
101 | test "a response code other than 101 gives a UpgradeFailureError", %{conn: conn} do
102 | {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/forbidden", [])
103 |
104 | {:ok, conn,
105 | [
106 | {:status, ^ref, status},
107 | {:headers, ^ref, resp_headers},
108 | {:data, ^ref, data},
109 | {:done, ^ref}
110 | ]} = WebSocket.recv(conn, 0, 5_000)
111 |
112 | assert status == 403
113 | assert data == "Forbidden."
114 |
115 | assert {:error, _conn, %UpgradeFailureError{} = reason} =
116 | WebSocket.new(conn, ref, status, resp_headers, mode: :passive)
117 |
118 | assert UpgradeFailureError.message(reason) =~ "status code 403"
119 | end
120 | end
121 |
122 | @doc !"""
123 | In Mint 1.5.0+, Mint handles the SETTINGS frame from the server asynchronously
124 | and returns default values for server settings until it is received. So we must
125 | wait for the server to send the SETTINGS frame enabling the connect protocol.
126 | """
127 | defp wait_for_connect_protocol(conn) do
128 | if HTTP2.get_server_setting(conn, :enable_connect_protocol) do
129 | conn
130 | else
131 | receive do
132 | message ->
133 | {:ok, conn, []} = HTTP2.stream(conn, message)
134 | wait_for_connect_protocol(conn)
135 | end
136 | end
137 | end
138 |
139 | describe "given an HTTP/2 WebSocket connection to an echo server" do
140 | setup do
141 | {:ok, conn} = HTTP2.connect(:http, "localhost", 7070)
142 |
143 | conn = wait_for_connect_protocol(conn)
144 |
145 | {:ok, conn, ref} =
146 | WebSocket.upgrade(:ws, conn, "/", [], extensions: [WebSocket.PerMessageDeflate])
147 |
148 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}]} =
149 | stream_until_responses(conn)
150 |
151 | {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers)
152 |
153 | [conn: conn, ref: ref, websocket: websocket]
154 | end
155 |
156 | @tag :http2
157 | test "we can send and hello-world frame and receive an echo reply", c do
158 | ref = c.ref
159 |
160 | # send the hello world frame
161 | {:ok, websocket, data} = WebSocket.encode(c.websocket, {:text, "hello world"})
162 | {:ok, conn} = WebSocket.stream_request_body(c.conn, ref, data)
163 |
164 | # receive the hello world reply frame
165 | assert_receive hello_world_echo_message
166 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, hello_world_echo_message)
167 | assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data)
168 |
169 | # send a ping frame
170 | {:ok, websocket, data} = WebSocket.encode(websocket, :ping)
171 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
172 |
173 | # receive a pong frame
174 | assert_receive pong_message
175 | {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, pong_message)
176 | assert {:ok, websocket, [{:pong, ""}]} = WebSocket.decode(websocket, data)
177 |
178 | # send a close frame
179 | {:ok, websocket, data} = WebSocket.encode(websocket, :close)
180 | {:ok, conn} = WebSocket.stream_request_body(conn, ref, data)
181 |
182 | # receive a close frame
183 | assert_receive close_message
184 |
185 | {:ok, conn, [{:data, ^ref, data}, {:done, ^ref}]} = WebSocket.stream(conn, close_message)
186 |
187 | assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data)
188 |
189 | {:ok, _conn} = HTTP2.close(conn)
190 | end
191 |
192 | @tag :http2
193 | test "we can multiplex WebSocket and HTTP traffic", c do
194 | websocket_ref = c.ref
195 |
196 | {:ok, conn, http_ref} = HTTP2.request(c.conn, "GET", "/http_get", [], nil)
197 |
198 | assert_receive http_get_response
199 |
200 | assert {:ok, conn,
201 | [
202 | {:status, ^http_ref, 200},
203 | {:headers, ^http_ref, _headers},
204 | {:data, ^http_ref, "hi!"},
205 | {:done, ^http_ref}
206 | ]} = WebSocket.stream(conn, http_get_response)
207 |
208 | # send the hello world frame
209 | {:ok, websocket, data} = WebSocket.encode(c.websocket, {:text, "hello world"})
210 | {:ok, conn} = WebSocket.stream_request_body(conn, websocket_ref, data)
211 |
212 | # receive the hello world reply frame
213 | assert_receive hello_world_echo_message
214 |
215 | {:ok, conn, [{:data, ^websocket_ref, data}]} =
216 | WebSocket.stream(conn, hello_world_echo_message)
217 |
218 | assert {:ok, _websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data)
219 |
220 | {:ok, _conn} = HTTP2.close(conn)
221 | end
222 |
223 | @tag :http2
224 | test "a response code outside the 200..299 range gives a UpgradeFailureError", %{conn: conn} do
225 | {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/forbidden", [])
226 |
227 | assert_receive message
228 |
229 | {:ok, conn,
230 | [
231 | {:status, ^ref, status},
232 | {:headers, ^ref, resp_headers},
233 | {:data, ^ref, data},
234 | {:done, ^ref}
235 | ]} = WebSocket.stream(conn, message)
236 |
237 | assert status == 403
238 | assert data == "Forbidden."
239 |
240 | assert {:error, _conn, %UpgradeFailureError{} = reason} =
241 | WebSocket.new(conn, ref, status, resp_headers, mode: :passive)
242 |
243 | assert UpgradeFailureError.message(reason) =~ "status code 403"
244 | end
245 | end
246 |
247 | # cowboy's WebSocket is a little weird here, is it sending SETTINGS frames and then
248 | # frames with content? will need to crack open WireShark to tell
249 | defp stream_until_responses(conn) do
250 | with {:ok, conn, []} <- WebSocket.stream(conn, receive(do: (message -> message))) do
251 | stream_until_responses(conn)
252 | end
253 | end
254 | end
255 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
--------------------------------------------------------------------------------
/lib/mint/web_socket/extension.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.Extension do
2 | @moduledoc """
3 | Tools for defining extensions to the WebSocket protocol
4 |
5 | The WebSocket protocol allows for extensions which act as middle-ware
6 | in the encoding and decoding of frames. In `Mint.WebSocket`, extensions are
7 | written as module which implement the `Mint.WebSocket.Extension` behaviour.
8 |
9 | The common "permessage-deflate" extension is built-in to `Mint.WebSocket` as
10 | `Mint.WebSocket.PerMessageDeflate`. This extension should be used as a
11 | reference when writing future extensions, but future extensions should be
12 | written as separate libraries which extend `Mint.WebSocket` instead of
13 | built-in. Also note that extensions must operate on the internal
14 | representations of frames using the records defined in an internal module.
15 | """
16 |
17 | alias Mint.WebSocketError
18 |
19 | @typedoc """
20 | Parameters to configure an extension
21 |
22 | Some extensions can be configured by negotiation between the client and
23 | server. For example "permessage-deflate" usually shows up as in the
24 | "sec-websocket-extensions" header literally like so:
25 |
26 | ```text
27 | Sec-WebSocket-Extensions: permessage-deflate
28 | ```
29 |
30 | But the zlib window sizes and reset behavior can be negotiated with parameters
31 | with headers like so
32 |
33 | ```text
34 | Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover; client_max_window_bits=12
35 | ```
36 |
37 | These can be configured by passing parameters to any element passed in the
38 | `:extensions` option to `Mint.WebSocket.upgrade/4`.
39 |
40 | For example, one might write the above parameter configuration as
41 |
42 | ```elixir
43 | [
44 | {Mint.WebSocket.PerMessageDeflate,
45 | [client_no_context_takeover: true, client_max_window_bits: 12]}
46 | ]
47 | ```
48 |
49 | when passing the `:extensions` option to `Mint.WebSocket.upgrade/4`.
50 |
51 | Note that `Mint.WebSocket.upgrade/4` will normalize the parameters of an
52 | extension to a list of two-tuples with string keys and values. For example,
53 | the above would be normalized to this extensions list:
54 |
55 | ```elixir
56 | [
57 | %Mint.WebSocket.Extension{
58 | name: "permessage-deflate",
59 | module: Mint.WebSocket.PerMessageDeflate,
60 | params: [
61 | {"client_no_context_takeover", "true"},
62 | {"client_max_window_bits", "12"}
63 | ],
64 | state: nil,
65 | opts: []
66 | }
67 | ]
68 | ```
69 | """
70 | @type params :: [atom() | String.t() | {atom() | String.t(), boolean() | String.t()}]
71 |
72 | @typedoc """
73 | A structure representing an instance of an extension
74 |
75 | Extensions are implemented as modules but passed to `Mint.WebSocket` as
76 | `Mint.WebSocket.Extension` structs with the following keys:
77 |
78 | * `:name` - the name of the extension. When using the short-hand tuple
79 | syntax to pass extensions to `Mint.WebSocket.upgrade/4`, the name is
80 | determined by calling the `c:name/0` callback.
81 | * `:module` - the module which implements the callbacks defined in the
82 | `Mint.WebSocket.Extension` behavior.
83 | * `:state` - an arbitrary piece of data curated by the extension. For
84 | example, the "permessage-deflate" extension uses this field to
85 | hold `t:zlib.zstream()`s for compression and decompression.
86 | * `:params` - a list with key-value tuples or atom/string keys which configure
87 | the parameters communicated to the server. All params are encoded into the
88 | "sec-websocket-extensions" header. Also see the documentation for
89 | `t:params/0`.
90 | * `:opts` - a keyword list to pass configuration to the extension. These
91 | are not encoded into the "sec-websocket-extensions" header. For example,
92 | `:opts` is used by the "permessage-deflate" extension to configure `:zlib`
93 | configuration.
94 | """
95 | @type t :: %__MODULE__{
96 | name: String.t(),
97 | module: module(),
98 | state: term(),
99 | params: params(),
100 | opts: Keyword.t()
101 | }
102 |
103 | @doc """
104 | Returns the name of the WebSocket extension
105 |
106 | This should not include the parameters for the extension, such as
107 | "client_max_window_bits" for the "permessage-deflate" extension.
108 |
109 | ## Examples
110 |
111 | iex> Mint.WebSocket.PerMessageDeflate.name()
112 | "permessage-deflate"
113 | """
114 | @callback name() :: String.t()
115 |
116 | @doc """
117 | Invoked when the WebSocket server accepts an extension
118 |
119 | This callback should be used to initialize any `:state` that the extension
120 | needs to operate. For example, this callback is used by the
121 | "permessage-deflate" extension to setup `t::zlib.zstream()`s and store
122 | them in state.
123 |
124 | The `all_extensions` argument is passed so that the extension can know
125 | about the existence and ordering of other extensions. This can be useful
126 | if a client declares multiple extensions which accomplish the same job
127 | (such as multiple compression extensions) but want to only enable one based
128 | on what the server accepts.
129 |
130 | Note that extensions are initialized in the order in which the server accepts
131 | them: any extensions preceeding `this_extension` in `all_extensions` are
132 | initialized while any extensions after `this_extension` are not yet
133 | initialized.
134 |
135 | Error tuples bubble up to `Mint.WebSocket.upgrade/4`.
136 | """
137 | @callback init(this_extension :: t(), all_extensions :: t()) :: {:ok, t()} | {:error, term()}
138 |
139 | @doc """
140 | Invoked when encoding frames before sending them across the wire
141 |
142 | Error tuples bubble up to `Mint.WebSocket.encode/2`.
143 | """
144 | @callback encode(frame :: tuple(), state :: term()) ::
145 | {:ok, frame :: tuple(), state :: term()} | {:error, term()}
146 |
147 | @doc """
148 | Invoked when decoding frames after receiving them from the wire
149 |
150 | Error tuples bubble up to `Mint.WebSocket.decode/2`.
151 | """
152 | @callback decode(frame :: tuple(), state :: term()) ::
153 | {:ok, frame :: tuple(), state :: term()} | {:error, term()}
154 |
155 | defstruct [:name, :module, :state, opts: [], params: []]
156 |
157 | @doc false
158 | @spec encode(tuple(), [t()]) :: {tuple(), [t()]} | no_return()
159 | def encode(frame, extensions) do
160 | encode_all_extensions(frame, extensions, [])
161 | end
162 |
163 | defp encode_all_extensions(frame, extensions, acc)
164 |
165 | defp encode_all_extensions(frame, [], acc), do: {frame, :lists.reverse(acc)}
166 |
167 | defp encode_all_extensions(frame, [extension | extensions], acc) do
168 | case extension.module.encode(frame, extension.state) do
169 | {:ok, frame, new_state} ->
170 | encode_all_extensions(
171 | frame,
172 | extensions,
173 | [put_in(extension.state, new_state) | acc]
174 | )
175 |
176 | {:error, reason} ->
177 | throw({:mint, reason})
178 | end
179 | end
180 |
181 | @doc false
182 | @spec decode(tuple(), [t()]) :: {tuple(), [t()]} | {{:error, term()}, [t()]}
183 | def decode(frame, extensions) do
184 | decode_all_extensions(frame, extensions, [])
185 | end
186 |
187 | defp decode_all_extensions(frame, extensions, acc)
188 |
189 | defp decode_all_extensions(frame, [], acc), do: {frame, :lists.reverse(acc)}
190 |
191 | defp decode_all_extensions(frame, [extension | extensions], acc) do
192 | case extension.module.decode(frame, extension.state) do
193 | {:ok, frame, new_state} ->
194 | decode_all_extensions(
195 | frame,
196 | extensions,
197 | [put_in(extension.state, new_state) | acc]
198 | )
199 |
200 | {:error, reason} ->
201 | {{:error, reason}, :lists.reverse(acc)}
202 | end
203 | end
204 |
205 | @doc false
206 | @spec accept_extensions([t()], Mint.Types.headers()) ::
207 | {:ok, [t()]} | {:error, Mint.WebSocket.error()}
208 | def accept_extensions(client_extensions, response_headers) do
209 | server_extensions = parse_accepted_extensions(response_headers)
210 | client_extension_mapping = Enum.into(client_extensions, %{}, &{&1.name, &1})
211 |
212 | accept_extensions(server_extensions, [], client_extension_mapping)
213 | end
214 |
215 | @spec accept_extensions([t()], [t()], %{String.t() => t()}) ::
216 | {:ok, [t()]} | {:error, Mint.WebSocket.error()}
217 | defp accept_extensions(server_extension, acc, client_extension_mapping)
218 |
219 | defp accept_extensions([], acc, _), do: {:ok, :lists.reverse(acc)}
220 |
221 | defp accept_extensions(
222 | [%__MODULE__{name: name, params: params} = server_extension | server_extensions],
223 | acc,
224 | client_extension_mapping
225 | ) do
226 | case client_extension_mapping do
227 | %{^name => %__MODULE__{} = client_extension} ->
228 | extension = %{client_extension | params: params}
229 | all_extensions = acc ++ [extension | server_extensions]
230 |
231 | case client_extension.module.init(extension, all_extensions) do
232 | {:ok, extension} ->
233 | accept_extensions(
234 | server_extensions,
235 | acc ++ [extension],
236 | client_extension_mapping
237 | )
238 |
239 | error ->
240 | error
241 | end
242 |
243 | _not_found ->
244 | {:error, %WebSocketError{reason: {:extension_not_negotiated, server_extension}}}
245 | end
246 | end
247 |
248 | # There may be multiple sec-websocket-extension headers and these
249 | # should be treated as separate items, or many extensions may be declared
250 | # in one header.
251 | #
252 | # As [the RFC](https://datatracker.ietf.org/doc/html/rfc6455#section-9.1)
253 | # says:
254 | #
255 | # Note that like other HTTP header fields, this header field MAY be
256 | # split or combined across multiple lines. Ergo, the following are
257 | # equivalent:
258 | #
259 | # Sec-WebSocket-Extensions: foo
260 | # Sec-WebSocket-Extensions: bar; baz=2
261 | #
262 | # is exactly equivalent to
263 | #
264 | # Sec-WebSocket-Extensions: foo, bar; baz=2
265 | @spec parse_accepted_extensions(Mint.Types.headers()) :: [t()]
266 | defp parse_accepted_extensions(response_headers) do
267 | response_headers
268 | |> Enum.flat_map(fn
269 | {"sec-websocket-extensions", extension_string} ->
270 | extension_string
271 | |> String.split(", ")
272 | |> Enum.map(&parse_extension/1)
273 |
274 | _ ->
275 | []
276 | end)
277 | end
278 |
279 | defp parse_extension(extension_string) do
280 | [name | params] = String.split(extension_string, ";")
281 |
282 | params =
283 | Enum.map(params, fn param ->
284 | param_tokens =
285 | param
286 | |> String.trim()
287 | |> String.split("=", parts: 2)
288 |
289 | case param_tokens do
290 | [param] -> {param, "true"}
291 | [param, value] -> {param, value}
292 | end
293 | end)
294 |
295 | %__MODULE__{name: name, params: params}
296 | end
297 | end
298 |
--------------------------------------------------------------------------------
/lib/mint/web_socket/frame.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket.Frame do
2 | @moduledoc false
3 |
4 | # Functions and data structures for describing websocket frames.
5 | # https://tools.ietf.org/html/rfc6455#section-5.2
6 |
7 | import Record
8 | alias Mint.WebSocket.{Utils, Extension}
9 | alias Mint.WebSocketError
10 |
11 | @compile {:inline, apply_mask: 2, apply_mask: 3}
12 |
13 | shared = [{:reserved, <<0::size(3)>>}, :mask, :data, :fin?]
14 |
15 | defrecord :continuation, shared
16 | defrecord :text, shared
17 | defrecord :binary, shared
18 | # > All control frames MUST have a payload length of 125 bytes or less
19 | # > and MUST NOT be fragmented.
20 | defrecord :close, shared ++ [:code, :reason]
21 | defrecord :ping, shared
22 | defrecord :pong, shared
23 |
24 | @typep continuation_frame() ::
25 | record(:continuation,
26 | reserved: <<_::3>>,
27 | mask: binary(),
28 | data: binary(),
29 | fin?: boolean()
30 | )
31 | @type text_frame() ::
32 | record(:text, reserved: <<_::3>>, mask: binary(), data: binary(), fin?: boolean())
33 | @type binary_frame() ::
34 | record(:binary, reserved: <<_::3>>, mask: binary(), data: binary(), fin?: boolean())
35 | @type close_frame() ::
36 | record(:close,
37 | reserved: <<_::3>>,
38 | mask: binary(),
39 | data: binary(),
40 | fin?: boolean(),
41 | code: binary(),
42 | reason: binary()
43 | )
44 | @type ping_frame() ::
45 | record(:ping, reserved: <<_::3>>, mask: binary(), data: binary(), fin?: boolean())
46 | @type pong_frame() ::
47 | record(:pong, reserved: <<_::3>>, mask: binary(), data: binary(), fin?: boolean())
48 |
49 | @type frame_record() ::
50 | continuation_frame()
51 | | text_frame()
52 | | binary_frame()
53 | | close_frame()
54 | | ping_frame()
55 | | pong_frame()
56 |
57 | defguard is_control(frame)
58 | when is_tuple(frame) and
59 | (elem(frame, 0) == :close or elem(frame, 0) == :ping or elem(frame, 0) == :pong)
60 |
61 | defguard is_fin(frame) when elem(frame, 4) == true
62 |
63 | # guards frames dealt with in the user-space (not records)
64 | defguardp is_friendly_frame(frame)
65 | when frame in [:ping, :pong, :close] or
66 | (is_tuple(frame) and elem(frame, 0) in [:text, :binary, :ping, :pong] and
67 | is_binary(elem(frame, 1))) or
68 | (is_tuple(frame) and elem(frame, 0) == :close and is_integer(elem(frame, 1)) and
69 | is_binary(elem(frame, 2)))
70 |
71 | # https://tools.ietf.org/html/rfc6455#section-7.4.1
72 | @invalid_status_codes [1_004, 1_005, 1_006, 1_016, 1_100, 2_000, 2_999]
73 | # https://tools.ietf.org/html/rfc6455#section-7.4.2
74 | defguardp is_valid_close_code(code)
75 | when code in 1_000..4_999 and code not in @invalid_status_codes
76 |
77 | @opcodes %{
78 | # non-control opcodes:
79 | continuation: <<0x0::size(4)>>,
80 | text: <<0x1::size(4)>>,
81 | binary: <<0x2::size(4)>>,
82 | # 0x3-7 reserved for future non-control frames
83 | # control opcodes:
84 | close: <<0x8::size(4)>>,
85 | ping: <<0x9::size(4)>>,
86 | pong: <<0xA::size(4)>>
87 | # 0xB-F reserved for future control frames
88 | }
89 | @reverse_opcodes Map.new(@opcodes, fn {k, v} -> {v, k} end)
90 | @non_control_opcodes [:continuation, :text, :binary]
91 |
92 | def opcodes, do: Map.keys(@opcodes)
93 |
94 | def new_mask, do: :crypto.strong_rand_bytes(4)
95 |
96 | @spec encode(Mint.WebSocket.t(), Mint.WebSocket.shorthand_frame() | Mint.WebSocket.frame()) ::
97 | {:ok, Mint.WebSocket.t(), bitstring()}
98 | | {:error, Mint.WebSocket.t(), WebSocketError.t()}
99 | def encode(websocket, frame) when is_friendly_frame(frame) do
100 | {frame, extensions} =
101 | frame
102 | |> translate()
103 | |> Extension.encode(websocket.extensions)
104 |
105 | websocket = put_in(websocket.extensions, extensions)
106 | frame = encode_to_binary(frame)
107 |
108 | {:ok, websocket, frame}
109 | catch
110 | :throw, {:mint, reason} -> {:error, websocket, reason}
111 | end
112 |
113 | @spec encode_to_binary(frame_record()) :: bitstring()
114 | defp encode_to_binary(frame) do
115 | payload = payload(frame)
116 | mask = mask(frame)
117 | masked? = if mask == nil, do: 0, else: 1
118 | encoded_payload_length = encode_payload_length(elem(frame, 0), byte_size(payload))
119 |
120 | <<
121 | encode_fin(frame)::bitstring,
122 | reserved(frame)::bitstring,
123 | encode_opcode(frame)::bitstring,
124 | masked?::size(1),
125 | encoded_payload_length::bitstring,
126 | mask || <<>>::binary,
127 | apply_mask(payload, mask)::bitstring
128 | >>
129 | end
130 |
131 | defp payload(close(code: nil, reason: nil)) do
132 | <<>>
133 | end
134 |
135 | defp payload(close(code: code, reason: reason)) do
136 | code = code || 1_000
137 | reason = reason || ""
138 | <>
139 | end
140 |
141 | for type <- Map.keys(@opcodes) -- [:close] do
142 | defp payload(unquote(type)(data: data)), do: data
143 | end
144 |
145 | for type <- Map.keys(@opcodes) do
146 | defp mask(unquote(type)(mask: mask)), do: mask
147 | defp reserved(unquote(type)(reserved: reserved)), do: reserved
148 | end
149 |
150 | defp encode_fin(text(fin?: false)), do: <<0b0::size(1)>>
151 | defp encode_fin(binary(fin?: false)), do: <<0b0::size(1)>>
152 | defp encode_fin(continuation(fin?: false)), do: <<0b0::size(1)>>
153 | defp encode_fin(_), do: <<0b1::size(1)>>
154 |
155 | defp encode_opcode(frame), do: @opcodes[elem(frame, 0)]
156 |
157 | def encode_payload_length(_opcode, length) when length in 0..125 do
158 | <>
159 | end
160 |
161 | def encode_payload_length(opcode, length)
162 | when length in 126..65_535 and opcode in @non_control_opcodes do
163 | <<126::integer-size(7), length::unsigned-integer-size(8)-unit(2)>>
164 | end
165 |
166 | def encode_payload_length(opcode, length)
167 | when length in 65_535..9_223_372_036_854_775_807 and opcode in @non_control_opcodes do
168 | <<127::integer-size(7), length::unsigned-integer-size(8)-unit(8)>>
169 | end
170 |
171 | def encode_payload_length(_opcode, _length) do
172 | throw({:mint, %WebSocketError{reason: :payload_too_large}})
173 | end
174 |
175 | # Mask the payload by bytewise XOR-ing the payload bytes against the mask
176 | # bytes (where the mask bytes repeat).
177 | # This is an "involution" function: applying the mask will mask
178 | # the data and applying the mask again will unmask it.
179 | def apply_mask(payload, mask, acc \\ <<>>)
180 |
181 | def apply_mask(payload, nil, _acc), do: payload
182 |
183 | # n=4 is the happy path
184 | # n=3..1 catches cases where the remaining byte_size/1 of the payload is shorter
185 | # than the mask
186 | # MINOR: We use a literal list instead of `4..1` to avoid compilation warnings on
187 | # Elixir 1.17+ and instead of `4..1//-1` to maintain compatibility with older
188 | # Elixir versions that do not support the range-step syntax.
189 | for n <- [4, 3, 2, 1] do
190 | def apply_mask(
191 | <>,
192 | <> = mask,
193 | acc
194 | ) do
195 | apply_mask(
196 | payload_rest,
197 | mask,
198 | <>
199 | )
200 | end
201 | end
202 |
203 | def apply_mask(<<>>, _mask, acc), do: acc
204 |
205 | @spec decode(Mint.WebSocket.t(), binary()) ::
206 | {:ok, Mint.WebSocket.t(), [Mint.WebSocket.frame() | {:error, term()}]}
207 | | {:error, Mint.WebSocket.t(), any()}
208 | def decode(websocket, data) do
209 | {websocket, frames} = binary_to_frames(websocket, data)
210 |
211 | {websocket, frames} =
212 | Enum.reduce(frames, {websocket, []}, fn
213 | {:error, reason}, {websocket, acc} ->
214 | {websocket, [{:error, reason} | acc]}
215 |
216 | frame, {websocket, acc} ->
217 | {frame, extensions} = Extension.decode(frame, websocket.extensions)
218 |
219 | {put_in(websocket.extensions, extensions), [translate(frame) | acc]}
220 | end)
221 |
222 | {:ok, websocket, :lists.reverse(frames)}
223 | catch
224 | {:mint, reason} -> {:error, websocket, reason}
225 | end
226 |
227 | defp binary_to_frames(websocket, data) do
228 | case websocket.buffer |> Utils.maybe_concat(data) |> decode_raw(websocket, []) do
229 | {:ok, frames} ->
230 | {websocket, frames} = resolve_fragments(websocket, frames)
231 | {put_in(websocket.buffer, <<>>), frames}
232 |
233 | {:buffer, partial, frames} ->
234 | {websocket, frames} = resolve_fragments(websocket, frames)
235 | {put_in(websocket.buffer, partial), frames}
236 | end
237 | end
238 |
239 | defp decode_raw(
240 | <> = data,
242 | websocket,
243 | acc
244 | ) do
245 | case decode_payload_and_mask(payload_and_mask, masked == 0b1) do
246 | {:ok, payload, mask, rest} ->
247 | frame = decode_full_frame_binary(opcode, fin, reserved, mask, payload)
248 |
249 | decode_raw(rest, websocket, [frame | acc])
250 |
251 | {:error, reason} ->
252 | {:ok, :lists.reverse([{:error, reason} | acc])}
253 |
254 | :buffer ->
255 | {:buffer, data, :lists.reverse(acc)}
256 | end
257 | end
258 |
259 | defp decode_raw(<<>>, _websocket, acc), do: {:ok, :lists.reverse(acc)}
260 |
261 | defp decode_raw(partial, _websocket, acc) when is_binary(partial) do
262 | {:buffer, partial, :lists.reverse(acc)}
263 | end
264 |
265 | defp decode_payload_and_mask(payload, masked?) do
266 | with {:ok, payload_length, rest} <- decode_payload_length(payload),
267 | {:ok, mask, rest} <- decode_mask(rest, masked?),
268 | <> <- rest do
269 | {:ok, payload, mask, more}
270 | else
271 | partial when is_binary(partial) -> :buffer
272 | :buffer -> :buffer
273 | {:error, reason} -> {:error, reason}
274 | end
275 | end
276 |
277 | defp decode_full_frame_binary(opcode, fin, reserved, mask, payload) do
278 | with {:ok, opcode} <- decode_opcode(opcode) do
279 | into_frame(
280 | opcode,
281 | _fin? = fin == 0b1,
282 | reserved,
283 | mask,
284 | apply_mask(payload, mask)
285 | )
286 | end
287 | end
288 |
289 | defp decode_opcode(opcode) do
290 | with :error <- Map.fetch(@reverse_opcodes, opcode) do
291 | {:error, {:unsupported_opcode, opcode}}
292 | end
293 | end
294 |
295 | defp decode_payload_length(
296 | <<127::integer-size(7), payload_length::unsigned-integer-size(8)-unit(8),
297 | rest::bitstring>>
298 | ),
299 | do: {:ok, payload_length, rest}
300 |
301 | defp decode_payload_length(<<127::integer-size(7), _rest::bitstring>>), do: :buffer
302 |
303 | defp decode_payload_length(
304 | <<126::integer-size(7), payload_length::unsigned-integer-size(8)-unit(2),
305 | rest::bitstring>>
306 | ),
307 | do: {:ok, payload_length, rest}
308 |
309 | defp decode_payload_length(<<126::integer-size(7), _rest::bitstring>>), do: :buffer
310 |
311 | defp decode_payload_length(<>)
312 | when payload_length in 0..125,
313 | do: {:ok, payload_length, rest}
314 |
315 | defp decode_payload_length(malformed) do
316 | {:error, {:malformed_payload_length, malformed}}
317 | end
318 |
319 | defp decode_mask(payload, masked?)
320 |
321 | defp decode_mask(<>, true) do
322 | {:ok, mask, rest}
323 | end
324 |
325 | defp decode_mask(payload, false) do
326 | {:ok, nil, payload}
327 | end
328 |
329 | defp decode_mask(payload, _masked?) do
330 | {:error, {:missing_mask, payload}}
331 | end
332 |
333 | for data_type <- [:continuation, :text, :binary, :ping, :pong] do
334 | def into_frame(unquote(data_type), fin?, reserved, mask, payload) do
335 | unquote(data_type)(
336 | fin?: fin?,
337 | reserved: reserved,
338 | mask: mask,
339 | data: payload
340 | )
341 | end
342 | end
343 |
344 | def into_frame(
345 | :close,
346 | fin?,
347 | reserved,
348 | mask,
349 | <> = payload
350 | )
351 | when byte_size(reason) in 0..123 and is_valid_close_code(code) do
352 | if String.valid?(reason) do
353 | close(reserved: reserved, mask: mask, code: code, reason: reason, fin?: fin?)
354 | else
355 | {:error, {:invalid_close_payload, payload}}
356 | end
357 | end
358 |
359 | def into_frame(
360 | :close,
361 | fin?,
362 | reserved,
363 | mask,
364 | <<>>
365 | ) do
366 | close(reserved: reserved, mask: mask, code: 1_000, reason: "", fin?: fin?)
367 | end
368 |
369 | def into_frame(
370 | :close,
371 | _fin?,
372 | _reserved,
373 | _mask,
374 | payload
375 | ) do
376 | {:error, {:invalid_close_payload, payload}}
377 | end
378 |
379 | # translate from user-friendly tuple into record defined in this module
380 | # (and the reverse)
381 | @spec translate(Mint.WebSocket.frame() | Mint.WebSocket.shorthand_frame()) :: tuple()
382 | for opcode <- Map.keys(@opcodes) do
383 | def translate(unquote(opcode)(reserved: <>))
384 | when reserved != <<0::size(3)>> do
385 | {:error, {:malformed_reserved, reserved}}
386 | end
387 | end
388 |
389 | def translate({:error, reason}), do: {:error, reason}
390 |
391 | def translate({:text, text}) do
392 | text(fin?: true, mask: new_mask(), data: text)
393 | end
394 |
395 | def translate(text(fin?: true, data: data)) do
396 | if String.valid?(data) do
397 | {:text, data}
398 | else
399 | {:error, {:invalid_utf8, data}}
400 | end
401 | end
402 |
403 | def translate({:binary, binary}) do
404 | binary(fin?: true, mask: new_mask(), data: binary)
405 | end
406 |
407 | def translate(binary(fin?: true, data: data)), do: {:binary, data}
408 |
409 | def translate(:ping), do: translate({:ping, <<>>})
410 |
411 | def translate({:ping, body}) do
412 | ping(mask: new_mask(), data: body)
413 | end
414 |
415 | def translate(ping(data: data)), do: {:ping, data}
416 |
417 | def translate(:pong), do: translate({:pong, <<>>})
418 |
419 | def translate({:pong, body}) do
420 | pong(mask: new_mask(), data: body)
421 | end
422 |
423 | def translate(pong(data: data)), do: {:pong, data}
424 |
425 | def translate(:close) do
426 | translate({:close, nil, nil})
427 | end
428 |
429 | def translate({:close, code, reason}) do
430 | close(mask: new_mask(), code: code, reason: reason, data: <<>>)
431 | end
432 |
433 | def translate(close(code: code, reason: reason)) do
434 | {:close, code, reason}
435 | end
436 |
437 | def translate(continuation()) do
438 | {:error, :unexpected_continuation}
439 | end
440 |
441 | @doc """
442 | Emits frames for any finalized fragments and stores any unfinalized fragments
443 | in the `:fragment` key in the websocket data structure
444 | """
445 | def resolve_fragments(websocket, frames, acc \\ [])
446 |
447 | def resolve_fragments(websocket, [], acc) do
448 | {websocket, :lists.reverse(acc)}
449 | end
450 |
451 | def resolve_fragments(websocket, [{:error, reason} | rest], acc) do
452 | resolve_fragments(websocket, rest, [{:error, reason} | acc])
453 | end
454 |
455 | def resolve_fragments(websocket, [frame | rest], acc)
456 | when is_control(frame) and is_fin(frame) do
457 | resolve_fragments(websocket, rest, [frame | acc])
458 | end
459 |
460 | def resolve_fragments(websocket, [frame | rest], acc) when is_fin(frame) do
461 | frame = combine(websocket.fragment, frame)
462 |
463 | put_in(websocket.fragment, nil)
464 | |> resolve_fragments(rest, [frame | acc])
465 | end
466 |
467 | def resolve_fragments(websocket, [frame | rest], acc) do
468 | case combine(websocket.fragment, frame) do
469 | {:error, reason} ->
470 | put_in(websocket.fragment, nil)
471 | |> resolve_fragments(rest, [{:error, reason} | acc])
472 |
473 | frame ->
474 | put_in(websocket.fragment, frame)
475 | |> resolve_fragments(rest, acc)
476 | end
477 | end
478 |
479 | defp combine(nil, continuation(fin?: true)), do: {:error, :insular_continuation}
480 |
481 | defp combine(nil, frame), do: frame
482 |
483 | for type <- [:continuation, :text, :binary] do
484 | defp combine(
485 | unquote(type)(data: frame_data) = frame,
486 | continuation(data: continuation_data, fin?: fin?)
487 | ) do
488 | unquote(type)(frame, data: Utils.maybe_concat(frame_data, continuation_data), fin?: fin?)
489 | end
490 | end
491 |
492 | defp combine(a, b), do: {:error, {:cannot_combine_frames, a, b}}
493 | end
494 |
--------------------------------------------------------------------------------
/lib/mint/web_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule Mint.WebSocket do
2 | @moduledoc """
3 | HTTP/1 and HTTP/2 WebSocket support for the Mint functional HTTP client
4 |
5 | Like Mint, `Mint.WebSocket` provides a functional, process-less interface
6 | for operating a WebSocket connection. Prospective Mint.WebSocket users
7 | may wish to first familiarize themselves with `Mint.HTTP`.
8 |
9 | Mint.WebSocket is not fully spec-conformant on its own. Runtime behaviors
10 | such as responding to pings with pongs must be implemented by the user of
11 | Mint.WebSocket.
12 |
13 | ## Usage
14 |
15 | A connection formed with `Mint.HTTP.connect/4` can be upgraded to a WebSocket
16 | connection with `upgrade/5`.
17 |
18 | ```elixir
19 | {:ok, conn} = Mint.HTTP.connect(:http, "localhost", 9_000)
20 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", [])
21 | ```
22 |
23 | `upgrade/5` sends an upgrade request to the remote server. The WebSocket
24 | connection is then built by awaiting the HTTP response from the server.
25 |
26 | ```elixir
27 | http_reply_message = receive(do: (message -> message))
28 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} =
29 | Mint.WebSocket.stream(conn, http_reply_message)
30 |
31 | {:ok, conn, websocket} =
32 | Mint.WebSocket.new(conn, ref, status, resp_headers)
33 | ```
34 |
35 | Once the WebSocket connection has been established, use the `websocket`
36 | data structure to encode and decode frames with `encode/2` and `decode/2`,
37 | and send and stream messages with `stream_request_body/3` and `stream/2`.
38 |
39 | For example, one may send a "hello world" text frame across a connection
40 | like so:
41 |
42 | ```elixir
43 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"})
44 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
45 | ```
46 |
47 | Say that the remote is echoing messages. Use `stream/2` and `decode/2` to
48 | decode a received WebSocket frame:
49 |
50 | ```elixir
51 | echo_message = receive(do: (message -> message))
52 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, echo_message)
53 | {:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data)
54 | ```
55 |
56 | ## HTTP/2 Support
57 |
58 | Mint.WebSocket supports WebSockets over HTTP/2 as defined in rfc8441.
59 | rfc8441 is an extension to the HTTP/2 specification. At the time of
60 | writing, very few HTTP/2 server libraries support or enable HTTP/2
61 | WebSockets by default.
62 |
63 | `upgrade/5` works on both HTTP/1 and HTTP/2 connections. In order to select
64 | HTTP/2, the `:http2` protocol should be explicitly selected in
65 | `Mint.HTTP.connect/4`.
66 |
67 | ```elixir
68 | {:ok, conn} =
69 | Mint.HTTP.connect(:http, "websocket.example", 80, protocols: [:http2])
70 | :http2 = Mint.HTTP.protocol(conn)
71 | {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", [])
72 | ```
73 |
74 | If the server does not support the extended CONNECT method needed to bootstrap
75 | WebSocket connections over HTTP/2, `upgrade/4` will return an error tuple
76 | with the `:extended_connect_disabled` error reason.
77 |
78 | ```elixir
79 | {:error, conn, %Mint.WebSocketError{reason: :extended_connect_disabled}}
80 | ```
81 |
82 | Why use HTTP/2 for WebSocket connections in the first place? HTTP/2
83 | can multiplex many requests over the same connection, which can
84 | reduce the latency incurred by forming new connections for each request.
85 | A WebSocket connection only occupies one stream of a HTTP/2 connection, so
86 | even if an HTTP/2 connection has an open WebSocket communication, it can be
87 | used to transport more requests.
88 |
89 | ## WebSocket Secure
90 |
91 | Encryption of connections is handled by Mint functions. To start a WSS
92 | connection, select `:https` as the scheme in `Mint.HTTP.connect/4`:
93 |
94 | ```elixir
95 | {:ok, conn} = Mint.HTTP.connect(:https, "websocket.example", 443)
96 | ```
97 |
98 | And pass the `:wss` scheme to `upgrade/5`. See the Mint documentation
99 | on SSL for more information.
100 |
101 | ## Extensions
102 |
103 | The WebSocket protocol allows for _extensions_. Extensions act as a
104 | middleware for encoding and decoding frames. For example "permessage-deflate"
105 | compresses and decompresses the body of data frames, which minifies the amount
106 | of bytes which must be sent over the network.
107 |
108 | See `Mint.WebSocket.Extension` for more information about extensions and
109 | `Mint.WebSocket.PerMessageDeflate` for information about the
110 | "permessage-deflate" extension.
111 | """
112 |
113 | alias __MODULE__.{Utils, Extension, Frame}
114 | alias Mint.{WebSocketError, WebSocket.UpgradeFailureError}
115 | import Mint.HTTP, only: [get_private: 2, put_private: 3, protocol: 1]
116 |
117 | @typedoc """
118 | An immutable data structure representing WebSocket state.
119 |
120 | You will usually want to keep these around:
121 |
122 | * The Mint connection
123 | * The request reference for the WebSocket upgrade request
124 | * This WebSocket data structure
125 |
126 | """
127 | @opaque t :: %__MODULE__{
128 | extensions: [Extension.t()],
129 | fragment: tuple(),
130 | private: map(),
131 | buffer: binary()
132 | }
133 | defstruct extensions: [],
134 | fragment: nil,
135 | private: %{},
136 | buffer: <<>>
137 |
138 | @type error :: Mint.Types.error() | WebSocketError.t() | UpgradeFailureError.t()
139 |
140 | @typedoc """
141 | Shorthand notations for control frames.
142 |
143 | * `:ping` - shorthand for `{:ping, ""}`
144 | * `:pong` - shorthand for `{:pong, ""}`
145 | * `:close` - shorthand for `{:close, nil, nil}`
146 |
147 | These may be passed to `encode/2`. Frames decoded with `decode/2` are always
148 | in `t:frame/0` format.
149 | """
150 | @type shorthand_frame :: :ping | :pong | :close
151 |
152 | @typedoc """
153 | A WebSocket frame.
154 |
155 | * `{:binary, binary}` - a frame containing binary data. Binary frames
156 | can be used to send arbitrary binary data such as a PDF.
157 | * `{:text, text}` - a frame containing string data. Text frames must be
158 | valid utf8. Elixir has wonderful support for utf8: `String.valid?/1`
159 | can detect valid and invalid utf8.
160 | * `{:ping, binary}` - a control frame which the server should respond to
161 | with a pong. The binary data must be echoed in the pong response.
162 | * `{:pong, binary}` - a control frame which forms a reply to a ping frame.
163 | Pings and pongs may be used to check the a connection is alive or to
164 | estimate latency.
165 | * `{:close, code, reason}` - a control frame used to request that a connection
166 | be closed or to acknowledgee a close frame send by the server.
167 |
168 | These may be passed to `encode/2` or returned from `decode/2`.
169 |
170 | ## Close frames
171 |
172 | In order to close a WebSocket connection gracefully, either the client or
173 | server sends a close frame. Then the other endpoint responds with a
174 | close with code `1_000` and then closes the TCP/TLS connection. This can be
175 | accomplished in `Mint.WebSocket` like so:
176 |
177 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :close)
178 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data)
179 |
180 | close_response = receive(do: (message -> message))
181 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, close_response)
182 | {:ok, websocket, [{:close, 1_000, ""}]} = Mint.WebSocket.decode(websocket, data)
183 |
184 | Mint.HTTP.close(conn)
185 |
186 | [RFC6455 § 7.4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1)
187 | documents codes which may be used in the `code` element.
188 | """
189 | @type frame ::
190 | {:text, String.t()}
191 | | {:binary, binary()}
192 | | {:ping, binary()}
193 | | {:pong, binary()}
194 | | {:close, code :: non_neg_integer() | nil, reason :: binary() | nil}
195 |
196 | @doc """
197 | Requests that a connection be upgraded to the WebSocket protocol
198 |
199 | This function wraps `Mint.HTTP.request/5` to provide a single interface
200 | for bootstrapping an upgrade for HTTP/1 and HTTP/2 connections.
201 |
202 | For HTTP/1 connections, this function performs a GET request with
203 | WebSocket-specific headers. For HTTP/2 connections, this function performs
204 | an extended CONNECT request which opens a stream to be used for the WebSocket
205 | connection.
206 |
207 | The `scheme` argument should be either `:ws` or `:wss`, using `:ws` for
208 | connections established by passing `:http` to `Mint.HTTP.connect/4` and
209 | `:wss` corresponding to `:https`.
210 |
211 | ## Options
212 |
213 | * `:extensions` - a list of extensions to negotiate. See the extensions
214 | section below.
215 |
216 | ## Extensions
217 |
218 | Extensions should be declared by passing the `:extensions` option in the
219 | `opts` keyword list. Note that in the WebSocket protocol, extensions are
220 | negotiated: the client proposes a list of extensions and the server may
221 | accept any (or none) of them. See `Mint.WebSocket.Extension` for more
222 | information about extension negotiation.
223 |
224 | Extensions may be passed as a list of `Mint.WebSocket.Extension` structs
225 | or with the following shorthand notations:
226 |
227 | * `module` - shorthand for `{module, []}`
228 | * `{module, params}` - shorthand for `{module, params, []}`
229 | * `{module, params, opts}` - a shorthand which is expanded to a
230 | `Mint.WebSocket.Extension` struct
231 |
232 | ## Examples
233 |
234 | First, establish the Mint connection:
235 |
236 | {:ok, conn} = Mint.HTTP.connect(:http, "localhost", 9_000)
237 |
238 | Then, send the upgrade request (with an extension in this example):
239 |
240 | {:ok, conn, ref} =
241 | Mint.WebSocket.upgrade(:ws, conn, "/", [], extensions: [Mint.WebSocket.PerMessageDeflate])
242 |
243 | Here's an example of providing extension parameters:
244 |
245 | {:ok, conn, ref} =
246 | Mint.WebSocket.upgrade(
247 | :ws,
248 | conn,
249 | "/",
250 | [],
251 | extensions: [{Mint.WebSocket.PerMessageDeflate, [:client_max_window_bits]]}]
252 | )
253 |
254 | """
255 | @spec upgrade(
256 | scheme :: :ws | :wss,
257 | conn :: Mint.HTTP.t(),
258 | path :: String.t(),
259 | headers :: Mint.Types.headers(),
260 | opts :: Keyword.t()
261 | ) :: {:ok, Mint.HTTP.t(), Mint.Types.request_ref()} | {:error, Mint.HTTP.t(), error()}
262 | def upgrade(scheme, conn, path, headers, opts \\ []) when scheme in ~w[ws wss]a do
263 | conn = put_private(conn, :scheme, scheme)
264 |
265 | do_upgrade(scheme, Mint.HTTP.protocol(conn), conn, path, headers, opts)
266 | end
267 |
268 | defp do_upgrade(_scheme, :http1, conn, path, headers, opts) do
269 | nonce = Utils.random_nonce()
270 | extensions = get_extensions(opts)
271 |
272 | conn =
273 | conn
274 | |> put_private(:sec_websocket_key, nonce)
275 | |> put_private(:extensions, extensions)
276 |
277 | headers = Utils.headers({:http1, nonce}, extensions) ++ headers
278 |
279 | Mint.HTTP.request(conn, "GET", path, headers, nil)
280 | end
281 |
282 | @dialyzer {:no_opaque, do_upgrade: 6}
283 | defp do_upgrade(scheme, :http2, conn, path, headers, opts) do
284 | if Mint.HTTP2.get_server_setting(conn, :enable_connect_protocol) == true do
285 | extensions = get_extensions(opts)
286 | conn = put_private(conn, :extensions, extensions)
287 |
288 | headers =
289 | [
290 | {":scheme", if(scheme == :ws, do: "http", else: "https")},
291 | {":path", path},
292 | {":protocol", "websocket"}
293 | | headers
294 | ] ++ Utils.headers(:http2, extensions)
295 |
296 | Mint.HTTP2.request(conn, "CONNECT", path, headers, :stream)
297 | else
298 | {:error, conn, %WebSocketError{reason: :extended_connect_disabled}}
299 | end
300 | end
301 |
302 | @doc """
303 | Creates a new WebSocket data structure given the server's reply to the
304 | upgrade request.
305 |
306 | `request_ref` should be the reference of the request made with `upgrade/5`.
307 | `status` and `response_headers` should be the status code and headers
308 | of the server's response to the upgrade request—see the example below.
309 |
310 | The returned [WebSocket data structure](`t:t/0`) is used to encode and decode frames.
311 |
312 | This function will setup any extensions accepted by the server using
313 | the `c:Mint.WebSocket.Extension.init/2` callback.
314 |
315 | ## Options
316 |
317 | * `:mode` - (default: `:active`) either `:active` or `:passive`. This
318 | corresponds to the same option in `Mint.HTTP.connect/4`.
319 |
320 | ## Examples
321 |
322 | http_reply = receive(do: (message -> message))
323 |
324 | {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, headers}, {:done, ^ref}]} =
325 | Mint.WebSocket.stream(conn, http_reply)
326 |
327 | {:ok, conn, websocket} =
328 | Mint.WebSocket.new(conn, ref, status, headers)
329 |
330 | """
331 | @spec new(
332 | Mint.HTTP.t(),
333 | Mint.Types.request_ref(),
334 | Mint.Types.status(),
335 | Mint.Types.headers()
336 | ) ::
337 | {:ok, Mint.HTTP.t(), t()} | {:error, Mint.HTTP.t(), error()}
338 | def new(conn, request_ref, status, response_headers, opts \\ []) do
339 | websockets = [request_ref | get_private(conn, :websockets) || []]
340 |
341 | conn =
342 | conn
343 | |> put_private(:websockets, websockets)
344 | |> put_private(:mode, Keyword.get(opts, :mode, :active))
345 |
346 | do_new(protocol(conn), conn, status, response_headers)
347 | end
348 |
349 | defp do_new(:http1, conn, status, headers) when status != 101 do
350 | error = %UpgradeFailureError{status_code: status, headers: headers}
351 | {:error, conn, error}
352 | end
353 |
354 | defp do_new(:http1, conn, _status, response_headers) do
355 | with :ok <- Utils.check_accept_nonce(get_private(conn, :sec_websocket_key), response_headers),
356 | {:ok, extensions} <-
357 | Extension.accept_extensions(get_private(conn, :extensions), response_headers) do
358 | {:ok, conn, %__MODULE__{extensions: extensions}}
359 | else
360 | {:error, reason} -> {:error, conn, reason}
361 | end
362 | end
363 |
364 | defp do_new(:http2, conn, status, response_headers)
365 | when status in 200..299 do
366 | with {:ok, extensions} <-
367 | Extension.accept_extensions(get_private(conn, :extensions), response_headers) do
368 | {:ok, conn, %__MODULE__{extensions: extensions}}
369 | end
370 | end
371 |
372 | defp do_new(:http2, conn, status, headers) do
373 | error = %UpgradeFailureError{status_code: status, headers: headers}
374 | {:error, conn, error}
375 | end
376 |
377 | @doc """
378 | A wrapper around `Mint.HTTP.stream/2` for streaming HTTP and WebSocket
379 | messages.
380 |
381 | **This function does not decode WebSocket frames**. Instead, once a WebSocket
382 | connection has been established, decode any `{:data, request_ref, data}`
383 | frames with `decode/2`.
384 |
385 | This function is a drop-in replacement for `Mint.HTTP.stream/2`, which
386 | enables streaming WebSocket data after the bootstrapping HTTP/1 connection
387 | has concluded. It decodes both WebSocket and regular HTTP messages.
388 |
389 | ## Examples
390 |
391 | message = receive(do: (message -> message))
392 |
393 | {:ok, conn, [{:data, ^websocket_ref, data}]} =
394 | Mint.WebSocket.stream(conn, message)
395 |
396 | {:ok, websocket, [{:text, "hello world!"}]} =
397 | Mint.WebSocket.decode(websocket, data)
398 |
399 | """
400 | @spec stream(Mint.HTTP.t(), term()) ::
401 | {:ok, Mint.HTTP.t(), [Mint.Types.response()]}
402 | | {:error, Mint.HTTP.t(), Mint.Types.error(), [Mint.Types.response()]}
403 | | :unknown
404 | def stream(conn, message) do
405 | with :http1 <- protocol(conn),
406 | # HTTP/1 only allows one WebSocket per connection
407 | [request_ref] <- get_private(conn, :websockets) do
408 | stream_http1(conn, request_ref, message)
409 | else
410 | _ -> Mint.HTTP.stream(conn, message)
411 | end
412 | end
413 |
414 | # we take manual control of the :gen_tcp and :ssl messages in HTTP/1 because
415 | # we have taken over the transport
416 | defp stream_http1(conn, request_ref, message) do
417 | socket = Mint.HTTP.get_socket(conn)
418 | tag = if get_private(conn, :scheme) == :ws, do: :tcp, else: :ssl
419 |
420 | case message do
421 | {^tag, ^socket, data} ->
422 | reset_mode(conn, [{:data, request_ref, data}])
423 |
424 | _ ->
425 | Mint.HTTP.stream(conn, message)
426 | end
427 | end
428 |
429 | defp reset_mode(conn, responses) do
430 | module = if get_private(conn, :scheme) == :ws, do: :inet, else: :ssl
431 |
432 | with :active <- get_private(conn, :mode),
433 | {:error, reason} <- module.setopts(Mint.HTTP.get_socket(conn), active: :once) do
434 | {:error, conn, %Mint.TransportError{reason: reason}, responses}
435 | else
436 | _ -> {:ok, conn, responses}
437 | end
438 | end
439 |
440 | @doc """
441 | Receives data from the socket.
442 |
443 | This function is used instead of `stream/2` when the connection is
444 | in `:passive` mode. You must pass the `mode: :passive` option to
445 | `new/5` in order to use `recv/3`.
446 |
447 | This function wraps `Mint.HTTP.recv/3`. See the `Mint.HTTP.recv/3`
448 | documentation for more information.
449 |
450 | ## Examples
451 |
452 | {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.recv(conn, 0, 5_000)
453 |
454 | {:ok, websocket, [{:text, "hello world!"}]} =
455 | Mint.WebSocket.decode(websocket, data)
456 |
457 | """
458 | @spec recv(Mint.HTTP.t(), non_neg_integer(), timeout()) ::
459 | {:ok, Mint.HTTP.t(), [Mint.Types.response()]}
460 | | {:error, t(), Mint.Types.error(), [Mint.Types.response()]}
461 | def recv(conn, byte_count, timeout) do
462 | with :http1 <- protocol(conn),
463 | [request_ref] <- get_private(conn, :websockets) do
464 | recv_http1(conn, request_ref, byte_count, timeout)
465 | else
466 | _ -> Mint.HTTP.recv(conn, byte_count, timeout)
467 | end
468 | end
469 |
470 | defp recv_http1(conn, request_ref, byte_count, timeout) do
471 | module = if get_private(conn, :scheme) == :ws, do: :gen_tcp, else: :ssl
472 | socket = Mint.HTTP.get_socket(conn)
473 |
474 | case module.recv(socket, byte_count, timeout) do
475 | {:ok, data} ->
476 | {:ok, conn, [{:data, request_ref, data}]}
477 |
478 | {:error, error} ->
479 | {:error, conn, error, []}
480 | end
481 | end
482 |
483 | @doc """
484 | Streams chunks of data on the connection.
485 |
486 | `stream_request_body/3` should be used to send encoded data on an
487 | established WebSocket connection that has already been upgraded with
488 | `upgrade/5`.
489 |
490 | > #### Encoding {: .warning}
491 | >
492 | > This function doesn't perform any encoding. You should use `encode/2`
493 | > to encode frames before sending them with `stream_request_body/3`.
494 |
495 | This function is a wrapper around `Mint.HTTP.stream_request_body/3`. It
496 | delegates to that function unless the `request_ref` belongs to an HTTP/1
497 | WebSocket connection. When the request is an HTTP/1 WebSocket, this
498 | function allows sending data on a request which Mint considers to be
499 | closed, but is actually a valid WebSocket connection.
500 |
501 | See the `Mint.HTTP.stream_request_body/3` documentation for more
502 | information.
503 |
504 | ## Examples
505 |
506 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world!"})
507 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, websocket_ref, data)
508 |
509 | """
510 | @spec stream_request_body(
511 | Mint.HTTP.t(),
512 | Mint.Types.request_ref(),
513 | iodata() | :eof | {:eof, trailing_headers :: Mint.Types.headers()}
514 | ) :: {:ok, Mint.HTTP.t()} | {:error, Mint.HTTP.t(), error()}
515 | def stream_request_body(conn, request_ref, data) do
516 | with :http1 <- protocol(conn),
517 | [^request_ref] <- get_private(conn, :websockets),
518 | data when is_binary(data) or is_list(data) <- data do
519 | stream_request_body_http1(conn, data)
520 | else
521 | _ -> Mint.HTTP.stream_request_body(conn, request_ref, data)
522 | end
523 | end
524 |
525 | defp stream_request_body_http1(conn, data) do
526 | transport = if get_private(conn, :scheme) == :ws, do: :gen_tcp, else: :ssl
527 |
528 | case transport.send(Mint.HTTP.get_socket(conn), data) do
529 | :ok -> {:ok, conn}
530 | {:error, reason} -> {:error, conn, %Mint.TransportError{reason: reason}}
531 | end
532 | end
533 |
534 | @doc """
535 | Encodes a frame into a binary.
536 |
537 | The resulting binary may be sent with `stream_request_body/3`.
538 |
539 | This function will invoke the `c:Mint.WebSocket.Extension.encode/2` callback
540 | for any accepted extensions.
541 |
542 | ## Examples
543 |
544 | {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"})
545 | {:ok, conn} = Mint.WebSocket.stream_request_body(conn, websocket_ref, data)
546 |
547 | """
548 | @spec encode(t(), shorthand_frame() | frame()) :: {:ok, t(), binary()} | {:error, t(), any()}
549 | defdelegate encode(websocket, frame), to: Frame
550 |
551 | @doc """
552 | Decodes a binary into a list of frames.
553 |
554 | The binary may received from the connection with `stream/2`.
555 |
556 | This function will invoke the `c:Mint.WebSocket.Extension.decode/2` callback
557 | for any accepted extensions.
558 |
559 | ## Examples
560 |
561 | message = receive(do: (message -> message))
562 | {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message)
563 | {:ok, websocket, frames} = Mint.WebSocket.decode(websocket, data)
564 |
565 | """
566 | @spec decode(t(), data :: binary()) ::
567 | {:ok, t(), [frame() | {:error, term()}]} | {:error, t(), any()}
568 | defdelegate decode(websocket, data), to: Frame
569 |
570 | defp get_extensions(opts) do
571 | opts
572 | |> Keyword.get(:extensions, [])
573 | |> Enum.map(fn
574 | module when is_atom(module) ->
575 | %Extension{module: module, name: module.name()}
576 |
577 | {module, params} ->
578 | %Extension{module: module, name: module.name(), params: normalize_params(params)}
579 |
580 | {module, params, opts} ->
581 | %Extension{
582 | module: module,
583 | name: module.name(),
584 | params: normalize_params(params),
585 | opts: opts
586 | }
587 |
588 | %Extension{} = extension ->
589 | update_in(extension.params, &normalize_params/1)
590 | end)
591 | end
592 |
593 | defp normalize_params(params) do
594 | params
595 | |> Enum.map(fn
596 | {_key, false} -> nil
597 | {key, value} -> {to_string(key), to_string(value)}
598 | key -> {to_string(key), "true"}
599 | end)
600 | |> Enum.reject(&is_nil/1)
601 | end
602 | end
603 |
--------------------------------------------------------------------------------