├── .gitignore ├── test ├── mist_test.gleam ├── hackney_ffi.erl ├── websocket_test.gleam ├── scaffold.gleam └── http1_test.gleam ├── src ├── mist │ └── internal │ │ ├── logger.gleam │ │ ├── buffer.gleam │ │ ├── file.gleam │ │ ├── encoder.gleam │ │ ├── handler.gleam │ │ ├── http.gleam │ │ └── websocket.gleam ├── mist_ffi.erl └── mist.gleam ├── gleam.toml ├── .github └── workflows │ └── test.yml ├── manifest.toml ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /test/mist_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | 3 | pub fn main() { 4 | gleeunit.main() 5 | } 6 | -------------------------------------------------------------------------------- /src/mist/internal/logger.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/charlist.{type Charlist} 2 | 3 | @external(erlang, "logger", "error") 4 | fn log_error(format format: Charlist, data data: any) -> Nil 5 | 6 | pub fn error(data: any) -> Nil { 7 | log_error(charlist.from_string("~tp"), [data]) 8 | } 9 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "mist" 2 | version = "0.16.0" 3 | 4 | licences = ["Apache-2.0"] 5 | description = "a misty Gleam web server" 6 | repository = { type = "github", user = "rawhat", repo = "mist" } 7 | target = "erlang" 8 | gleam = ">= 0.32.0" 9 | 10 | [dependencies] 11 | gleam_stdlib = "~> 0.34" 12 | gleam_erlang = "~> 0.23" 13 | gleam_http = "~> 3.5" 14 | gleam_otp = "~> 0.8" 15 | glisten = "~> 0.9" 16 | 17 | [dev-dependencies] 18 | gleeunit = "~> 1.0" 19 | gleam_hackney = "~> 1.2" 20 | -------------------------------------------------------------------------------- /test/hackney_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(hackney_ffi). 2 | 3 | -export([stream_request/4]). 4 | 5 | stream_request(Method, Path, Headers, Body) -> 6 | try 7 | {ok, ClientRef} = hackney:request(Method, Path, Headers, stream, []), 8 | ok = hackney:send_body(ClientRef, Body), 9 | {ok, Status, RespHeaders, ClientRef} = hackney:start_response(ClientRef), 10 | {ok, RespBody} = hackney:body(ClientRef), 11 | {ok, {Status, RespHeaders, RespBody}} 12 | catch 13 | _ -> {error, nil} 14 | end. 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3.5.3 15 | - uses: erlef/setup-beam@v1.16.0 16 | with: 17 | otp-version: "26.2.1" 18 | gleam-version: "0.34.0-rc2" 19 | rebar3-version: "3" 20 | - run: gleam format --check src test 21 | - run: gleam deps download 22 | - run: gleam test 23 | -------------------------------------------------------------------------------- /test/websocket_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/option.{None} 3 | import gleeunit/should 4 | import mist/internal/websocket.{ 5 | Complete, Continuation, Data, Incomplete, TextFrame, 6 | } 7 | 8 | pub fn it_should_combine_continuation_frames_test() { 9 | let one = <<"Hello":utf8>> 10 | let two = <<", ":utf8>> 11 | let three = <<"world!":utf8>> 12 | let messages = [ 13 | Incomplete(Data(TextFrame(bit_array.byte_size(one), one))), 14 | Incomplete(Continuation(bit_array.byte_size(two), two)), 15 | Complete(Continuation(bit_array.byte_size(three), three)), 16 | ] 17 | 18 | let combined = <<"Hello, world!":utf8>> 19 | 20 | messages 21 | |> websocket.aggregate_frames(None, []) 22 | |> should.equal( 23 | Ok([Data(TextFrame(bit_array.byte_size(combined), combined))]), 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/mist/internal/buffer.gleam: -------------------------------------------------------------------------------- 1 | pub type Buffer { 2 | Buffer(remaining: Int, data: BitArray) 3 | } 4 | 5 | pub fn empty() -> Buffer { 6 | Buffer(remaining: 0, data: <<>>) 7 | } 8 | 9 | pub fn new(data: BitArray) -> Buffer { 10 | Buffer(remaining: 0, data: data) 11 | } 12 | 13 | pub fn append(buffer: Buffer, data: BitArray) -> Buffer { 14 | Buffer(..buffer, data: <>) 15 | } 16 | 17 | pub fn slice(buffer: Buffer, bits: Int) -> #(BitArray, BitArray) { 18 | let bytes = bits * 8 19 | case buffer.data { 20 | <> -> #(value, rest) 21 | _ -> #(buffer.data, <<>>) 22 | } 23 | } 24 | 25 | pub fn with_capacity(buffer: Buffer, size: Int) -> Buffer { 26 | Buffer(..buffer, remaining: size) 27 | } 28 | 29 | pub fn size(remaining: Int) -> Buffer { 30 | Buffer(data: <<>>, remaining: remaining) 31 | } 32 | -------------------------------------------------------------------------------- /src/mist/internal/file.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/atom.{type Atom} 2 | import gleam/result 3 | import glisten/socket.{type Socket} 4 | 5 | pub type FileDescriptor 6 | 7 | pub type FileError { 8 | IsDir 9 | NoAccess 10 | NoEntry 11 | UnknownFileError 12 | } 13 | 14 | pub type File { 15 | File(descriptor: FileDescriptor, file_size: Int) 16 | } 17 | 18 | pub fn stat(filename: BitArray) -> Result(File, FileError) { 19 | filename 20 | |> open 21 | |> result.map(fn(fd) { 22 | let file_size = size(filename) 23 | File(fd, file_size) 24 | }) 25 | } 26 | 27 | @external(erlang, "file", "sendfile") 28 | pub fn sendfile( 29 | file_descriptor file_descriptor: FileDescriptor, 30 | socket socket: Socket, 31 | offset offset: Int, 32 | bytes bytes: Int, 33 | options options: List(a), 34 | ) -> Result(Int, Atom) 35 | 36 | @external(erlang, "mist_ffi", "file_open") 37 | fn open(file: BitArray) -> Result(FileDescriptor, FileError) 38 | 39 | @external(erlang, "filelib", "file_size") 40 | fn size(path: BitArray) -> Int 41 | -------------------------------------------------------------------------------- /src/mist_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(mist_ffi). 2 | 3 | -export([binary_match/2, decode_packet/3, file_open/1, string_to_int/2]). 4 | 5 | decode_packet(Type, Packet, Opts) -> 6 | case erlang:decode_packet(Type, Packet, Opts) of 7 | {ok, http_eoh, Rest} -> 8 | {ok, {end_of_headers, Rest}}; 9 | {ok, Binary, Rest} -> 10 | {ok, {binary_data, Binary, Rest}}; 11 | {more, Length} when Length =:= undefined -> 12 | {ok, {more_data, none}}; 13 | {more, Length} -> 14 | {ok, {more_data, {some, Length}}}; 15 | {error, Reason} -> 16 | {error, Reason} 17 | end. 18 | 19 | binary_match(Source, Pattern) -> 20 | case binary:match(Source, Pattern) of 21 | {Before, After} -> 22 | {ok, {Before, After}}; 23 | nomatch -> 24 | {error, nil} 25 | end. 26 | 27 | string_to_int(String, Base) -> 28 | try 29 | {ok, erlang:list_to_integer(String, Base)} 30 | catch 31 | error:badarg -> 32 | {error, nil} 33 | end. 34 | 35 | file_open(Path) -> 36 | case file:open(Path, [raw]) of 37 | {ok, Fd} -> 38 | {ok, Fd}; 39 | {error, enoent} -> 40 | {error, no_entry}; 41 | {error, eacces} -> 42 | {error, no_access}; 43 | {error, eisdir} -> 44 | {error, is_dir}; 45 | _ -> 46 | {error, unknown_file_error} 47 | end. 48 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "certifi", version = "2.12.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "EE68D85DF22E554040CDB4BE100F33873AC6051387BAF6A8F6CE82272340FF1C" }, 6 | { name = "gleam_erlang", version = "0.23.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "C21CFB816C114784E669FFF4BBF433535EEA9960FA2F216209B8691E87156B96" }, 7 | { name = "gleam_hackney", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "hackney", "gleam_stdlib"], otp_app = "gleam_hackney", source = "hex", outer_checksum = "066B1A55D37DBD61CC72A1C4EDE43C6015B1797FAF3818C16FE476534C7B6505" }, 8 | { name = "gleam_http", version = "3.5.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "AECDA43AFD523D07A8F09068598A6E271C505278A0CB6F9C7A2E4365EAE8D11E" }, 9 | { name = "gleam_otp", version = "0.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "18EF8242A5E54BA92F717C7222F03B3228AEE00D1F286D4C56C3E8C18AA2588E" }, 10 | { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, 11 | { name = "gleeunit", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "4E75DCF846D653848094169304743DFFB386E3AECCCF611F99ADB735FF4D4DD9" }, 12 | { name = "glisten", version = "0.9.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_otp", "gleam_erlang"], otp_app = "glisten", source = "hex", outer_checksum = "C960B6CF25D4AABAB01211146E9B57E11827B9C49E4175217E0FB7EF5BCB0FF7" }, 13 | { name = "hackney", version = "1.20.1", build_tools = ["rebar3"], requirements = ["certifi", "metrics", "ssl_verify_fun", "mimerl", "parse_trans", "unicode_util_compat", "idna"], otp_app = "hackney", source = "hex", outer_checksum = "FE9094E5F1A2A2C0A7D10918FEE36BFEC0EC2A979994CFF8CFE8058CD9AF38E3" }, 14 | { name = "idna", version = "6.1.1", build_tools = ["rebar3"], requirements = ["unicode_util_compat"], otp_app = "idna", source = "hex", outer_checksum = "92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA" }, 15 | { name = "metrics", version = "1.0.1", build_tools = ["rebar3"], requirements = [], otp_app = "metrics", source = "hex", outer_checksum = "69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16" }, 16 | { name = "mimerl", version = "1.2.0", build_tools = ["rebar3"], requirements = [], otp_app = "mimerl", source = "hex", outer_checksum = "F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323" }, 17 | { name = "parse_trans", version = "3.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "parse_trans", source = "hex", outer_checksum = "620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A" }, 18 | { name = "ssl_verify_fun", version = "1.1.7", build_tools = ["mix", "rebar3", "make"], requirements = [], otp_app = "ssl_verify_fun", source = "hex", outer_checksum = "FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8" }, 19 | { name = "unicode_util_compat", version = "0.7.0", build_tools = ["rebar3"], requirements = [], otp_app = "unicode_util_compat", source = "hex", outer_checksum = "25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521" }, 20 | ] 21 | 22 | [requirements] 23 | gleam_erlang = { version = "~> 0.23" } 24 | gleam_hackney = { version = "~> 1.2" } 25 | gleam_http = { version = "~> 3.5" } 26 | gleam_otp = { version = "~> 0.8" } 27 | gleam_stdlib = { version = "~> 0.34" } 28 | gleeunit = { version = "~> 1.0" } 29 | glisten = { version = "~> 0.9" } 30 | -------------------------------------------------------------------------------- /src/mist/internal/encoder.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/http.{type Header} 3 | import gleam/http/response.{type Response} 4 | import gleam/int 5 | import gleam/list 6 | 7 | /// Turns an HTTP response into a TCP message 8 | pub fn to_bytes_builder(resp: Response(BytesBuilder)) -> BytesBuilder { 9 | resp.status 10 | |> response_builder(resp.headers) 11 | |> bytes_builder.append_builder(resp.body) 12 | } 13 | 14 | pub fn response_builder(status: Int, headers: List(Header)) -> BytesBuilder { 15 | let status_string = 16 | status 17 | |> int.to_string 18 | |> bytes_builder.from_string 19 | |> bytes_builder.append(<<" ":utf8>>) 20 | |> bytes_builder.append(status_to_bit_array(status)) 21 | 22 | bytes_builder.new() 23 | |> bytes_builder.append(<<"HTTP/1.1 ":utf8>>) 24 | |> bytes_builder.append_builder(status_string) 25 | |> bytes_builder.append(<<"\r\n":utf8>>) 26 | |> bytes_builder.append_builder(encode_headers(headers)) 27 | |> bytes_builder.append(<<"\r\n":utf8>>) 28 | } 29 | 30 | pub fn status_to_bit_array(status: Int) -> BitArray { 31 | // Obviously nowhere near exhaustive... 32 | case status { 33 | 100 -> <<"Continue":utf8>> 34 | 101 -> <<"Switching Protocols":utf8>> 35 | 103 -> <<"Early Hints":utf8>> 36 | 200 -> <<"OK":utf8>> 37 | 201 -> <<"Created":utf8>> 38 | 202 -> <<"Accepted":utf8>> 39 | 203 -> <<"Non-Authoritative Information":utf8>> 40 | 204 -> <<"No Content":utf8>> 41 | 205 -> <<"Reset Content":utf8>> 42 | 206 -> <<"Partial Content":utf8>> 43 | 300 -> <<"Multiple Choices":utf8>> 44 | 301 -> <<"Moved Permanently":utf8>> 45 | 302 -> <<"Found":utf8>> 46 | 303 -> <<"See Other":utf8>> 47 | 304 -> <<"Not Modified":utf8>> 48 | 307 -> <<"Temporary Redirect":utf8>> 49 | 308 -> <<"Permanent Redirect":utf8>> 50 | 400 -> <<"Bad Request":utf8>> 51 | 401 -> <<"Unauthorized":utf8>> 52 | 402 -> <<"Payment Required":utf8>> 53 | 403 -> <<"Forbidden":utf8>> 54 | 404 -> <<"Not Found":utf8>> 55 | 405 -> <<"Method Not Allowed":utf8>> 56 | 406 -> <<"Not Acceptable":utf8>> 57 | 407 -> <<"Proxy Authentication Required":utf8>> 58 | 408 -> <<"Request Timeout":utf8>> 59 | 409 -> <<"Conflict":utf8>> 60 | 410 -> <<"Gone":utf8>> 61 | 411 -> <<"Length Required":utf8>> 62 | 412 -> <<"Precondition Failed":utf8>> 63 | 413 -> <<"Payload Too Large":utf8>> 64 | 414 -> <<"URI Too Long":utf8>> 65 | 415 -> <<"Unsupported Media Type":utf8>> 66 | 416 -> <<"Range Not Satisfiable":utf8>> 67 | 417 -> <<"Expectation Failed":utf8>> 68 | 418 -> <<"I'm a teapot":utf8>> 69 | 422 -> <<"Unprocessable Entity":utf8>> 70 | 425 -> <<"Too Early":utf8>> 71 | 426 -> <<"Upgrade Required":utf8>> 72 | 428 -> <<"Precondition Required":utf8>> 73 | 429 -> <<"Too Many Requests":utf8>> 74 | 431 -> <<"Request Header Fields Too Large":utf8>> 75 | 451 -> <<"Unavailable For Legal Reasons":utf8>> 76 | 500 -> <<"Internal Server Error":utf8>> 77 | 501 -> <<"Not Implemented":utf8>> 78 | 502 -> <<"Bad Gateway":utf8>> 79 | 503 -> <<"Service Unavailable":utf8>> 80 | 504 -> <<"Gateway Timeout":utf8>> 81 | 505 -> <<"HTTP Version Not Supported":utf8>> 82 | 506 -> <<"Variant Also Negotiates":utf8>> 83 | 507 -> <<"Insufficient Storage":utf8>> 84 | 508 -> <<"Loop Detected":utf8>> 85 | 510 -> <<"Not Extended":utf8>> 86 | 511 -> <<"Network Authentication Required":utf8>> 87 | _ -> <<"Unknown HTTP Status":utf8>> 88 | } 89 | } 90 | 91 | pub fn encode_headers(headers: List(Header)) -> BytesBuilder { 92 | list.fold(headers, bytes_builder.new(), fn(builder, tup) { 93 | let #(header, value) = tup 94 | 95 | builder 96 | |> bytes_builder.append_string(header) 97 | |> bytes_builder.append(<<": ":utf8>>) 98 | |> bytes_builder.append_string(value) 99 | |> bytes_builder.append(<<"\r\n":utf8>>) 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /test/scaffold.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/http 3 | import gleam/http/request 4 | import gleam/http/response.{type Response, Response} 5 | import gleam/list 6 | import gleam/set 7 | import gleeunit/should 8 | import mist/internal/http as mhttp 9 | import mist 10 | import gleam/bit_array 11 | import gleam/string 12 | import gleam/iterator 13 | import gleam/option 14 | import gleam/int 15 | 16 | pub fn chunked_echo_server(port: Int, chunk_size: Int) { 17 | fn(req: request.Request(mhttp.Connection)) { 18 | let assert Ok(req) = mhttp.read_body(req) 19 | let assert Ok(body) = bit_array.to_string(req.body) 20 | let chunks = 21 | body 22 | |> string.to_graphemes 23 | |> iterator.from_list 24 | |> iterator.sized_chunk(chunk_size) 25 | |> iterator.map(fn(chars) { 26 | chars 27 | |> string.join("") 28 | |> bytes_builder.from_string 29 | }) 30 | response.new(200) 31 | |> response.set_body(mist.Chunked(chunks)) 32 | } 33 | |> mist.new 34 | |> mist.port(port) 35 | |> mist.start_http 36 | } 37 | 38 | pub fn open_server(port: Int) { 39 | fn(req: request.Request(BitArray)) -> response.Response(mist.ResponseData) { 40 | let body = 41 | req.query 42 | |> option.map(bit_array.from_string) 43 | |> option.unwrap(req.body) 44 | |> bytes_builder.from_bit_array 45 | let length = 46 | body 47 | |> bytes_builder.byte_size 48 | |> int.to_string 49 | let headers = 50 | list.filter(req.headers, fn(p) { 51 | case p { 52 | #("transfer-encoding", "chunked") -> False 53 | #("content-length", _) -> False 54 | _ -> True 55 | } 56 | }) 57 | |> list.prepend(#("content-length", length)) 58 | Response(status: 200, headers: headers, body: mist.Bytes(body)) 59 | } 60 | |> mist.new 61 | |> mist.read_request_body( 62 | 4_000_000, 63 | response.new(413) 64 | |> response.set_header("connection", "close") 65 | |> response.set_body(mist.Bytes(bytes_builder.new())), 66 | ) 67 | |> mist.port(port) 68 | |> mist.start_http 69 | } 70 | 71 | fn compare_bitstring_body(actual: BitArray, expected: BytesBuilder) { 72 | actual 73 | |> bytes_builder.from_bit_array 74 | |> should.equal(expected) 75 | } 76 | 77 | fn compare_string_body(actual: String, expected: BytesBuilder) { 78 | actual 79 | |> bytes_builder.from_string 80 | |> should.equal(expected) 81 | } 82 | 83 | fn compare_headers_and_status(actual: Response(a), expected: Response(b)) { 84 | should.equal(actual.status, expected.status) 85 | 86 | let expected_headers = set.from_list(expected.headers) 87 | let actual_headers = set.from_list(actual.headers) 88 | 89 | let missing_headers = 90 | set.filter(expected_headers, fn(header) { 91 | set.contains(actual_headers, header) == False 92 | }) 93 | let extra_headers = 94 | set.filter(actual_headers, fn(header) { 95 | set.contains(expected_headers, header) == False 96 | }) 97 | 98 | should.equal(missing_headers, extra_headers) 99 | } 100 | 101 | pub fn string_response_should_equal( 102 | actual: Response(String), 103 | expected: Response(BytesBuilder), 104 | ) { 105 | compare_headers_and_status(actual, expected) 106 | compare_string_body(actual.body, expected.body) 107 | } 108 | 109 | pub fn bitstring_response_should_equal( 110 | actual: Response(BitArray), 111 | expected: Response(BytesBuilder), 112 | ) { 113 | compare_headers_and_status(actual, expected) 114 | compare_bitstring_body(actual.body, expected.body) 115 | } 116 | 117 | pub fn make_request(path: String, body: body) -> request.Request(body) { 118 | request.new() 119 | |> request.set_host("localhost:8888") 120 | |> request.set_method(http.Post) 121 | |> request.set_path(path) 122 | |> request.set_body(body) 123 | |> request.set_scheme(http.Http) 124 | } 125 | 126 | type IoFormat { 127 | User 128 | } 129 | 130 | @external(erlang, "io", "fwrite") 131 | fn io_fwrite( 132 | format format: IoFormat, 133 | output_format output_format: String, 134 | data data: any, 135 | ) -> Nil 136 | 137 | pub fn io_fwrite_user(data: anything) { 138 | io_fwrite(User, "~tp\n", [data]) 139 | } 140 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | - Updated for Gleam v0.33.0. 6 | - Log error from `rescue` in WebSocket handlers 7 | - WebSocket `Text` frame is now a `String`, since the spec notes that this type 8 | of message must be valid UTF-8 9 | 10 | ## v0.15.0 11 | 12 | - Lots of WebSocket changes around spec correctness (still not 100% there) 13 | - Fixed a few bugs around WebSocket handlers and user selectors 14 | 15 | ## v0.14.3 16 | 17 | - Fix regression in WebSocket handler 18 | 19 | ## v0.14.2 20 | 21 | - Pass scheme to `after_start` to allow building valid URLs 22 | 23 | ## v0.14.1 24 | 25 | - Pass WebSocket state to `on_close` handler 26 | - Fix socket active mode bug in WebSocket actor 27 | - Update packages and change `bit_string` to `bit_array` and `bit_builder` to 28 | `bytes_builder` 29 | 30 | ## v0.14.0 31 | 32 | - Remove WebSocket builder in favor of plain function 33 | - Adds `on_init` and `on_close` to WebSocket upgrade function 34 | - Fix an issue where websocket crashes on internal control close frame 35 | - Upgrade to `glisten` v0.9 36 | 37 | ## v0.13.2 38 | 39 | - Upgrade `glisten` and `gleam_otp` versions 40 | 41 | ## v0.13.1 42 | 43 | - Improve file sending ergonomics 44 | 45 | ## v0.13.0 46 | 47 | - Big API refactor 48 | - Add `client_ip` to `Connection` construct 49 | - Fix reading chunked encoding 50 | - Add method for streaming request body 51 | 52 | ## v0.12.0 53 | 54 | - Correctly handle query strings (@Johann150) 55 | - Add constructor for opaque `Body` type for testing handlers 56 | - Handle WebSocket `ping` frames and reply correctly 57 | - Fix incorrect pattern match in `file_open` (@avdgaag) 58 | 59 | ## v0.11.0 60 | 61 | - Big public API refactoring and docs clean-up 62 | - Fixed erroneous extra CRLF after response body 63 | 64 | ## v0.10.0 65 | 66 | - Support chunked responses, via `Chunked(Iterator(BitBuilder))` 67 | - Convert syntax for gleam v0.27 support 68 | 69 | ## v0.9.4 70 | 71 | - Utilize `request.scheme` to determine which transport to use automatically 72 | - Support the `Expect: 100-continue` header functionality 73 | 74 | ## v0.9.3 75 | 76 | - Remove duplicate imports that errored on newer gleam versions 77 | 78 | ## v0.9.2 79 | 80 | - Support more HTTP status codes 81 | 82 | ## v0.9.1 83 | 84 | - Allow `state.transport` in handlers to abstract over `TCP`/`SSL` 85 | 86 | ## v0.9.0 87 | 88 | - Add SSL support via the `run_service_ssl` and `serve_ssl` methods 89 | - Some refactorings in the internal libraries, if you were depending on them 90 | 91 | ## v0.8.3 92 | 93 | - Update `glisten` version 94 | 95 | ## v0.8.2 96 | 97 | - Fixed up broken README examples 98 | 99 | ## v0.8.1 100 | 101 | - Removed a `main` method I accidentally left in :( 102 | 103 | ## v0.8.0 104 | 105 | - BREAKING: 106 | - refactor `http.handle` and `http.handle_func` into separate module 107 | 108 | The `handler_func` implementation was about 250 lines. That seemed a little 109 | excessive and unclear, so I just pulled that stuff out into separate functions. 110 | I also thought it might be nice to have a separate `handler` module that housed 111 | this code. The actual consumer changes are minimal. 112 | 113 | ## v0.7.1 114 | 115 | - Revert `websocket.send` change 116 | - It should be a similar order to `process.send` 117 | 118 | ## v0.7.0 119 | 120 | - Stop automatically reading body 121 | - `run_service` now accepts maximum body size 122 | - `http` module exports `read_body` to manually parse body 123 | - Support `Transfer-Encoding: chunked` requests 124 | - Properly support query parameters 125 | 126 | ## v0.6.1 127 | 128 | - Fix `websocket.send` argument order 129 | - Bump GitHub workflow versions 130 | 131 | ## v0.6.0 132 | 133 | - Big WebSocket changes 134 | - Handle larger text messages 135 | - Support binary messages 136 | - Properly reply to `ping` messages 137 | - Add helper function for `send`ing 138 | 139 | ## v0.5.2 140 | 141 | - Properly support (most) HTTP methods 142 | 143 | ## v0.5.1 144 | 145 | - Use `Sender` in WS handler instead of raw socket 146 | 147 | ## v0.5.0 148 | 149 | - Bump `glisten` version 150 | - Add support for `on_init` and `on_close` events on WebSockets 151 | 152 | ## v0.4.5 153 | 154 | - Make sure to include `"content-length"` header 155 | 156 | ## v0.4.4 157 | 158 | - Wrap user handler function in `rescue` call 159 | - Add `logger` support for error handling 160 | 161 | ## v0.4.3 162 | 163 | - Remove default `"content-type"` header guessing 164 | - Add `run_service` method for simple servers 165 | 166 | ## v0.4.2 167 | 168 | - Update some handler response type names 169 | 170 | ## v0.4.1 171 | 172 | - Support for sending files with `file:sendfile` 173 | 174 | ## v0.4.0 175 | 176 | - Remove `router` module and move to `http` 177 | 178 | ## Note 179 | 180 | I started this list way later and don't really feel like going back further 181 | than this. 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mist 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/mist)](https://hex.pm/packages/mist) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/mist/) 5 | 6 | A glistening Gleam web server. 7 | 8 | ## Installation 9 | 10 | This package can be added to your Gleam project: 11 | 12 | ```sh 13 | gleam add mist 14 | ``` 15 | 16 | and its documentation can be found at . 17 | 18 | ## Usage 19 | 20 | The main entrypoints for your application are `mist.start_http` and 21 | `mist.start_https`. The argument to these functions is generated from the 22 | opaque `Builder` type. It can be constructed with the `mist.new` function, and 23 | fed updated configuration options with the associated methods (demonstrated 24 | in the examples below). 25 | 26 | ```gleam 27 | import gleam/bytes_builder 28 | import gleam/erlang/process 29 | import gleam/http/request.{Request} 30 | import gleam/http/response.{Response} 31 | import gleam/io 32 | import gleam/iterator 33 | import gleam/option.{None, Some} 34 | import gleam/otp/actor 35 | import gleam/result 36 | import gleam/string 37 | import mist.{Connection, ResponseData} 38 | 39 | pub fn main() { 40 | // These values are for the Websocket process initialized below 41 | let selector = process.new_selector() 42 | let state = Nil 43 | 44 | let not_found = 45 | response.new(404) 46 | |> response.set_body(mist.Bytes(bytes_builder.new())) 47 | 48 | let assert Ok(_) = 49 | fn(req: Request(Connection)) -> Response(ResponseData) { 50 | case request.path_segments(req) { 51 | ["ws"] -> 52 | mist.websocket( 53 | request: req, 54 | on_init: fn(_conn) { #(state, Some(selector)) }, 55 | on_close: fn(_state) { io.println("goodbye!") }, 56 | handler: handle_ws_message, 57 | ) 58 | ["echo"] -> echo_body(req) 59 | ["chunk"] -> serve_chunk(req) 60 | ["file", ..rest] -> serve_file(req, rest) 61 | ["form"] -> handle_form(req) 62 | 63 | _ -> not_found 64 | } 65 | } 66 | |> mist.new 67 | |> mist.port(3000) 68 | |> mist.start_http 69 | 70 | process.sleep_forever() 71 | } 72 | 73 | pub type MyMessage { 74 | Broadcast(String) 75 | } 76 | 77 | fn handle_ws_message(state, conn, message) { 78 | case message { 79 | mist.Text(<<"ping":utf8>>) -> { 80 | let assert Ok(_) = mist.send_text_frame(conn, <<"pong":utf8>>) 81 | actor.continue(state) 82 | } 83 | mist.Text(_) | mist.Binary(_) -> { 84 | actor.continue(state) 85 | } 86 | mist.Custom(Broadcast(text)) -> { 87 | let assert Ok(_) = mist.send_text_frame(conn, <>) 88 | actor.continue(state) 89 | } 90 | mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) 91 | } 92 | } 93 | 94 | fn echo_body(request: Request(Connection)) -> Response(ResponseData) { 95 | let content_type = 96 | request 97 | |> request.get_header("content-type") 98 | |> result.unwrap("text/plain") 99 | 100 | mist.read_body(request, 1024 * 1024 * 10) 101 | |> result.map(fn(req) { 102 | response.new(200) 103 | |> response.set_body(mist.Bytes(bytes_builder.from_bit_array(req.body))) 104 | |> response.set_header("content-type", content_type) 105 | }) 106 | |> result.lazy_unwrap(fn() { 107 | response.new(400) 108 | |> response.set_body(mist.Bytes(bytes_builder.new())) 109 | }) 110 | } 111 | 112 | fn serve_chunk(_request: Request(Connection)) -> Response(ResponseData) { 113 | let iter = 114 | ["one", "two", "three"] 115 | |> iterator.from_list 116 | |> iterator.map(bytes_builder.from_string) 117 | 118 | response.new(200) 119 | |> response.set_body(mist.Chunked(iter)) 120 | |> response.set_header("content-type", "text/plain") 121 | } 122 | 123 | fn serve_file( 124 | _req: Request(Connection), 125 | path: List(String), 126 | ) -> Response(ResponseData) { 127 | let file_path = string.join(path, "/") 128 | 129 | // Omitting validation for brevity 130 | mist.send_file(file_path, offset: 0, limit: None) 131 | |> result.map(fn(file) { 132 | let content_type = guess_content_type(file_path) 133 | response.new(200) 134 | |> response.prepend_header("content-type", content_type) 135 | |> response.set_body(file) 136 | }) 137 | |> result.lazy_unwrap(fn() { 138 | response.new(404) 139 | |> response.set_body(mist.Bytes(bytes_builder.new())) 140 | }) 141 | } 142 | 143 | fn handle_form(req: Request(Connection)) -> Response(ResponseData) { 144 | let _req = mist.read_body(req, 1024 * 1024 * 30) 145 | response.new(200) 146 | |> response.set_body(mist.Bytes(bytes_builder.new())) 147 | } 148 | 149 | fn guess_content_type(_path: String) -> String { 150 | "application/octet-stream" 151 | } 152 | ``` 153 | 154 | ## Streaming request body 155 | 156 | ``` 157 | NOTE: This is a new feature, and I may have made some mistakes. Please let me 158 | know if you run into anything :) 159 | ``` 160 | 161 | When handling file uploads or `multipart/form-data`, you probably don't want to 162 | load the whole file into memory. Previously, the only options in `mist` were to 163 | accept bodies up to `N` bytes, or read the entire body. 164 | 165 | Now, there is a `mist.stream` function which takes a `Request(Connection)` that 166 | gives you back a function to start reading chunks. This function will return: 167 | 168 | ```gleam 169 | pub type Chunk { 170 | Chunk(data: BitString, consume: fn(Int) -> Chunk) 171 | Done 172 | } 173 | ``` 174 | 175 | NOTE: You must only call this once on the `Request(Connection)`. Since it's 176 | reading data from the socket, this is a mutable action. The name `consume` was 177 | chosen to hopefully make that more clear. 178 | 179 | ### Example 180 | 181 | ```gleam 182 | // Replacing the named function in the application example above 183 | fn handle_form(req: Request(Connection)) -> Response(ResponseData) { 184 | let assert Ok(consume) = mist.stream(req) 185 | // NOTE: This is a little misleading, since `Iterator`s can be replayed. 186 | // However, this will only be running this once. 187 | let content = 188 | iterator.unfold( 189 | consume, 190 | fn(consume) { 191 | // Reads up to 1024 bytes from the request 192 | let res = consume(1024) 193 | case res { 194 | // The error will not be bubbled up to the iterator here. If either 195 | // we've read all the body, or we see an error, the iterator finishes 196 | Ok(mist.Done) | Error(_) -> iterator.Done 197 | // We read some data. It may be less than the specific amount above if 198 | // we have consumed all of the body. You'll still need to call it 199 | // again to ensure, since with `chunked` encoding, we need to check 200 | // for the last chunk. 201 | Ok(mist.Chunk(data, consume)) -> { 202 | iterator.Next(bit_builder.from_bit_string(data), consume) 203 | } 204 | } 205 | }, 206 | ) 207 | // For fun, respond with `chunked` encoding of the same iterator 208 | response.new(200) 209 | |> response.set_body(mist.Chunked(content)) 210 | } 211 | ``` 212 | -------------------------------------------------------------------------------- /src/mist/internal/handler.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/dynamic 3 | import gleam/erlang.{Errored, Exited, Thrown, rescue} 4 | import gleam/erlang/process.{type ProcessDown, type Selector, type Subject} 5 | import gleam/http/request.{type Request} 6 | import gleam/http/response 7 | import gleam/int 8 | import gleam/iterator.{type Iterator} 9 | import gleam/option.{type Option, None, Some} 10 | import gleam/otp/actor 11 | import gleam/result 12 | import glisten/handler.{Close, Internal} 13 | import glisten/socket.{type Socket, type SocketReason, Badarg} 14 | import glisten/socket/transport.{type Transport} 15 | import glisten.{type Loop, type Message, Packet} 16 | import mist/internal/encoder 17 | import mist/internal/file 18 | import mist/internal/http.{ 19 | type Connection, type DecodeError, Connection, DiscardPacket, Initial, 20 | } 21 | import mist/internal/logger 22 | 23 | pub type ResponseData { 24 | Websocket(Selector(ProcessDown)) 25 | Bytes(BytesBuilder) 26 | Chunked(Iterator(BytesBuilder)) 27 | File(descriptor: file.FileDescriptor, offset: Int, length: Int) 28 | } 29 | 30 | pub type Handler = 31 | fn(Request(Connection)) -> response.Response(ResponseData) 32 | 33 | pub type HandlerError { 34 | InvalidRequest(DecodeError) 35 | NotFound 36 | } 37 | 38 | const stop_normal = actor.Stop(process.Normal) 39 | 40 | pub type State { 41 | State(idle_timer: Option(process.Timer)) 42 | } 43 | 44 | pub fn new_state() -> State { 45 | State(None) 46 | } 47 | 48 | /// This is a more flexible handler. It will allow you to upgrade a connection 49 | /// to a websocket connection, or deal with a regular HTTP req->resp workflow. 50 | pub fn with_func(handler: Handler) -> Loop(user_message, State) { 51 | fn(msg, state: State, conn: glisten.Connection(user_message)) { 52 | let assert Packet(msg) = msg 53 | let sender = conn.subject 54 | let conn = 55 | Connection( 56 | body: Initial(<<>>), 57 | socket: conn.socket, 58 | transport: conn.transport, 59 | client_ip: conn.client_ip, 60 | ) 61 | { 62 | let _ = case state.idle_timer { 63 | Some(t) -> process.cancel_timer(t) 64 | _ -> process.TimerNotFound 65 | } 66 | msg 67 | |> http.parse_request(conn) 68 | |> result.map_error(fn(err) { 69 | case err { 70 | DiscardPacket -> Nil 71 | _ -> { 72 | logger.error(err) 73 | let _ = conn.transport.close(conn.socket) 74 | Nil 75 | } 76 | } 77 | }) 78 | |> result.replace_error(stop_normal) 79 | |> result.then(fn(req) { 80 | rescue(fn() { handler(req) }) 81 | |> result.map(fn(resp) { #(req, resp) }) 82 | |> result.map_error(log_and_error(_, conn.socket, conn.transport)) 83 | }) 84 | |> result.map(fn(req_resp) { 85 | let #(_req, response) = req_resp 86 | case response { 87 | response.Response(body: Bytes(body), ..) as resp -> 88 | handle_bytes_builder_body(resp, body, conn) 89 | |> result.map(fn(_res) { close_or_set_timer(resp, conn, sender) }) 90 | |> result.replace_error(stop_normal) 91 | |> result.unwrap_both 92 | response.Response(body: Chunked(body), ..) as resp -> { 93 | handle_chunked_body(resp, body, conn) 94 | |> result.map(fn(_res) { close_or_set_timer(resp, conn, sender) }) 95 | |> result.replace_error(stop_normal) 96 | |> result.unwrap_both 97 | } 98 | response.Response(body: File(..), ..) as resp -> 99 | handle_file_body(resp, conn) 100 | |> result.map(fn(_res) { close_or_set_timer(resp, conn, sender) }) 101 | |> result.replace_error(stop_normal) 102 | |> result.unwrap_both 103 | response.Response(body: Websocket(selector), ..) -> { 104 | let _resp = process.select_forever(selector) 105 | actor.Stop(process.Normal) 106 | } 107 | } 108 | }) 109 | } 110 | |> result.unwrap_both 111 | } 112 | } 113 | 114 | fn log_and_error( 115 | error: erlang.Crash, 116 | socket: Socket, 117 | transport: Transport, 118 | ) -> actor.Next(Message(user_message), State) { 119 | case error { 120 | Exited(msg) | Thrown(msg) | Errored(msg) -> { 121 | logger.error(error) 122 | response.new(500) 123 | |> response.set_body( 124 | bytes_builder.from_bit_array(<<"Internal Server Error":utf8>>), 125 | ) 126 | |> response.prepend_header("content-length", "21") 127 | |> http.add_default_headers 128 | |> encoder.to_bytes_builder 129 | |> transport.send(socket, _) 130 | let _ = transport.close(socket) 131 | actor.Stop(process.Abnormal(dynamic.unsafe_coerce(msg))) 132 | } 133 | } 134 | } 135 | 136 | fn close_or_set_timer( 137 | resp: response.Response(ResponseData), 138 | conn: Connection, 139 | sender: Subject(handler.Message(user_message)), 140 | ) -> actor.Next(Message(user_message), State) { 141 | // If the handler explicitly says to close the connection, we should 142 | // probably listen to them 143 | case response.get_header(resp, "connection") { 144 | Ok("close") -> { 145 | let _ = conn.transport.close(conn.socket) 146 | stop_normal 147 | } 148 | _ -> { 149 | // TODO: this should be a configuration 150 | let timer = process.send_after(sender, 10_000, Internal(Close)) 151 | actor.continue(State(idle_timer: Some(timer))) 152 | } 153 | } 154 | } 155 | 156 | fn handle_bytes_builder_body( 157 | resp: response.Response(ResponseData), 158 | body: BytesBuilder, 159 | conn: Connection, 160 | ) -> Result(Nil, SocketReason) { 161 | resp 162 | |> response.set_body(body) 163 | |> http.add_default_headers 164 | |> encoder.to_bytes_builder 165 | |> conn.transport.send(conn.socket, _) 166 | } 167 | 168 | fn int_to_hex(int: Int) -> String { 169 | integer_to_list(int, 16) 170 | } 171 | 172 | fn handle_chunked_body( 173 | resp: response.Response(ResponseData), 174 | body: Iterator(BytesBuilder), 175 | conn: Connection, 176 | ) -> Result(Nil, SocketReason) { 177 | let headers = [#("transfer-encoding", "chunked"), ..resp.headers] 178 | let initial_payload = encoder.response_builder(resp.status, headers) 179 | 180 | conn.transport.send(conn.socket, initial_payload) 181 | |> result.then(fn(_ok) { 182 | body 183 | |> iterator.append(iterator.from_list([bytes_builder.new()])) 184 | |> iterator.try_fold(Nil, fn(_prev, chunk) { 185 | let size = bytes_builder.byte_size(chunk) 186 | let encoded = 187 | size 188 | |> int_to_hex 189 | |> bytes_builder.from_string 190 | |> bytes_builder.append_string("\r\n") 191 | |> bytes_builder.append_builder(chunk) 192 | |> bytes_builder.append_string("\r\n") 193 | 194 | conn.transport.send(conn.socket, encoded) 195 | }) 196 | }) 197 | |> result.replace(Nil) 198 | } 199 | 200 | fn handle_file_body( 201 | resp: response.Response(ResponseData), 202 | conn: Connection, 203 | ) -> Result(Nil, SocketReason) { 204 | let assert File(file_descriptor, offset, length) = resp.body 205 | resp 206 | |> response.prepend_header("content-length", int.to_string(length - offset)) 207 | |> response.set_body(bytes_builder.new()) 208 | |> fn(r: response.Response(BytesBuilder)) { 209 | encoder.response_builder(resp.status, r.headers) 210 | } 211 | |> conn.transport.send(conn.socket, _) 212 | |> result.then(fn(_) { 213 | file.sendfile(file_descriptor, conn.socket, offset, length, []) 214 | |> result.map_error(fn(err) { logger.error(#("Failed to send file", err)) }) 215 | |> result.replace_error(Badarg) 216 | }) 217 | |> result.replace(Nil) 218 | } 219 | 220 | /// Creates a standard HTTP handler service to pass to `mist.serve` 221 | @external(erlang, "erlang", "integer_to_list") 222 | fn integer_to_list(int int: Int, base base: Int) -> String 223 | -------------------------------------------------------------------------------- /test/http1_test.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/bit_array 3 | import gleam/http 4 | import gleam/http/request 5 | import gleam/http/response.{type Response, Response} 6 | import gleam/hackney 7 | import gleam/string 8 | import gleam/uri 9 | import gleam/option.{Some} 10 | import gleeunit/should 11 | import scaffold.{ 12 | bitstring_response_should_equal, make_request, open_server, 13 | string_response_should_equal, 14 | } 15 | 16 | pub type Fixture { 17 | Foreach 18 | Setup 19 | } 20 | 21 | pub type Instantiator { 22 | Spawn 23 | Timeout 24 | Inorder 25 | Inparallel 26 | } 27 | 28 | pub fn set_up_echo_server_test_() { 29 | #(Setup, fn() { open_server(8888) }, [ 30 | it_echoes_with_data, 31 | it_supports_large_header_fields, 32 | it_supports_patch_requests, 33 | it_rejects_large_requests, 34 | it_supports_chunked_encoding, 35 | it_supports_query_parameters, 36 | it_handles_query_parameters_with_question_mark, 37 | it_doesnt_mangle_query, 38 | it_supports_expect_continue_header, 39 | ]) 40 | } 41 | 42 | fn get_default_response() -> Response(BytesBuilder) { 43 | response.new(200) 44 | |> response.prepend_header("user-agent", "hackney/1.20.1") 45 | |> response.prepend_header("host", "localhost:8888") 46 | |> response.prepend_header("content-type", "application/octet-stream") 47 | |> response.prepend_header("content-length", "13") 48 | |> response.prepend_header("connection", "keep-alive") 49 | |> response.set_body(bytes_builder.from_bit_array(<<"hello, world!":utf8>>)) 50 | } 51 | 52 | pub fn it_echoes_with_data() { 53 | let req = make_request("/", "hello, world!") 54 | 55 | let assert Ok(resp) = hackney.send(req) 56 | 57 | string_response_should_equal(resp, get_default_response()) 58 | } 59 | 60 | pub fn it_supports_large_header_fields() { 61 | let big_request = 62 | make_request("/", "") 63 | |> request.prepend_header( 64 | "user-agent", 65 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:100.0) Gecko/20100101 Firefox/100.0", 66 | ) 67 | |> request.prepend_header( 68 | "accept", 69 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", 70 | ) 71 | |> request.prepend_header("accept-language", "en-GB,en;q=0.5") 72 | |> request.prepend_header("accept-encoding", "gzip, deflate, br") 73 | |> request.prepend_header("dnt", "1") 74 | |> request.prepend_header("connection", "keep-alive") 75 | |> request.prepend_header( 76 | "cookie", 77 | "csrftoken=redacted; firstVisit=redacted; sessionid=redacted; ph_GO532nkfIyRbVh8r-ts579S0ibtS4N7F8q1u7qy9FyY_posthog=%7B%22distinct_id%22%3A%22development-309%22%2C%22%24device_id%22%3A%2217f2233bb99e1f-0c5659974edb81-455a69-7e9000-17f2233bb9a8d6%22%2C%22%24user_id%22%3A%22development-309%22%2C%22%24initial_referrer%22%3A%22http%3A%2F%2Flocalhost%3A8000%2F%22%2C%22%24initial_referring_domain%22%3A%22localhost%3A8000%22%2C%22%24referrer%22%3A%22http%3A%2F%2Flocalhost%3A8000%2Fin-house-legal%2Fquery%2Fagreements%2F%22%2C%22%24referring_domain%22%3A%22localhost%3A8000%22%2C%22%24sesid%22%3A%5B1653655256592%2C%221810588be10b6a-070d87da3b86a88-402e2c34-4b9600-1810588be11e5a%22%5D%2C%22%24session_recording_enabled_server_side%22%3Afalse%2C%22%24active_feature_flags%22%3A%5B%5D%2C%22%24enabled_feature_flags%22%3A%7B%7D%7D; uid=SFMyNTY.MQ.3UIahins3fngCF2xLC2znGYe_xSbmG_bRdx0YSTwt_c; sessionid-3GDYO=redacted; CSRF-Token-3GDYO=tJoio2hbqKggK7fHZgFZyWVPnA7wySLf; CSRF-Token-CKIKR=Xc75SKFZS9qDDgLKD26rLYJtgtiH7Gja; sessionid-CKIKR=eikQox9V6M9sQwRnLHLj9HE5b2zRtopN; sessionid-NHOUY=wV9rHjEGqHzSpTfVrtMw3SkSUfV99YWq; CSRF-Token-NHOUY=ePu9N5NuRQSS6bwRfvrRTiSNdKjJV9Ro", 78 | ) 79 | |> request.prepend_header("upgrade-insecure-requests", "") 80 | |> request.prepend_header("sec-fetch-dest", "1") 81 | |> request.prepend_header("sec-fetch-mode", "document") 82 | |> request.prepend_header("sec-fetch-site", "navigate") 83 | |> request.prepend_header("sec-fetch-user", "none") 84 | |> request.prepend_header("pragma", "no-cache") 85 | |> request.prepend_header("cache-control", "no-cache") 86 | 87 | let expected = 88 | Response(..get_default_response(), headers: big_request.headers) 89 | |> response.prepend_header("content-type", "application/octet-stream") 90 | |> response.prepend_header("content-length", "0") 91 | |> response.prepend_header("host", "localhost:8888") 92 | |> response.set_body(bytes_builder.from_bit_array(<<>>)) 93 | 94 | let assert Ok(resp) = hackney.send(big_request) 95 | 96 | string_response_should_equal(resp, expected) 97 | } 98 | 99 | pub fn it_supports_patch_requests() { 100 | let req = 101 | make_request("/", "hello, world!") 102 | |> request.set_method(http.Patch) 103 | 104 | let assert Ok(resp) = hackney.send(req) 105 | 106 | string_response_should_equal(resp, get_default_response()) 107 | } 108 | 109 | pub fn it_rejects_large_requests() { 110 | let req = 111 | string.repeat("a", 4_000_001) 112 | |> make_request("/", _) 113 | 114 | let assert Ok(resp) = hackney.send(req) 115 | 116 | let expected = 117 | response.new(413) 118 | |> response.set_body(bytes_builder.from_bit_array(<<>>)) 119 | |> response.prepend_header("content-length", "0") 120 | |> response.prepend_header("connection", "close") 121 | 122 | string_response_should_equal(resp, expected) 123 | } 124 | 125 | @external(erlang, "hackney_ffi", "stream_request") 126 | fn stream_request( 127 | method method: http.Method, 128 | path path: String, 129 | headers headers: List(#(String, String)), 130 | body body: BitArray, 131 | ) -> Result(#(Int, List(#(String, String)), BitArray), Nil) 132 | 133 | pub fn it_supports_chunked_encoding() { 134 | let req = 135 | string.repeat("a", 10_000) 136 | |> bit_array.from_string 137 | |> make_request("/", _) 138 | |> request.set_method(http.Post) 139 | |> request.prepend_header("transfer-encoding", "chunked") 140 | 141 | let path = 142 | req 143 | |> request.to_uri 144 | |> uri.to_string 145 | let assert Ok(#(status, headers, body)) = 146 | stream_request(req.method, path, req.headers, req.body) 147 | let actual = response.Response(status, headers, body) 148 | 149 | let expected = 150 | response.new(200) 151 | |> response.prepend_header("user-agent", "hackney/1.20.1") 152 | |> response.prepend_header("host", "localhost:8888") 153 | |> response.prepend_header("connection", "keep-alive") 154 | |> response.prepend_header("content-length", "10000") 155 | |> response.set_body(bytes_builder.from_string(string.repeat("a", 10_000))) 156 | 157 | bitstring_response_should_equal(actual, expected) 158 | } 159 | 160 | pub fn it_supports_query_parameters() { 161 | let req = 162 | make_request("/", "hello, world!") 163 | |> request.set_method(http.Get) 164 | |> request.set_query([ 165 | #("something", "123"), 166 | #("another", "true"), 167 | #("a-complicated-one", uri.percent_encode("is the thing")), 168 | ]) 169 | 170 | let assert Ok(resp) = hackney.send(req) 171 | 172 | let expected = 173 | get_default_response() 174 | |> response.set_header("content-length", "61") 175 | |> response.set_body( 176 | bytes_builder.from_bit_array(<< 177 | "something=123&another=true&a-complicated-one=is%20the%20thing":utf8, 178 | >>), 179 | ) 180 | 181 | string_response_should_equal(resp, expected) 182 | } 183 | 184 | pub fn it_handles_query_parameters_with_question_mark() { 185 | let req = 186 | make_request("/", "hello, world!") 187 | |> request.set_method(http.Get) 188 | |> request.set_query([#("?", "123")]) 189 | 190 | let assert Ok(resp) = hackney.send(req) 191 | 192 | let expected = 193 | get_default_response() 194 | |> response.set_header("content-length", "5") 195 | |> response.set_body(bytes_builder.from_bit_array(<<"?=123":utf8>>)) 196 | 197 | string_response_should_equal(resp, expected) 198 | } 199 | 200 | pub fn it_doesnt_mangle_query() { 201 | let req = 202 | make_request("/", "hello, world!") 203 | |> request.set_method(http.Get) 204 | let req = request.Request(..req, query: Some("test")) 205 | 206 | let assert Ok(resp) = hackney.send(req) 207 | 208 | let expected = 209 | get_default_response() 210 | |> response.set_header("content-length", "4") 211 | |> response.set_body(bytes_builder.from_bit_array(<<"test":utf8>>)) 212 | 213 | string_response_should_equal(resp, expected) 214 | } 215 | 216 | pub fn it_supports_expect_continue_header() { 217 | let req = 218 | string.repeat("a", 1000) 219 | |> make_request("/", _) 220 | |> request.set_method(http.Post) 221 | |> request.prepend_header("expect", "100-continue") 222 | 223 | let assert Ok(resp) = hackney.send(req) 224 | 225 | let expected_body = 226 | string.repeat("a", 1000) 227 | |> bytes_builder.from_string 228 | 229 | let expected = 230 | response.new(200) 231 | |> response.prepend_header("user-agent", "hackney/1.20.1") 232 | |> response.prepend_header("host", "localhost:8888") 233 | |> response.prepend_header("connection", "keep-alive") 234 | |> response.prepend_header("content-type", "application/octet-stream") 235 | |> response.prepend_header("content-length", "1000") 236 | |> response.prepend_header("expect", "100-continue") 237 | |> response.set_body(expected_body) 238 | 239 | string_response_should_equal(resp, expected) 240 | } 241 | 242 | pub fn it_sends_back_chunked_responses_test() { 243 | let _server = scaffold.chunked_echo_server(8889, 100) 244 | 245 | let req = 246 | string.repeat("a", 1000) 247 | |> make_request("/", _) 248 | |> request.set_host("localhost:8889") 249 | |> request.set_method(http.Post) 250 | 251 | let assert Ok(resp) = hackney.send(req) 252 | 253 | should.equal(resp.status, 200) 254 | should.equal(resp.body, string.repeat("a", 1000)) 255 | } 256 | -------------------------------------------------------------------------------- /src/mist/internal/http.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/bit_array 3 | import gleam/dynamic.{type Dynamic} 4 | import gleam/erlang/atom.{type Atom} 5 | import gleam/erlang/charlist.{type Charlist} 6 | import gleam/http/request.{type Request} 7 | import gleam/http/response.{type Response, Response} 8 | import gleam/http 9 | import gleam/int 10 | import gleam/list 11 | import gleam/dict.{type Dict} 12 | import gleam/option.{type Option} 13 | import gleam/pair 14 | import gleam/result 15 | import gleam/string 16 | import gleam/uri 17 | import glisten/handler.{type ClientIp} 18 | import glisten/socket.{type Socket} 19 | import glisten/socket/transport.{type Transport} 20 | import mist/internal/buffer.{type Buffer, Buffer} 21 | import mist/internal/encoder 22 | 23 | pub type Connection { 24 | Connection( 25 | body: Body, 26 | socket: Socket, 27 | transport: Transport, 28 | client_ip: ClientIp, 29 | ) 30 | } 31 | 32 | pub type PacketType { 33 | Http 34 | HttphBin 35 | HttpBin 36 | } 37 | 38 | pub type HttpUri { 39 | AbsPath(BitArray) 40 | } 41 | 42 | pub type HttpPacket { 43 | HttpRequest(Dynamic, HttpUri, #(Int, Int)) 44 | HttpHeader(Int, Atom, BitArray, BitArray) 45 | } 46 | 47 | pub type DecodedPacket { 48 | BinaryData(HttpPacket, BitArray) 49 | EndOfHeaders(BitArray) 50 | MoreData(Option(Int)) 51 | } 52 | 53 | pub type DecodeError { 54 | MalformedRequest 55 | InvalidMethod 56 | InvalidPath 57 | UnknownHeader 58 | UnknownMethod 59 | // TODO: better name? 60 | InvalidBody 61 | DiscardPacket 62 | } 63 | 64 | pub fn from_header(value: BitArray) -> String { 65 | let assert Ok(value) = bit_array.to_string(value) 66 | 67 | string.lowercase(value) 68 | } 69 | 70 | pub fn parse_headers( 71 | bs: BitArray, 72 | socket: Socket, 73 | transport: Transport, 74 | headers: Dict(String, String), 75 | ) -> Result(#(Dict(String, String), BitArray), DecodeError) { 76 | case decode_packet(HttphBin, bs, []) { 77 | Ok(BinaryData(HttpHeader(_, _field, field, value), rest)) -> { 78 | let field = from_header(field) 79 | let assert Ok(value) = bit_array.to_string(value) 80 | headers 81 | |> dict.insert(field, value) 82 | |> parse_headers(rest, socket, transport, _) 83 | } 84 | Ok(EndOfHeaders(rest)) -> Ok(#(headers, rest)) 85 | Ok(MoreData(size)) -> { 86 | let amount_to_read = option.unwrap(size, 0) 87 | use next <- result.then(read_data( 88 | socket, 89 | transport, 90 | Buffer(amount_to_read, bs), 91 | UnknownHeader, 92 | )) 93 | parse_headers(next, socket, transport, headers) 94 | } 95 | _other -> Error(UnknownHeader) 96 | } 97 | } 98 | 99 | pub fn read_data( 100 | socket: Socket, 101 | transport: Transport, 102 | buffer: Buffer, 103 | error: DecodeError, 104 | ) -> Result(BitArray, DecodeError) { 105 | // TODO: don't hard-code these, probably 106 | let to_read = int.min(buffer.remaining, 1_000_000) 107 | let timeout = 15_000 108 | use data <- result.then( 109 | socket 110 | |> transport.receive_timeout(to_read, timeout) 111 | |> result.replace_error(error), 112 | ) 113 | let next_buffer = 114 | Buffer(remaining: int.max(0, buffer.remaining - to_read), data: << 115 | buffer.data:bits, 116 | data:bits, 117 | >>) 118 | 119 | case next_buffer.remaining > 0 { 120 | True -> read_data(socket, transport, next_buffer, error) 121 | False -> Ok(next_buffer.data) 122 | } 123 | } 124 | 125 | const crnl = <<13:int, 10:int>> 126 | 127 | pub type Chunk { 128 | Chunk(data: BitArray, buffer: Buffer) 129 | Complete 130 | } 131 | 132 | pub fn parse_chunk(string: BitArray) -> Chunk { 133 | case binary_split(string, <<"\r\n":utf8>>) { 134 | [<<"0":utf8>>, _] -> Complete 135 | [chunk_size, rest] -> { 136 | let assert Ok(chunk_size) = bit_array.to_string(chunk_size) 137 | case int.base_parse(chunk_size, 16) { 138 | Ok(size) -> { 139 | let size = size * 8 140 | case rest { 141 | <> -> { 142 | Chunk(data: next_chunk, buffer: buffer.new(rest)) 143 | } 144 | _ -> { 145 | Chunk(data: <<>>, buffer: buffer.new(string)) 146 | } 147 | } 148 | } 149 | Error(_) -> { 150 | Chunk(data: <<>>, buffer: buffer.new(string)) 151 | } 152 | } 153 | } 154 | 155 | _ -> { 156 | Chunk(data: <<>>, buffer: buffer.new(string)) 157 | } 158 | } 159 | } 160 | 161 | // TODO: use `parse_chunk` for this 162 | fn read_chunk( 163 | socket: Socket, 164 | transport: Transport, 165 | buffer: Buffer, 166 | body: BytesBuilder, 167 | ) -> Result(BytesBuilder, DecodeError) { 168 | case buffer.data, binary_match(buffer.data, crnl) { 169 | _, Ok(#(offset, _)) -> { 170 | let assert << 171 | chunk:bytes-size(offset), 172 | _return:int, 173 | _newline:int, 174 | rest:bytes, 175 | >> = buffer.data 176 | use chunk_size <- result.then( 177 | chunk 178 | |> bit_array.to_string 179 | |> result.map(charlist.from_string) 180 | |> result.replace_error(InvalidBody), 181 | ) 182 | use size <- result.then( 183 | string_to_int(chunk_size, 16) 184 | |> result.replace_error(InvalidBody), 185 | ) 186 | case size { 187 | 0 -> Ok(body) 188 | size -> 189 | case rest { 190 | <> -> 191 | read_chunk( 192 | socket, 193 | transport, 194 | Buffer(0, rest), 195 | bytes_builder.append(body, next_chunk), 196 | ) 197 | _ -> { 198 | use next <- result.then(read_data( 199 | socket, 200 | transport, 201 | Buffer(0, buffer.data), 202 | InvalidBody, 203 | )) 204 | read_chunk(socket, transport, Buffer(0, next), body) 205 | } 206 | } 207 | } 208 | } 209 | <<>> as data, _ | data, Error(Nil) -> { 210 | use next <- result.then(read_data( 211 | socket, 212 | transport, 213 | Buffer(0, data), 214 | InvalidBody, 215 | )) 216 | read_chunk(socket, transport, Buffer(0, next), body) 217 | } 218 | } 219 | } 220 | 221 | /// Turns the TCP message into an HTTP request 222 | pub fn parse_request( 223 | bs: BitArray, 224 | conn: Connection, 225 | ) -> Result(request.Request(Connection), DecodeError) { 226 | case decode_packet(HttpBin, bs, []) { 227 | Ok(BinaryData(HttpRequest(http_method, AbsPath(path), _version), rest)) -> { 228 | use method <- result.then( 229 | http_method 230 | |> atom.from_dynamic 231 | |> result.map(atom.to_string) 232 | |> result.or(dynamic.string(http_method)) 233 | |> result.nil_error 234 | |> result.then(http.parse_method) 235 | |> result.replace_error(UnknownMethod), 236 | ) 237 | use #(headers, rest) <- result.then(parse_headers( 238 | rest, 239 | conn.socket, 240 | conn.transport, 241 | dict.new(), 242 | )) 243 | use path <- result.then( 244 | path 245 | |> bit_array.to_string 246 | |> result.replace_error(InvalidPath), 247 | ) 248 | use parsed <- result.then( 249 | uri.parse(path) 250 | |> result.replace_error(InvalidPath), 251 | ) 252 | let #(path, query) = #(parsed.path, parsed.query) 253 | let req = 254 | request.new() 255 | |> request.set_scheme(case conn.transport { 256 | transport.Ssl(..) -> http.Https 257 | transport.Tcp(..) -> http.Http 258 | }) 259 | |> request.set_body(Connection(..conn, body: Initial(rest))) 260 | |> request.set_method(method) 261 | |> request.set_path(path) 262 | Ok(request.Request(..req, query: query, headers: dict.to_list(headers))) 263 | } 264 | _ -> Error(DiscardPacket) 265 | } 266 | } 267 | 268 | pub type Body { 269 | Initial(BitArray) 270 | } 271 | 272 | pub fn read_body( 273 | req: Request(Connection), 274 | ) -> Result(Request(BitArray), DecodeError) { 275 | let transport = case req.scheme { 276 | http.Https -> transport.ssl() 277 | http.Http -> transport.tcp() 278 | } 279 | case request.get_header(req, "transfer-encoding"), req.body.body { 280 | Ok("chunked"), Initial(rest) -> { 281 | use _nil <- result.then(handle_continue(req)) 282 | 283 | use chunk <- result.then(read_chunk( 284 | req.body.socket, 285 | transport, 286 | Buffer(remaining: 0, data: rest), 287 | bytes_builder.new(), 288 | )) 289 | Ok(request.set_body(req, bytes_builder.to_bit_array(chunk))) 290 | } 291 | _, Initial(rest) -> { 292 | use _nil <- result.then(handle_continue(req)) 293 | let body_size = 294 | req.headers 295 | |> list.find(fn(tup) { pair.first(tup) == "content-length" }) 296 | |> result.map(pair.second) 297 | |> result.then(int.parse) 298 | |> result.unwrap(0) 299 | let remaining = body_size - bit_array.byte_size(rest) 300 | case body_size, remaining { 301 | 0, 0 -> Ok(<<>>) 302 | 0, _n -> Ok(rest) 303 | // is this pipelining? check for GET? 304 | _n, 0 -> Ok(rest) 305 | _size, _rem -> 306 | read_data( 307 | req.body.socket, 308 | transport, 309 | Buffer(remaining, rest), 310 | InvalidBody, 311 | ) 312 | } 313 | |> result.map(request.set_body(req, _)) 314 | |> result.replace_error(InvalidBody) 315 | } 316 | } 317 | } 318 | 319 | pub type BodySlice { 320 | Chunked(data: BitArray, buffer: Buffer) 321 | Default(data: BitArray) 322 | Done 323 | } 324 | 325 | const websocket_key = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 326 | 327 | pub type ShaHash { 328 | Sha 329 | } 330 | 331 | fn parse_websocket_key(key: String) -> String { 332 | key 333 | |> string.append(websocket_key) 334 | |> crypto_hash(Sha, _) 335 | |> base64_encode 336 | } 337 | 338 | pub fn upgrade_socket( 339 | req: Request(Connection), 340 | ) -> Result(Response(BytesBuilder), Request(Connection)) { 341 | use _upgrade <- result.then( 342 | request.get_header(req, "upgrade") 343 | |> result.replace_error(req), 344 | ) 345 | use key <- result.then( 346 | request.get_header(req, "sec-websocket-key") 347 | |> result.replace_error(req), 348 | ) 349 | use _version <- result.then( 350 | request.get_header(req, "sec-websocket-version") 351 | |> result.replace_error(req), 352 | ) 353 | 354 | let accept_key = parse_websocket_key(key) 355 | 356 | response.new(101) 357 | |> response.set_body(bytes_builder.new()) 358 | |> response.prepend_header("Upgrade", "websocket") 359 | |> response.prepend_header("Connection", "Upgrade") 360 | |> response.prepend_header("Sec-WebSocket-Accept", accept_key) 361 | |> Ok 362 | } 363 | 364 | // TODO: improve this error type 365 | pub fn upgrade( 366 | socket: Socket, 367 | transport: Transport, 368 | req: Request(Connection), 369 | ) -> Result(Nil, Nil) { 370 | use resp <- result.then( 371 | upgrade_socket(req) 372 | |> result.nil_error, 373 | ) 374 | 375 | use _sent <- result.then( 376 | resp 377 | |> add_default_headers 378 | |> encoder.to_bytes_builder 379 | |> transport.send(socket, _) 380 | |> result.nil_error, 381 | ) 382 | 383 | Ok(Nil) 384 | } 385 | 386 | pub fn add_default_headers( 387 | resp: Response(BytesBuilder), 388 | ) -> Response(BytesBuilder) { 389 | let body_size = bytes_builder.byte_size(resp.body) 390 | 391 | let headers = 392 | dict.from_list([ 393 | #("content-length", int.to_string(body_size)), 394 | #("connection", "keep-alive"), 395 | ]) 396 | |> list.fold( 397 | resp.headers, 398 | _, 399 | fn(defaults, tup) { 400 | let #(key, value) = tup 401 | dict.insert(defaults, key, value) 402 | }, 403 | ) 404 | |> dict.to_list 405 | 406 | Response(..resp, headers: headers) 407 | } 408 | 409 | fn is_continue(req: Request(Connection)) -> Bool { 410 | req.headers 411 | |> list.find(fn(tup) { 412 | pair.first(tup) == "expect" && pair.second(tup) == "100-continue" 413 | }) 414 | |> result.is_ok 415 | } 416 | 417 | pub fn handle_continue(req: Request(Connection)) -> Result(Nil, DecodeError) { 418 | case is_continue(req) { 419 | True -> { 420 | response.new(100) 421 | |> response.set_body(bytes_builder.new()) 422 | |> encoder.to_bytes_builder 423 | |> req.body.transport.send(req.body.socket, _) 424 | |> result.replace_error(MalformedRequest) 425 | } 426 | False -> Ok(Nil) 427 | } 428 | } 429 | 430 | @external(erlang, "mist_ffi", "decode_packet") 431 | fn decode_packet( 432 | packet_type packet_type: PacketType, 433 | packet packet: BitArray, 434 | options options: List(a), 435 | ) -> Result(DecodedPacket, DecodeError) 436 | 437 | @external(erlang, "crypto", "hash") 438 | pub fn crypto_hash(hash hash: ShaHash, data data: String) -> String 439 | 440 | @external(erlang, "base64", "encode") 441 | pub fn base64_encode(data data: String) -> String 442 | 443 | @external(erlang, "mist_ffi", "binary_match") 444 | fn binary_match( 445 | source source: BitArray, 446 | pattern pattern: BitArray, 447 | ) -> Result(#(Int, Int), Nil) 448 | 449 | @external(erlang, "mist_ffi", "string_to_int") 450 | fn string_to_int(string string: Charlist, base base: Int) -> Result(Int, Nil) 451 | 452 | @external(erlang, "binary", "split") 453 | fn binary_split(source: BitArray, pattern: BitArray) -> List(BitArray) 454 | -------------------------------------------------------------------------------- /src/mist/internal/websocket.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/bit_array 3 | import gleam/dynamic 4 | import gleam/erlang.{rescue} 5 | import gleam/erlang/atom 6 | import gleam/erlang/process.{type Selector, type Subject} 7 | import gleam/list 8 | import gleam/option.{type Option, None, Some} 9 | import gleam/otp/actor 10 | import gleam/result 11 | import glisten/socket.{type Socket} 12 | import glisten/socket/options 13 | import glisten/socket/transport.{type Transport} 14 | import mist/internal/logger 15 | 16 | pub type DataFrame { 17 | TextFrame(payload_length: Int, payload: BitArray) 18 | BinaryFrame(payload_length: Int, payload: BitArray) 19 | } 20 | 21 | pub type ControlFrame { 22 | CloseFrame(payload_length: Int, payload: BitArray) 23 | // We don't care about basicaly everything else for now 24 | PingFrame(payload_length: Int, payload: BitArray) 25 | PongFrame(payload_length: Int, payload: BitArray) 26 | } 27 | 28 | // TODO: there are other message types, AND ALSO will need to buffer across 29 | // multiple frames, potentially 30 | pub type Frame { 31 | Data(DataFrame) 32 | Control(ControlFrame) 33 | Continuation(length: Int, payload: BitArray) 34 | } 35 | 36 | @external(erlang, "crypto", "exor") 37 | fn crypto_exor(a a: BitArray, b b: BitArray) -> BitArray 38 | 39 | fn unmask_data( 40 | data: BitArray, 41 | masks: List(BitArray), 42 | index: Int, 43 | resp: BitArray, 44 | ) -> BitArray { 45 | case data { 46 | <> -> { 47 | let assert Ok(mask_value) = list.at(masks, index % 4) 48 | let unmasked = crypto_exor(mask_value, masked) 49 | unmask_data(rest, masks, index + 1, <>) 50 | } 51 | _ -> resp 52 | } 53 | } 54 | 55 | type FrameParseError { 56 | NeedMoreData(BitArray) 57 | InvalidFrame 58 | } 59 | 60 | pub type ParsedFrame { 61 | Complete(Frame) 62 | Incomplete(Frame) 63 | } 64 | 65 | fn frame_from_message( 66 | message: BitArray, 67 | conn: WebsocketConnection, 68 | ) -> Result(#(ParsedFrame, BitArray), FrameParseError) { 69 | case message { 70 | << 71 | complete:1, 72 | _reserved:3, 73 | opcode:int-size(4), 74 | 1:1, 75 | payload_length:int-size(7), 76 | rest:bits, 77 | >> -> { 78 | let payload_size = case payload_length { 79 | 126 -> 16 80 | 127 -> 64 81 | _ -> 0 82 | } 83 | case rest { 84 | << 85 | length:int-size(payload_size), 86 | mask1:bytes-size(1), 87 | mask2:bytes-size(1), 88 | mask3:bytes-size(1), 89 | mask4:bytes-size(1), 90 | rest:bits, 91 | >> -> { 92 | let payload_byte_size = case length { 93 | 0 -> payload_length 94 | n -> n 95 | } 96 | case rest { 97 | <> -> { 98 | let data = 99 | unmask_data(payload, [mask1, mask2, mask3, mask4], 0, <<>>) 100 | case opcode { 101 | 0 -> Ok(Continuation(payload_length, data)) 102 | 1 -> Ok(Data(TextFrame(payload_length, data))) 103 | 2 -> Ok(Data(BinaryFrame(payload_length, data))) 104 | 8 -> Ok(Control(CloseFrame(payload_length, data))) 105 | 9 -> Ok(Control(PingFrame(payload_length, data))) 106 | 10 -> Ok(Control(PongFrame(payload_length, data))) 107 | _ -> Error(InvalidFrame) 108 | } 109 | |> result.then(fn(frame) { 110 | case complete { 111 | 1 -> Ok(#(Complete(frame), rest)) 112 | 0 -> Ok(#(Incomplete(frame), rest)) 113 | _ -> Error(InvalidFrame) 114 | } 115 | }) 116 | } 117 | _ -> { 118 | let assert Ok(data) = conn.transport.receive(conn.socket, 0) 119 | frame_from_message(<>, conn) 120 | } 121 | } 122 | } 123 | _ -> Error(InvalidFrame) 124 | } 125 | } 126 | _ -> Error(InvalidFrame) 127 | } 128 | } 129 | 130 | pub fn frame_to_bytes_builder(frame: Frame) -> BytesBuilder { 131 | case frame { 132 | Data(TextFrame(payload_length, payload)) -> 133 | make_frame(1, payload_length, payload) 134 | Control(CloseFrame(payload_length, payload)) -> 135 | make_frame(8, payload_length, payload) 136 | Data(BinaryFrame(payload_length, payload)) -> 137 | make_frame(2, payload_length, payload) 138 | Control(PongFrame(payload_length, payload)) -> 139 | make_frame(10, payload_length, payload) 140 | Control(PingFrame(payload_length, payload)) -> 141 | make_frame(9, payload_length, payload) 142 | Continuation(length, payload) -> make_frame(0, length, payload) 143 | } 144 | } 145 | 146 | fn make_frame(opcode: Int, length: Int, payload: BitArray) -> BytesBuilder { 147 | let length_section = case length { 148 | length if length > 65_535 -> <<127:7, length:int-size(64)>> 149 | length if length >= 126 -> <<126:7, length:int-size(16)>> 150 | _length -> <> 151 | } 152 | 153 | <<1:1, 0:3, opcode:4, 0:1, length_section:bits, payload:bits>> 154 | |> bytes_builder.from_bit_array 155 | } 156 | 157 | pub fn to_text_frame(data: String) -> BytesBuilder { 158 | let msg = bit_array.from_string(data) 159 | let size = bit_array.byte_size(msg) 160 | frame_to_bytes_builder(Data(TextFrame(size, msg))) 161 | } 162 | 163 | pub fn to_binary_frame(data: BitArray) -> BytesBuilder { 164 | let size = bit_array.byte_size(data) 165 | frame_to_bytes_builder(Data(BinaryFrame(size, data))) 166 | } 167 | 168 | pub type ValidMessage(user_message) { 169 | SocketMessage(BitArray) 170 | SocketClosedMessage 171 | UserMessage(user_message) 172 | } 173 | 174 | pub type WebsocketMessage(user_message) { 175 | Valid(ValidMessage(user_message)) 176 | Invalid 177 | } 178 | 179 | pub type WebsocketConnection { 180 | WebsocketConnection(socket: Socket, transport: Transport) 181 | } 182 | 183 | pub type HandlerMessage(user_message) { 184 | Internal(Frame) 185 | User(user_message) 186 | } 187 | 188 | pub type WebsocketState(state) { 189 | WebsocketState(buffer: BitArray, user: state) 190 | } 191 | 192 | pub type Handler(state, message) = 193 | fn(state, WebsocketConnection, HandlerMessage(message)) -> 194 | actor.Next(message, state) 195 | 196 | // TODO: this is pulled straight from glisten, prob should share it 197 | fn message_selector() -> Selector(WebsocketMessage(user_message)) { 198 | process.new_selector() 199 | |> process.selecting_record3(atom.create_from_string("tcp"), fn(_sock, data) { 200 | data 201 | |> dynamic.bit_array 202 | |> result.replace_error(Nil) 203 | |> result.map(SocketMessage) 204 | |> result.map(Valid) 205 | |> result.unwrap(Invalid) 206 | }) 207 | |> process.selecting_record3(atom.create_from_string("ssl"), fn(_sock, data) { 208 | data 209 | |> dynamic.bit_array 210 | |> result.replace_error(Nil) 211 | |> result.map(SocketMessage) 212 | |> result.map(Valid) 213 | |> result.unwrap(Invalid) 214 | }) 215 | |> process.selecting_record2(atom.create_from_string("ssl_closed"), fn(_nil) { 216 | Valid(SocketClosedMessage) 217 | }) 218 | |> process.selecting_record2(atom.create_from_string("tcp_closed"), fn(_nil) { 219 | Valid(SocketClosedMessage) 220 | }) 221 | } 222 | 223 | pub fn initialize_connection( 224 | on_init: fn(WebsocketConnection) -> #(state, Option(Selector(user_message))), 225 | on_close: fn(state) -> Nil, 226 | handler: Handler(state, user_message), 227 | socket: Socket, 228 | transport: Transport, 229 | ) -> Result(Subject(WebsocketMessage(user_message)), Nil) { 230 | let connection = WebsocketConnection(socket: socket, transport: transport) 231 | actor.start_spec( 232 | actor.Spec( 233 | init: fn() { 234 | let #(initial_state, user_selector) = on_init(connection) 235 | let selector = case user_selector { 236 | Some(user_selector) -> 237 | user_selector 238 | |> process.map_selector(UserMessage) 239 | |> process.map_selector(Valid) 240 | |> process.merge_selector(message_selector()) 241 | _ -> message_selector() 242 | } 243 | actor.Ready(WebsocketState(buffer: <<>>, user: initial_state), selector) 244 | }, 245 | init_timeout: 500, 246 | loop: fn(msg, state) { 247 | case msg { 248 | Valid(SocketMessage(data)) -> { 249 | let #(frames, rest) = 250 | get_messages(<>, connection, []) 251 | frames 252 | |> aggregate_frames(None, []) 253 | |> result.map(fn(frames) { 254 | let next = 255 | apply_frames( 256 | frames, 257 | handler, 258 | connection, 259 | actor.continue(state.user), 260 | on_close, 261 | ) 262 | case next { 263 | actor.Continue(user_state, selector) -> { 264 | actor.Continue( 265 | WebsocketState(buffer: rest, user: user_state), 266 | selector, 267 | ) 268 | } 269 | actor.Stop(reason) -> actor.Stop(reason) 270 | } 271 | }) 272 | |> result.lazy_unwrap(fn() { 273 | logger.error(#("Received a malformed WebSocket frame")) 274 | on_close(state.user) 275 | actor.Stop(process.Abnormal( 276 | "WebSocket received a malformed message", 277 | )) 278 | }) 279 | } 280 | Valid(UserMessage(msg)) -> { 281 | rescue(fn() { handler(state.user, connection, User(msg)) }) 282 | |> result.map(fn(cont) { 283 | case cont { 284 | actor.Continue(user_state, selector) -> { 285 | let selector = 286 | selector 287 | |> map_user_selector 288 | |> option.map(fn(with_user) { 289 | process.merge_selector(message_selector(), with_user) 290 | }) 291 | actor.Continue( 292 | WebsocketState(..state, user: user_state), 293 | selector, 294 | ) 295 | } 296 | actor.Stop(reason) -> { 297 | on_close(state.user) 298 | actor.Stop(reason) 299 | } 300 | } 301 | }) 302 | |> result.map_error(fn(err) { 303 | logger.error( 304 | "Caught error in websocket handler: " <> erlang.format(err), 305 | ) 306 | }) 307 | |> result.lazy_unwrap(fn() { 308 | on_close(state.user) 309 | actor.Stop(process.Abnormal("Crash in user websocket handler")) 310 | }) 311 | } 312 | Valid(SocketClosedMessage) -> { 313 | on_close(state.user) 314 | actor.Stop(process.Normal) 315 | } 316 | // TODO: do we need to send something back for this? 317 | Invalid -> { 318 | logger.error(#("Received a malformed WebSocket frame")) 319 | on_close(state.user) 320 | actor.Stop(process.Abnormal( 321 | "WebSocket received a malformed message", 322 | )) 323 | } 324 | } 325 | }, 326 | ), 327 | ) 328 | |> result.replace_error(Nil) 329 | |> result.map(fn(subj) { 330 | let websocket_pid = process.subject_owner(subj) 331 | let assert Ok(_) = 332 | connection.transport.controlling_process(connection.socket, websocket_pid) 333 | set_active(connection) 334 | subj 335 | }) 336 | |> result.replace_error(Nil) 337 | } 338 | 339 | fn get_messages( 340 | data: BitArray, 341 | conn: WebsocketConnection, 342 | frames: List(ParsedFrame), 343 | ) -> #(List(ParsedFrame), BitArray) { 344 | case frame_from_message(data, conn) { 345 | Ok(#(frame, <<>>)) -> #(list.reverse([frame, ..frames]), <<>>) 346 | Ok(#(frame, rest)) -> get_messages(rest, conn, [frame, ..frames]) 347 | Error(NeedMoreData(rest)) -> #(frames, rest) 348 | Error(InvalidFrame) -> #(frames, data) 349 | } 350 | } 351 | 352 | fn apply_frames( 353 | frames: List(Frame), 354 | handler: Handler(state, user_message), 355 | connection: WebsocketConnection, 356 | next: actor.Next(WebsocketMessage(user_message), state), 357 | on_close: fn(state) -> Nil, 358 | ) -> actor.Next(WebsocketMessage(user_message), state) { 359 | case frames, next { 360 | _, actor.Stop(reason) -> actor.Stop(reason) 361 | [], next -> { 362 | set_active(connection) 363 | next 364 | } 365 | [Control(CloseFrame(..)) as frame, ..], actor.Continue(state, _selector) -> { 366 | let _ = 367 | connection.transport.send( 368 | connection.socket, 369 | frame_to_bytes_builder(frame), 370 | ) 371 | on_close(state) 372 | actor.Stop(process.Normal) 373 | } 374 | [Control(PingFrame(length, payload)), ..], actor.Continue(state, _selector) -> { 375 | connection.transport.send( 376 | connection.socket, 377 | frame_to_bytes_builder(Control(PongFrame(length, payload))), 378 | ) 379 | |> result.map(fn(_nil) { 380 | set_active(connection) 381 | actor.continue(state) 382 | }) 383 | |> result.lazy_unwrap(fn() { 384 | on_close(state) 385 | actor.Stop(process.Abnormal("Failed to send pong frame")) 386 | }) 387 | } 388 | [frame, ..rest], actor.Continue(state, prev_selector) -> { 389 | case rescue(fn() { handler(state, connection, Internal(frame)) }) { 390 | Ok(actor.Continue(state, selector)) -> { 391 | let next_selector = 392 | selector 393 | |> map_user_selector 394 | |> option.or(prev_selector) 395 | |> option.map(fn(with_user) { 396 | process.merge_selector(message_selector(), with_user) 397 | }) 398 | 399 | apply_frames( 400 | rest, 401 | handler, 402 | connection, 403 | actor.Continue(state, next_selector), 404 | on_close, 405 | ) 406 | } 407 | Ok(actor.Stop(reason)) -> { 408 | on_close(state) 409 | actor.Stop(reason) 410 | } 411 | Error(reason) -> { 412 | logger.error( 413 | "Caught error in websocket handler: " <> erlang.format(reason), 414 | ) 415 | on_close(state) 416 | actor.Stop(process.Abnormal("Crash in user websocket handler")) 417 | } 418 | } 419 | } 420 | } 421 | } 422 | 423 | pub fn aggregate_frames( 424 | frames: List(ParsedFrame), 425 | previous: Option(Frame), 426 | joined: List(Frame), 427 | ) -> Result(List(Frame), Nil) { 428 | case frames, previous { 429 | [], _ -> Ok(list.reverse(joined)) 430 | [Complete(Continuation(length, data)), ..rest], Some(prev) -> { 431 | let next = append_frame(prev, length, data) 432 | aggregate_frames(rest, None, [next, ..joined]) 433 | } 434 | [Incomplete(Continuation(length, data)), ..rest], Some(prev) -> { 435 | let next = append_frame(prev, length, data) 436 | aggregate_frames(rest, Some(next), joined) 437 | } 438 | [Incomplete(frame), ..rest], None -> { 439 | aggregate_frames(rest, Some(frame), joined) 440 | } 441 | [Complete(frame), ..rest], None -> { 442 | aggregate_frames(rest, None, [frame, ..joined]) 443 | } 444 | _, _ -> Error(Nil) 445 | } 446 | } 447 | 448 | fn set_active(connection: WebsocketConnection) -> Nil { 449 | let assert Ok(_) = 450 | connection.transport.set_opts(connection.socket, [ 451 | options.ActiveMode(options.Once), 452 | ]) 453 | 454 | Nil 455 | } 456 | 457 | fn map_user_selector( 458 | selector: Option(Selector(user_message)), 459 | ) -> Option(Selector(WebsocketMessage(user_message))) { 460 | option.map(selector, process.map_selector(_, fn(msg) { 461 | Valid(UserMessage(msg)) 462 | })) 463 | } 464 | 465 | fn append_frame(left: Frame, length: Int, data: BitArray) -> Frame { 466 | case left { 467 | Data(TextFrame(len, payload)) -> 468 | Data(TextFrame(len + length, <>)) 469 | Data(BinaryFrame(len, payload)) -> 470 | Data(BinaryFrame(len + length, <>)) 471 | Control(CloseFrame(len, payload)) -> 472 | Control(CloseFrame(len + length, <>)) 473 | Control(PingFrame(len, payload)) -> 474 | Control(PingFrame(len + length, <>)) 475 | Control(PongFrame(len, payload)) -> 476 | Control(PongFrame(len + length, <>)) 477 | Continuation(..) -> left 478 | } 479 | } 480 | -------------------------------------------------------------------------------- /src/mist.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder.{type BytesBuilder} 2 | import gleam/bit_array 3 | import gleam/erlang/process.{type ProcessDown, type Selector} 4 | import gleam/function 5 | import gleam/http/request.{type Request} 6 | import gleam/http/response.{type Response} 7 | import gleam/http.{type Scheme, Http, Https} as gleam_http 8 | import gleam/int 9 | import gleam/io 10 | import gleam/iterator.{type Iterator} 11 | import gleam/option.{type Option, None} 12 | import gleam/otp/actor 13 | import gleam/result 14 | import glisten 15 | import glisten/socket 16 | import glisten/socket/transport 17 | import mist/internal/buffer.{type Buffer, Buffer} 18 | import mist/internal/file 19 | import mist/internal/handler.{ 20 | type ResponseData as InternalResponseData, Bytes as InternalBytes, 21 | Chunked as InternalChunked, File as InternalFile, 22 | Websocket as InternalWebsocket, 23 | } 24 | import mist/internal/http.{type Connection as InternalConnection} 25 | import mist/internal/websocket.{ 26 | type HandlerMessage, type WebsocketConnection as InternalWebsocketConnection, 27 | BinaryFrame, Data, Internal, TextFrame, User, 28 | } 29 | 30 | /// Re-exported type that represents the default `Request` body type. See 31 | /// `mist.read_body` to convert this type into a `BitString`. The `Connection` 32 | /// also holds some additional information about the request. Currently, the 33 | /// only useful field is `client_ip` which is a `Result` with a tuple of 34 | /// integers representing the IPv4 address. 35 | pub type Connection = 36 | InternalConnection 37 | 38 | /// The response body type. This allows `mist` to handle these different cases 39 | /// for you. `Bytes` is the regular data return. `Websocket` will upgrade the 40 | /// socket to websockets, but should not be used directly. See the 41 | /// `mist.upgrade` function for usage. `Chunked` will use 42 | /// `Transfer-Encoding: chunked` to send an iterator in chunks. `File` will use 43 | /// Erlang's `sendfile` to more efficiently return a file to the client. 44 | pub type ResponseData { 45 | Websocket(Selector(ProcessDown)) 46 | Bytes(BytesBuilder) 47 | Chunked(Iterator(BytesBuilder)) 48 | /// See `mist.send_file` to use this response type. 49 | File(descriptor: file.FileDescriptor, offset: Int, length: Int) 50 | } 51 | 52 | /// Potential errors when opening a file to send. This list is 53 | /// currently not exhaustive with POSIX errors. 54 | pub type FileError { 55 | IsDir 56 | NoAccess 57 | NoEntry 58 | UnknownFileError 59 | } 60 | 61 | fn convert_file_errors(err: file.FileError) -> FileError { 62 | case err { 63 | file.IsDir -> IsDir 64 | file.NoAccess -> NoAccess 65 | file.NoEntry -> NoEntry 66 | file.UnknownFileError -> UnknownFileError 67 | } 68 | } 69 | 70 | /// To respond with a file using Erlang's `sendfile`, use this function 71 | /// with the specified offset and limit (optional). It will attempt to open the 72 | /// file for reading, get its file size, and then send the file. If the read 73 | /// errors, this will return the relevant `FileError`. Generally, this will be 74 | /// more memory efficient than manually doing this process with `mist.Bytes`. 75 | pub fn send_file( 76 | path: String, 77 | offset offset: Int, 78 | limit limit: Option(Int), 79 | ) -> Result(ResponseData, FileError) { 80 | path 81 | |> bit_array.from_string 82 | |> file.stat 83 | |> result.map_error(convert_file_errors) 84 | |> result.map(fn(stat) { 85 | File( 86 | descriptor: stat.descriptor, 87 | offset: offset, 88 | length: option.unwrap(limit, stat.file_size), 89 | ) 90 | }) 91 | } 92 | 93 | /// The possible errors from reading the request body. If the size is larger 94 | /// than the provided value, `ExcessBody` is returned. If there is an error 95 | /// reading the body from the socket or the body is malformed (i.e a chunked 96 | /// request with invalid sizes), `MalformedBody` is returned. 97 | pub type ReadError { 98 | ExcessBody 99 | MalformedBody 100 | } 101 | 102 | /// The request body is not pulled from the socket until requested. The 103 | /// `content-length` header is used to determine whether the socket is read 104 | /// from or not. The read may also fail, and a `ReadError` is raised. 105 | pub fn read_body( 106 | req: Request(Connection), 107 | max_body_limit max_body_limit: Int, 108 | ) -> Result(Request(BitArray), ReadError) { 109 | req 110 | |> request.get_header("content-length") 111 | |> result.then(int.parse) 112 | |> result.unwrap(0) 113 | |> fn(content_length) { 114 | case content_length { 115 | value if value <= max_body_limit -> { 116 | http.read_body(req) 117 | |> result.replace_error(MalformedBody) 118 | } 119 | _ -> { 120 | Error(ExcessBody) 121 | } 122 | } 123 | } 124 | } 125 | 126 | /// The values returning from streaming the request body. The `Chunk` 127 | /// variant gives back some data and the next token. `Done` signifies 128 | /// that we have completed reading the body. 129 | pub type Chunk { 130 | Chunk(data: BitArray, consume: fn(Int) -> Result(Chunk, ReadError)) 131 | Done 132 | } 133 | 134 | fn do_stream( 135 | req: Request(Connection), 136 | buffer: Buffer, 137 | ) -> fn(Int) -> Result(Chunk, ReadError) { 138 | fn(size) { 139 | let socket = req.body.socket 140 | let transport = req.body.transport 141 | let byte_size = bit_array.byte_size(buffer.data) 142 | 143 | case buffer.remaining, byte_size { 144 | 0, 0 -> Ok(Done) 145 | 146 | 0, _buffer_size -> { 147 | let #(data, rest) = buffer.slice(buffer, size) 148 | Ok(Chunk(data, do_stream(req, buffer.new(rest)))) 149 | } 150 | 151 | _, buffer_size if buffer_size >= size -> { 152 | let #(data, rest) = buffer.slice(buffer, size) 153 | let new_buffer = Buffer(..buffer, data: rest) 154 | Ok(Chunk(data, do_stream(req, new_buffer))) 155 | } 156 | 157 | _, _buffer_size -> { 158 | http.read_data(socket, transport, buffer.empty(), http.InvalidBody) 159 | |> result.replace_error(MalformedBody) 160 | |> result.map(fn(data) { 161 | let fetched_data = bit_array.byte_size(data) 162 | let new_buffer = 163 | Buffer( 164 | data: bit_array.append(buffer.data, data), 165 | remaining: int.max(0, buffer.remaining - fetched_data), 166 | ) 167 | let #(new_data, rest) = buffer.slice(new_buffer, size) 168 | Chunk(new_data, do_stream(req, Buffer(..new_buffer, data: rest))) 169 | }) 170 | } 171 | } 172 | } 173 | } 174 | 175 | type ChunkState { 176 | ChunkState(data_buffer: Buffer, chunk_buffer: Buffer, done: Bool) 177 | } 178 | 179 | fn do_stream_chunked( 180 | req: Request(Connection), 181 | state: ChunkState, 182 | ) -> fn(Int) -> Result(Chunk, ReadError) { 183 | let socket = req.body.socket 184 | let transport = req.body.transport 185 | 186 | fn(size) { 187 | case fetch_chunks_until(socket, transport, state, size) { 188 | Ok(#(data, ChunkState(done: True, ..))) -> { 189 | Ok(Chunk(data, fn(_size) { Ok(Done) })) 190 | } 191 | Ok(#(data, state)) -> { 192 | Ok(Chunk(data, do_stream_chunked(req, state))) 193 | } 194 | Error(_) -> Error(MalformedBody) 195 | } 196 | } 197 | } 198 | 199 | fn fetch_chunks_until( 200 | socket: socket.Socket, 201 | transport: transport.Transport, 202 | state: ChunkState, 203 | byte_size: Int, 204 | ) -> Result(#(BitArray, ChunkState), ReadError) { 205 | let data_size = bit_array.byte_size(state.data_buffer.data) 206 | case state.done, data_size { 207 | _, size if size >= byte_size -> { 208 | let #(value, rest) = buffer.slice(state.data_buffer, byte_size) 209 | Ok(#(value, ChunkState(..state, data_buffer: buffer.new(rest)))) 210 | } 211 | 212 | True, _ -> { 213 | Ok(#(state.data_buffer.data, ChunkState(..state, done: True))) 214 | } 215 | 216 | False, _ -> { 217 | case http.parse_chunk(state.chunk_buffer.data) { 218 | http.Complete -> { 219 | let updated_state = 220 | ChunkState(..state, chunk_buffer: buffer.empty(), done: True) 221 | fetch_chunks_until(socket, transport, updated_state, byte_size) 222 | } 223 | http.Chunk(<<>>, next_buffer) -> { 224 | http.read_data(socket, transport, next_buffer, http.InvalidBody) 225 | |> result.replace_error(MalformedBody) 226 | |> result.then(fn(new_data) { 227 | let updated_state = 228 | ChunkState(..state, chunk_buffer: buffer.new(new_data)) 229 | fetch_chunks_until(socket, transport, updated_state, byte_size) 230 | }) 231 | } 232 | http.Chunk(data, next_buffer) -> { 233 | let updated_state = 234 | ChunkState( 235 | ..state, 236 | data_buffer: buffer.append(state.data_buffer, data), 237 | chunk_buffer: next_buffer, 238 | ) 239 | fetch_chunks_until(socket, transport, updated_state, byte_size) 240 | } 241 | } 242 | } 243 | } 244 | } 245 | 246 | /// Rather than explicitly reading either the whole body (optionally up to 247 | /// `N` bytes), this function allows you to consume a stream of the request 248 | /// body. Any errors reading the body will propagate out, or `Chunk`s will be 249 | /// emitted. This provides a `consume` method to attempt to grab the next 250 | /// `size` chunk from the socket. 251 | pub fn stream( 252 | req: Request(Connection), 253 | ) -> Result(fn(Int) -> Result(Chunk, ReadError), ReadError) { 254 | let continue = 255 | req 256 | |> http.handle_continue 257 | |> result.replace_error(MalformedBody) 258 | 259 | use _nil <- result.map(continue) 260 | 261 | let is_chunked = case request.get_header(req, "transfer-encoding") { 262 | Ok("chunked") -> True 263 | _ -> False 264 | } 265 | 266 | let assert http.Initial(data) = req.body.body 267 | 268 | case is_chunked { 269 | True -> { 270 | let state = ChunkState(buffer.new(<<>>), buffer.new(data), False) 271 | do_stream_chunked(req, state) 272 | } 273 | False -> { 274 | let content_length = 275 | req 276 | |> request.get_header("content-length") 277 | |> result.then(int.parse) 278 | |> result.unwrap(0) 279 | 280 | let initial_size = bit_array.byte_size(data) 281 | 282 | let buffer = 283 | Buffer(data: data, remaining: int.max(0, content_length - initial_size)) 284 | 285 | do_stream(req, buffer) 286 | } 287 | } 288 | } 289 | 290 | pub opaque type Builder(request_body, response_body) { 291 | Builder( 292 | port: Int, 293 | handler: fn(Request(request_body)) -> Response(response_body), 294 | after_start: fn(Int, Scheme) -> Nil, 295 | ) 296 | } 297 | 298 | /// Create a new `mist` handler with a given function. The default port is 299 | /// 4000. 300 | pub fn new(handler: fn(Request(in)) -> Response(out)) -> Builder(in, out) { 301 | Builder(port: 4000, handler: handler, after_start: fn(port, scheme) { 302 | let message = 303 | "Listening on " 304 | <> gleam_http.scheme_to_string(scheme) 305 | <> "://localhost:" 306 | <> int.to_string(port) 307 | io.println(message) 308 | }) 309 | } 310 | 311 | /// Assign a different listening port to the service. 312 | pub fn port(builder: Builder(in, out), port: Int) -> Builder(in, out) { 313 | Builder(..builder, port: port) 314 | } 315 | 316 | /// This function allows for implicitly reading the body of requests up 317 | /// to a given size. If the size is too large, or the read fails, the provided 318 | /// `failure_response` will be sent back as the response. 319 | pub fn read_request_body( 320 | builder: Builder(BitArray, out), 321 | bytes_limit bytes_limit: Int, 322 | failure_response failure_response: Response(out), 323 | ) -> Builder(Connection, out) { 324 | let handler = fn(request) { 325 | case read_body(request, bytes_limit) { 326 | Ok(request) -> builder.handler(request) 327 | Error(_) -> failure_response 328 | } 329 | } 330 | Builder(builder.port, handler, builder.after_start) 331 | } 332 | 333 | /// Override the default function to be called after the service starts. The 334 | /// default is to log a message with the listening port. 335 | pub fn after_start( 336 | builder: Builder(in, out), 337 | after_start: fn(Int, Scheme) -> Nil, 338 | ) -> Builder(in, out) { 339 | Builder(..builder, after_start: after_start) 340 | } 341 | 342 | fn convert_body_types( 343 | resp: Response(ResponseData), 344 | ) -> Response(InternalResponseData) { 345 | let new_body = case resp.body { 346 | Websocket(selector) -> InternalWebsocket(selector) 347 | Bytes(data) -> InternalBytes(data) 348 | File(descriptor, offset, length) -> InternalFile(descriptor, offset, length) 349 | Chunked(iter) -> InternalChunked(iter) 350 | } 351 | response.set_body(resp, new_body) 352 | } 353 | 354 | /// Start a `mist` service over HTTP with the provided builder. 355 | pub fn start_http( 356 | builder: Builder(Connection, ResponseData), 357 | ) -> Result(Nil, glisten.StartError) { 358 | builder.handler 359 | |> function.compose(convert_body_types) 360 | |> handler.with_func 361 | |> glisten.handler(fn() { #(handler.new_state(), None) }, _) 362 | |> glisten.serve(builder.port) 363 | |> result.map(fn(nil) { 364 | builder.after_start(builder.port, Http) 365 | // TODO: This should not be `Nil` but instead a subject that can receive 366 | // messages, such as shutdown 367 | nil 368 | }) 369 | } 370 | 371 | /// Start a `mist` service over HTTPS with the provided builder. This method 372 | /// requires both a certificate file and a key file. The library will attempt 373 | /// to read these files off of the disk. 374 | pub fn start_https( 375 | builder: Builder(Connection, ResponseData), 376 | certfile certfile: String, 377 | keyfile keyfile: String, 378 | ) -> Result(Nil, glisten.StartError) { 379 | builder.handler 380 | |> function.compose(convert_body_types) 381 | |> handler.with_func 382 | |> glisten.handler(fn() { #(handler.new_state(), None) }, _) 383 | |> glisten.serve_ssl(builder.port, certfile, keyfile) 384 | |> result.map(fn(nil) { 385 | builder.after_start(builder.port, Https) 386 | // TODO: This should not be `Nil` but instead a subject that can receive 387 | // messages, such as shutdown 388 | nil 389 | }) 390 | } 391 | 392 | /// These are the types of messages that a websocket handler may receive. 393 | pub type WebsocketMessage(custom) { 394 | Text(String) 395 | Binary(BitArray) 396 | Closed 397 | Shutdown 398 | Custom(custom) 399 | } 400 | 401 | fn internal_to_public_ws_message( 402 | msg: HandlerMessage(custom), 403 | ) -> Result(WebsocketMessage(custom), Nil) { 404 | case msg { 405 | Internal(Data(TextFrame(_length, data))) -> { 406 | data 407 | |> bit_array.to_string 408 | |> result.map(Text) 409 | } 410 | Internal(Data(BinaryFrame(_length, data))) -> Ok(Binary(data)) 411 | User(msg) -> Ok(Custom(msg)) 412 | _ -> Error(Nil) 413 | } 414 | } 415 | 416 | /// Upgrade a request to handle websockets. If the request is 417 | /// malformed, or the websocket process fails to initialize, an empty 418 | /// 400 response will be sent to the client. 419 | /// 420 | /// The `on_init` method will be called when the actual WebSocket process 421 | /// is started, and the return value is the initial state and an optional 422 | /// selector for receiving user messages. 423 | /// 424 | /// The `on_close` method is called when the WebSocket process shuts down 425 | /// for any reason, valid or otherwise. 426 | pub fn websocket( 427 | request request: Request(Connection), 428 | handler handler: fn(state, WebsocketConnection, WebsocketMessage(message)) -> 429 | actor.Next(message, state), 430 | on_init on_init: fn(WebsocketConnection) -> 431 | #(state, Option(process.Selector(message))), 432 | on_close on_close: fn(state) -> Nil, 433 | ) -> Response(ResponseData) { 434 | let handler = fn(state, connection, message) { 435 | message 436 | |> internal_to_public_ws_message 437 | |> result.map(handler(state, connection, _)) 438 | |> result.unwrap(actor.continue(state)) 439 | } 440 | let socket = request.body.socket 441 | let transport = request.body.transport 442 | request 443 | |> http.upgrade(socket, transport, _) 444 | |> result.then(fn(_nil) { 445 | websocket.initialize_connection( 446 | on_init, 447 | on_close, 448 | handler, 449 | socket, 450 | transport, 451 | ) 452 | }) 453 | |> result.map(fn(subj) { 454 | let ws_process = process.subject_owner(subj) 455 | let monitor = process.monitor_process(ws_process) 456 | let selector = 457 | process.new_selector() 458 | |> process.selecting_process_down(monitor, function.identity) 459 | response.new(200) 460 | |> response.set_body(Websocket(selector)) 461 | }) 462 | |> result.lazy_unwrap(fn() { 463 | response.new(400) 464 | |> response.set_body(Bytes(bytes_builder.new())) 465 | }) 466 | } 467 | 468 | pub type WebsocketConnection = 469 | InternalWebsocketConnection 470 | 471 | /// Sends a binary frame across the websocket. 472 | pub fn send_binary_frame( 473 | connection: WebsocketConnection, 474 | frame: BitArray, 475 | ) -> Result(Nil, socket.SocketReason) { 476 | frame 477 | |> websocket.to_binary_frame 478 | |> connection.transport.send(connection.socket, _) 479 | } 480 | 481 | /// Sends a text frame across the websocket. 482 | pub fn send_text_frame( 483 | connection: WebsocketConnection, 484 | frame: String, 485 | ) -> Result(Nil, socket.SocketReason) { 486 | frame 487 | |> websocket.to_text_frame 488 | |> connection.transport.send(connection.socket, _) 489 | } 490 | --------------------------------------------------------------------------------