├── 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 <essen@ninenines.eu> 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::size(3)>> = 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(<<data::binary, 0x00, 0x00, 0xFF, 0xFF>>) 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::size(3)>> = 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::binary-size(data_size), 0x00, 0x00, 0xFF, 0xFF>> -> 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 | <<code::unsigned-integer-size(8)-unit(2), reason::binary>> 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 | <<length::integer-size(7)>> 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 | <<part_key::integer-size(8)-unit(unquote(n)), payload_rest::binary>>, 192 | <<mask_key::integer-size(8)-unit(unquote(n)), _::binary>> = mask, 193 | acc 194 | ) do 195 | apply_mask( 196 | payload_rest, 197 | mask, 198 | <<acc::binary, :erlang.bxor(mask_key, part_key)::integer-size(8)-unit(unquote(n))>> 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 | <<fin::size(1), reserved::bitstring-size(3), opcode::bitstring-size(4), masked::size(1), 241 | payload_and_mask::bitstring>> = 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 | <<payload::binary-size(payload_length), more::bitstring>> <- 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(<<payload_length::integer-size(7), rest::bitstring>>) 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(<<mask::binary-size(8)-unit(4), rest::bitstring>>, 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 | <<code::unsigned-integer-size(8)-unit(2), reason::binary>> = 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: <<reserved::bitstring>>)) 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 | --------------------------------------------------------------------------------