diff --git a/examples/manifest.toml b/examples/manifest.toml index 53e6dd4..5a24a9e 100644 --- a/examples/manifest.toml +++ b/examples/manifest.toml @@ -12,6 +12,8 @@ packages = [ { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, + { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, + { name = "gleam_stdlib", version = "0.64.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EA2E13FC4E65750643E078487D5EF360BEBCA5EBBBA12042FB589C19F53E35C0" }, { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "glearray", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "5E272F7CB278CC05A929C58DEB58F5D5AC6DB5B879A681E71138658D0061C38A" }, diff --git a/examples/src/working_with_websockets/app.gleam b/examples/src/working_with_websockets/app.gleam new file mode 100644 index 0000000..1827996 --- /dev/null +++ b/examples/src/working_with_websockets/app.gleam @@ -0,0 +1,19 @@ +import gleam/erlang/process +import mist +import wisp +import wisp/wisp_mist +import working_with_websockets/app/router + +pub fn main() { + wisp.configure_logger() + let secret_key_base = wisp.random_string(64) + + let assert Ok(_) = + router.handle_request + |> wisp_mist.handler(secret_key_base) + |> mist.new + |> mist.port(8001) + |> mist.start + + process.sleep_forever() +} diff --git a/examples/src/working_with_websockets/app/router.gleam b/examples/src/working_with_websockets/app/router.gleam new file mode 100644 index 0000000..5391ced --- /dev/null +++ b/examples/src/working_with_websockets/app/router.gleam @@ -0,0 +1,146 @@ +import gleam/int +import gleam/option +import wisp.{type Request, type Response} +import wisp/websocket + +pub fn handle_request(request: Request) -> Response { + use <- wisp.log_request(request) + + case wisp.path_segments(request) { + [] -> home_page() + ["websocket"] -> websocket_handler(request) + _ -> wisp.not_found() + } +} + +fn home_page() -> Response { + let html = + " + + + WebSocket Echo Example + + + +

WebSocket Echo Example

+

This example demonstrates WebSocket support in Wisp.

+ +
+ + + Disconnected +
+ +
+ +
+ + +
+ + + +" + + wisp.html_response(html, 200) +} + +fn websocket_handler(request: Request) -> Response { + wisp.websocket( + request, + on_init: fn(_connection) { #(0, option.None) }, + on_message: fn(state, message, connection) { + case message { + websocket.Text(text) -> { + let count = state + 1 + let response = "Echo #" <> int.to_string(count) <> ": " <> text + case websocket.send_text(connection, response) { + Ok(_) -> websocket.Continue(count) + Error(_) -> websocket.StopWithError("Failed to send message") + } + } + websocket.Binary(binary) -> { + case websocket.send_binary(connection, binary) { + Ok(_) -> websocket.Continue(state) + Error(_) -> websocket.StopWithError("Failed to send binary message") + } + } + websocket.Closed -> websocket.Stop + websocket.Shutdown -> websocket.Stop + websocket.Custom(_) -> websocket.Stop + } + }, + on_close: fn(state) { + wisp.log_info( + "Connection closed after: " <> int.to_string(state) <> " messages", + ) + }, + ) +} diff --git a/examples/test/working_with_websockets/app_test.gleam b/examples/test/working_with_websockets/app_test.gleam new file mode 100644 index 0000000..603b755 --- /dev/null +++ b/examples/test/working_with_websockets/app_test.gleam @@ -0,0 +1,138 @@ +import gleam/http +import wisp +import wisp/simulate +import working_with_websockets/app/router + +pub fn get_home_page_test() { + let request = simulate.browser_request(http.Get, "/") + let response = router.handle_request(request) + + assert response.status == 200 + assert response.headers == [#("content-type", "text/html; charset=utf-8")] +} + +pub fn page_not_found_test() { + let request = simulate.browser_request(http.Get, "/nothing-here") + let response = router.handle_request(request) + + assert response.status == 404 +} + +pub fn websocket_upgrade_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(_) = response.body +} + +pub fn websocket_text_echo_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(upgrade) = response.body + let handler = wisp.recover(upgrade) + let assert Ok(ws) = simulate.create_websocket(handler) + + // Send first text message + let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") + let assert ["Echo #1: Hello"] = simulate.websocket_sent_text_messages(ws) + let assert [] = simulate.websocket_sent_binary_messages(ws) + + // Send second text message + let assert Ok(ws) = simulate.send_websocket_text(ws, "World") + let assert ["Echo #1: Hello", "Echo #2: World"] = + simulate.websocket_sent_text_messages(ws) + + // Send third text message + let assert Ok(ws) = simulate.send_websocket_text(ws, "!") + let assert ["Echo #1: Hello", "Echo #2: World", "Echo #3: !"] = + simulate.websocket_sent_text_messages(ws) +} + +pub fn websocket_binary_echo_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(upgrade) = response.body + let handler = wisp.recover(upgrade) + let assert Ok(ws) = simulate.create_websocket(handler) + + // Send binary message + let assert Ok(ws) = simulate.send_websocket_binary(ws, <<1, 2, 3>>) + let assert [<<1, 2, 3>>] = simulate.websocket_sent_binary_messages(ws) + let assert [] = simulate.websocket_sent_text_messages(ws) + + // Send another binary message + let assert Ok(ws) = simulate.send_websocket_binary(ws, <<4, 5, 6>>) + let assert [<<1, 2, 3>>, <<4, 5, 6>>] = + simulate.websocket_sent_binary_messages(ws) +} + +pub fn websocket_mixed_messages_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(upgrade) = response.body + let handler = wisp.recover(upgrade) + let assert Ok(ws) = simulate.create_websocket(handler) + + // Send text message + let assert Ok(ws) = simulate.send_websocket_text(ws, "Text message") + let assert ["Echo #1: Text message"] = + simulate.websocket_sent_text_messages(ws) + + // Send binary message (doesn't increment count) + let assert Ok(ws) = simulate.send_websocket_binary(ws, <<7, 8, 9>>) + let assert [<<7, 8, 9>>] = simulate.websocket_sent_binary_messages(ws) + + // Send another text message (count should be 2) + let assert Ok(ws) = simulate.send_websocket_text(ws, "Another text") + let assert ["Echo #1: Text message", "Echo #2: Another text"] = + simulate.websocket_sent_text_messages(ws) +} + +pub fn websocket_close_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(upgrade) = response.body + let handler = wisp.recover(upgrade) + let assert Ok(ws) = simulate.create_websocket(handler) + + // Send some messages + let assert Ok(ws) = simulate.send_websocket_text(ws, "First") + let assert Ok(ws) = simulate.send_websocket_text(ws, "Second") + let assert Ok(ws) = simulate.send_websocket_text(ws, "Third") + + let assert ["Echo #1: First", "Echo #2: Second", "Echo #3: Third"] = + simulate.websocket_sent_text_messages(ws) + + // Close the websocket - should succeed + let assert Ok(Nil) = simulate.close_websocket(ws) +} + +pub fn websocket_closed_ignores_messages_test() { + let request = simulate.websocket_request(http.Get, "/websocket") + let response = router.handle_request(request) + + let assert wisp.WebSocket(upgrade) = response.body + let handler = wisp.recover(upgrade) + let assert Ok(ws) = simulate.create_websocket(handler) + + // Send a message + let assert Ok(ws) = simulate.send_websocket_text(ws, "Before close") + let assert ["Echo #1: Before close"] = + simulate.websocket_sent_text_messages(ws) + + // Close the websocket + let assert Ok(Nil) = simulate.close_websocket(ws) + + // Try to send messages after closing + let assert Ok(ws) = simulate.send_websocket_text(ws, "After close") + let assert Ok(ws) = simulate.send_websocket_binary(ws, <<10, 11, 12>>) + + // Messages should not be processed + let assert ["Echo #1: Before close"] = + simulate.websocket_sent_text_messages(ws) + let assert [] = simulate.websocket_sent_binary_messages(ws) +} diff --git a/gleam.toml b/gleam.toml index 4321100..1ad2e29 100644 --- a/gleam.toml +++ b/gleam.toml @@ -21,6 +21,7 @@ logging = ">= 1.2.0 and < 2.0.0" directories = ">= 1.0.0 and < 2.0.0" houdini = ">= 1.0.0 and < 2.0.0" filepath = ">= 1.1.2 and < 2.0.0" +gleam_otp = ">= 1.1.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 93a7183..af442d2 100644 --- a/manifest.toml +++ b/manifest.toml @@ -34,6 +34,7 @@ gleam_crypto = { version = ">= 1.0.0 and < 2.0.0" } gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } gleam_http = { version = ">= 3.5.0 and < 5.0.0" } gleam_json = { version = ">= 3.0.0 and < 4.0.0" } +gleam_otp = { version = ">= 1.1.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.50.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } houdini = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/wisp.gleam b/src/wisp.gleam index 2aeffcc..3b87c57 100644 --- a/src/wisp.gleam +++ b/src/wisp.gleam @@ -9,6 +9,7 @@ import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode import gleam/erlang/application import gleam/erlang/atom.{type Atom} +import gleam/erlang/process import gleam/http.{type Method} import gleam/http/cookie import gleam/http/request.{type Request as HttpRequest} @@ -28,6 +29,7 @@ import logging import marceau import simplifile import wisp/internal +import wisp/websocket // // Responses @@ -66,8 +68,28 @@ pub type Body { /// The maximum number of bytes to send. Set to `None` for the whole file. limit: Option(Int), ) + /// A WebSocket upgrade response. + /// + /// This will upgrade the HTTP connection to a WebSocket connection. + /// The upgrade is handled by the underlying HTTP server adapter. + /// + WebSocket(WebSocketUpgrade) } +pub type WebSocketUpgrade + +@internal +pub type Unknown + +@external(erlang, "gleam@function", "identity") +fn erase(callbacks: websocket.WebSocket(state, message)) -> WebSocketUpgrade + +@external(erlang, "gleam@function", "identity") +@internal +pub fn recover( + upgrade: WebSocketUpgrade, +) -> websocket.WebSocket(Unknown, Unknown) + /// An alias for a HTTP response containing a `Body`. pub type Response = HttpResponse(Body) @@ -1991,6 +2013,63 @@ pub fn get_cookie( bit_array.to_string(value) } +// +// WebSocket +// + +/// Upgrade a HTTP request to a WebSocket connection. +/// +/// This function creates a response that will upgrade the connection to +/// WebSocket. The actual WebSocket protocol handling is done by the +/// web server adapter (such as wisp_mist). +/// +/// # Examples +/// +/// ```gleam +/// fn handle_request(request: Request) -> Response { +/// case wisp.path_segments(request) { +/// ["websocket"] -> { +/// wisp.websocket( +/// request, +/// on_init: fn(connection) { +/// 0 +/// }, +/// on_message: fn(state, message, connection) { +/// case message { +/// websocket.Text(text) -> { +/// websocket.send_text(connection, "Echo: " <> text) +/// websocket.continue(state) +/// } +/// websocket.Closed -> websocket.stop() +/// _ -> websocket.continue(state) +/// } +/// }, +/// on_close: fn(_state) { Nil } +/// ) +/// } +/// _ -> wisp.not_found() +/// } +/// } +/// ``` +/// +pub fn websocket( + request _request: Request, + on_init on_init: fn(websocket.Connection) -> + #(state, Option(process.Selector(message))), + on_message on_message: fn( + state, + websocket.Message(custom), + websocket.Connection, + ) -> + websocket.Next(state), + on_close on_close: fn(state) -> Nil, +) -> Response { + let ws = websocket.new(on_init, on_message, on_close) + + response(200) + |> set_body(WebSocket(erase(ws))) +} + // // Testing // diff --git a/src/wisp/simulate.gleam b/src/wisp/simulate.gleam index fc790cd..629cbbc 100644 --- a/src/wisp/simulate.gleam +++ b/src/wisp/simulate.gleam @@ -1,16 +1,19 @@ import gleam/bit_array import gleam/bytes_tree import gleam/crypto +import gleam/erlang/process import gleam/http import gleam/http/request import gleam/json.{type Json} import gleam/list import gleam/option.{None, Some} +import gleam/otp/actor import gleam/result import gleam/string import gleam/uri import simplifile import wisp.{type Request, type Response, Bytes, File, Text} +import wisp/websocket /// Create a test request that can be used to test your request handler /// functions. @@ -279,6 +282,9 @@ pub fn read_body(response: Response) -> String { as "the body file range was not valid UTF-8" string } + wisp.WebSocket(_) -> { + panic as "Cannot read body of WebSocket response - use a WebSocket client instead" + } } } @@ -306,6 +312,9 @@ pub fn read_body_bits(response: Response) -> BitArray { as "the body was a file, but the limit and offset were invalid" sliced } + wisp.WebSocket(_) -> { + panic as "Cannot read body of WebSocket response - use a WebSocket client instead" + } } } @@ -349,3 +358,427 @@ pub const default_browser_headers: List(#(String, String)) = [ #("origin", "https://" <> default_host), #("host", default_host), ] + +/// Create a websocket upgrade request with proper headers. +/// +/// This creates a request that simulates a WebSocket upgrade handshake by +/// setting the necessary headers: `connection`, `upgrade`, `sec-websocket-key`, +/// and `sec-websocket-version`. +/// +/// ## Example +/// +/// ```gleam +/// let request = simulate.websocket_request(http.Get, "/chat") +/// let response = handle_request(request) +/// +/// case response.body { +/// wisp.WebSocket(upgrade) -> { +/// let handler = wisp.upgrade_to_websocket(upgrade) +/// // Test the websocket handler +/// } +/// _ -> panic as "Expected WebSocket upgrade" +/// } +/// ``` +/// +pub fn websocket_request(method: http.Method, path: String) -> Request { + request(method, path) + |> header("connection", "Upgrade") + |> header("upgrade", "websocket") + |> header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + |> header("sec-websocket-version", "13") +} + +/// A websocket mock for testing websocket handlers. +/// +/// This opaque type represents a test websocket connection that captures all +/// messages sent by the handler. It maintains the handler's state and tracks +/// all text and binary messages sent through the connection. +/// +/// You cannot construct this type directly - use `create_websocket` to create +/// a test websocket from a websocket handler. +/// +/// ## Functions +/// +/// - `create_websocket` - Create a new test websocket +/// - `send_websocket_text` - Send a text message to the handler +/// - `send_websocket_binary` - Send a binary message to the handler +/// - `websocket_sent_text_messages` - Get all text messages sent by the handler +/// - `websocket_sent_binary_messages` - Get all binary messages sent by the handler +/// - `reset_websocket` - Reset to initial state +/// - `close_websocket` - Close the connection +/// +pub opaque type WebSocket(selector_message, state) { + WebSocket( + websocket: websocket.WebSocket(selector_message, state), + connection: websocket.Connection, + state: websocket.State(state), + subject: process.Subject(WebSocketMessage(state)), + ) +} + +/// Internal state for the websocket mock actor +type WebSocketState(state) { + State( + state: option.Option(websocket.State(state)), + sent_text_messages: List(String), + sent_binary_messages: List(BitArray), + closed: Bool, + ) +} + +/// Messages that can be sent to the mock websocket actor +type WebSocketMessage(state) { + SendText(String) + SendBinary(BitArray) + Close(websocket.State(state)) + GetSentTextMessages(reply_with: process.Subject(List(String))) + GetSentBinaryMessages(reply_with: process.Subject(List(BitArray))) + Reset(state: websocket.State(state)) + SetState(state: websocket.State(state)) + GetState(reply_with: process.Subject(websocket.State(state))) + IsClosed(reply_with: process.Subject(Bool)) +} + +/// Create a new websocket mock for testing. +/// +/// This function creates a test websocket that captures all messages sent by the +/// handler, allowing you to verify the handler's behavior without needing a real +/// WebSocket connection. The mock automatically tracks text and binary messages +/// sent through the connection. +/// +/// ## Example +/// +/// ```gleam +/// let handler = websocket.new( +/// on_init: fn(_conn) { 0 }, +/// on_message: fn(state, message, connection) { +/// case message { +/// websocket.Text(text) -> { +/// websocket.send_text(connection, "Echo: " <> text) +/// websocket.Continue(state + 1) +/// } +/// _ -> websocket.Continue(state) +/// } +/// }, +/// on_close: fn(_state) { Nil }, +/// ) +/// +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") +/// let assert ["Echo: Hello"] = simulate.websocket_sent_text_messages(ws) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(WebSocket)` - A test websocket that can be used with other simulate functions +/// - `Error(actor.StartError)` - If the underlying actor fails to start +/// +pub fn create_websocket( + handler websocket: websocket.WebSocket(selector_message, state), +) -> Result(WebSocket(selector_message, state), actor.StartError) { + let #(init, _, stop) = websocket.extract_callbacks(websocket) + + use started <- result.try( + actor.new(State( + state: option.None, + sent_text_messages: [], + sent_binary_messages: [], + closed: False, + )) + |> actor.on_message(handle_message) + |> actor.start, + ) + + let connection = + websocket.make_connection( + fn(text) { + process.send(started.data, SendText(text)) + Ok(Nil) + }, + fn(binary) { + process.send(started.data, SendBinary(binary)) + Ok(Nil) + }, + fn() { + let state = process.call(started.data, 1000, GetState) + stop(state) + Ok(Nil) + }, + ) + let #(state, _selector) = init(connection) + process.send(started.data, SetState(state)) + let ws_instance = + WebSocket( + websocket: websocket, + connection: connection, + state: state, + subject: started.data, + ) + Ok(ws_instance) +} + +/// Handle messages sent to the mock websocket actor +fn handle_message( + state: WebSocketState(state), + message: WebSocketMessage(state), +) -> actor.Next(WebSocketState(state), WebSocketMessage(state)) { + case message { + SendText(text) -> { + let new_state = case state.closed { + True -> state + False -> + State(..state, sent_text_messages: [text, ..state.sent_text_messages]) + } + actor.continue(new_state) + } + SendBinary(binary) -> { + let new_state = case state.closed { + True -> state + False -> + State(..state, sent_binary_messages: [ + binary, + ..state.sent_binary_messages + ]) + } + actor.continue(new_state) + } + Close(_) -> { + let new_state = State(..state, closed: True) + actor.continue(new_state) + } + GetSentTextMessages(reply_with) -> { + process.send(reply_with, list.reverse(state.sent_text_messages)) + actor.continue(state) + } + GetSentBinaryMessages(reply_with) -> { + process.send(reply_with, list.reverse(state.sent_binary_messages)) + actor.continue(state) + } + Reset(new_state) -> { + let new_state = + State( + state: option.Some(new_state), + sent_text_messages: [], + sent_binary_messages: [], + closed: False, + ) + actor.continue(new_state) + } + SetState(new_state) -> { + let new_state = State(..state, state: Some(new_state)) + actor.continue(new_state) + } + GetState(reply_with:) -> { + let assert Some(s) = state.state + process.send(reply_with, s) + actor.continue(state) + } + IsClosed(reply_with:) -> { + process.send(reply_with, state.closed) + actor.continue(state) + } + } +} + +/// Get all text messages that have been sent by the websocket handler. +/// +/// Messages are returned in the order they were sent. This is useful for +/// verifying that your handler sends the expected messages in response to +/// incoming messages. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "World") +/// +/// let messages = simulate.websocket_sent_text_messages(ws) +/// assert messages == ["Response 1", "Response 2"] +/// ``` +/// +pub fn websocket_sent_text_messages( + websocket: WebSocket(selector_message, state), +) -> List(String) { + process.call(websocket.subject, 1000, GetSentTextMessages) +} + +/// Get all binary messages that have been sent by the websocket handler. +/// +/// Messages are returned in the order they were sent. This is useful for +/// verifying that your handler sends the expected binary data in response to +/// incoming messages. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_binary(ws, <<1, 2, 3>>) +/// +/// let messages = simulate.websocket_sent_binary_messages(ws) +/// assert messages == [<<1, 2, 3>>] +/// ``` +/// +pub fn websocket_sent_binary_messages( + websocket: WebSocket(selector_message, state), +) -> List(BitArray) { + process.call(websocket.subject, 1000, GetSentBinaryMessages) +} + +/// Reset the websocket to its initial state, clearing all captured messages. +/// +/// This calls the handler's `on_init` callback again and clears the list of +/// sent messages. The websocket is also marked as not closed, allowing you to +/// send messages again after a close. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") +/// let assert ["Response"] = simulate.websocket_sent_text_messages(ws) +/// +/// // Reset to initial state +/// let ws = simulate.reset_websocket(ws) +/// let assert [] = simulate.websocket_sent_text_messages(ws) +/// +/// // Can send messages again from a clean slate +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello again") +/// ``` +/// +pub fn reset_websocket( + websocket: WebSocket(selector_message, state), +) -> WebSocket(selector_message, state) { + let WebSocket(websocket: internal_websocket, connection:, state: _, subject:) = + websocket + let #(init, _, _) = websocket.extract_callbacks(internal_websocket) + let #(state, _selector) = init(connection) + process.send(subject, Reset(state)) + WebSocket(websocket: internal_websocket, connection:, state:, subject:) +} + +/// Simulate sending a text message to the websocket handler. +/// +/// This calls the handler's `on_message` callback with a `Text` message. The +/// handler's state is updated based on the callback's response. Any messages +/// sent by the handler can be retrieved using `websocket_sent_text_messages` +/// or `websocket_sent_binary_messages`. +/// +/// If the websocket has been closed, this function returns the websocket +/// unchanged without calling the handler. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") +/// let assert ["Echo: Hello"] = simulate.websocket_sent_text_messages(ws) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(WebSocket)` - The websocket with updated state +/// - `Error(Nil)` - If the handler returns `Stop` or `StopWithError` +/// +pub fn send_websocket_text( + ws: WebSocket(selector_message, state), + message: String, +) -> Result(WebSocket(selector_message, state), Nil) { + let WebSocket(websocket:, state:, connection:, subject:) = ws + let is_closed = process.call(subject, 1000, IsClosed) + case is_closed { + True -> Ok(ws) + False -> { + let #(_, handle, _) = websocket.extract_callbacks(websocket) + case handle(state, websocket.Text(message), connection) { + websocket.Continue(state) -> { + process.send(subject, SetState(state)) + Ok(WebSocket(websocket:, state:, connection:, subject:)) + } + websocket.Stop -> Error(Nil) + websocket.StopWithError(_) -> Error(Nil) + } + } + } +} + +/// Simulate sending a binary message to the websocket handler. +/// +/// This calls the handler's `on_message` callback with a `Binary` message. The +/// handler's state is updated based on the callback's response. Any messages +/// sent by the handler can be retrieved using `websocket_sent_text_messages` +/// or `websocket_sent_binary_messages`. +/// +/// If the websocket has been closed, this function returns the websocket +/// unchanged without calling the handler. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_binary(ws, <<1, 2, 3>>) +/// let assert [<<1, 2, 3>>] = simulate.websocket_sent_binary_messages(ws) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(WebSocket)` - The websocket with updated state +/// - `Error(Nil)` - If the handler returns `Stop` or `StopWithError` +/// +pub fn send_websocket_binary( + ws: WebSocket(selector_message, state), + message: BitArray, +) -> Result(WebSocket(selector_message, state), Nil) { + let WebSocket(websocket:, state:, connection:, subject:) = ws + let is_closed = process.call(subject, 1000, IsClosed) + case is_closed { + True -> Ok(ws) + False -> { + let #(_, handle, _) = websocket.extract_callbacks(websocket) + case handle(state, websocket.Binary(message), connection) { + websocket.Continue(state) -> { + process.send(subject, SetState(state)) + Ok(WebSocket(websocket:, state:, connection:, subject:)) + } + websocket.Stop -> Error(Nil) + websocket.StopWithError(_) -> Error(Nil) + } + } + } +} + +/// Simulate closing the websocket connection. +/// +/// This calls the handler's `on_close` callback with the current handler state. +/// After closing, any subsequent calls to `send_websocket_text` or +/// `send_websocket_binary` will be ignored without calling the handler. +/// +/// Use `reset_websocket` to re-open the connection for further testing. +/// +/// ## Example +/// +/// ```gleam +/// let assert Ok(ws) = simulate.create_websocket(handler) +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "Hello") +/// +/// // Close the connection +/// let assert Ok(Nil) = simulate.close_websocket(ws) +/// +/// // Further messages are ignored +/// let assert Ok(ws) = simulate.send_websocket_text(ws, "After close") +/// let assert ["Response 1"] = simulate.websocket_sent_text_messages(ws) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(Nil)` - If the connection was closed successfully +/// - `Error(WebSocketError)` - If closing the connection failed +/// +pub fn close_websocket( + websocket_arg: WebSocket(selector_message, state), +) -> Result(Nil, websocket.WebSocketError) { + let WebSocket(websocket: _, state: _, connection:, subject:) = websocket_arg + let current_state = process.call(subject, 1000, GetState) + process.send(subject, Close(current_state)) + websocket.close_connection(connection) +} diff --git a/src/wisp/websocket.gleam b/src/wisp/websocket.gleam new file mode 100644 index 0000000..e5683c8 --- /dev/null +++ b/src/wisp/websocket.gleam @@ -0,0 +1,340 @@ +import gleam/erlang/process.{type Selector} +import gleam/option.{type Option} + +/// Represents a WebSocket connection that can be used to send messages. +/// +/// This opaque type is passed to your handler's callbacks, allowing you to +/// send messages to the client using `send_text` and `send_binary`. +/// +/// You cannot construct this type directly - it is provided by the framework +/// when your handler is called. +/// +pub opaque type Connection { + WebSocketConnection( + send_text: fn(String) -> Result(Nil, WebSocketError), + send_binary: fn(BitArray) -> Result(Nil, WebSocketError), + close: fn() -> Result(Nil, WebSocketError), + ) +} + +/// Errors that can occur when working with WebSockets. +/// +pub type WebSocketError { + /// The WebSocket connection has been closed. + ConnectionClosed + /// Failed to send a message over the WebSocket. + SendFailed + /// The message format was invalid. + InvalidMessage + /// A custom WebSocket error with a description. + WebSocketError(String) +} + +/// Messages that can be received from a WebSocket client. +/// +/// Your `on_message` callback will receive one of these variants to handle +/// different types of incoming messages. +/// +pub type Message(custom) { + /// A text message received from the client. + Text(String) + /// A binary message received from the client. + Binary(BitArray) + /// The client has closed the connection. + Closed + /// The server is shutting down the connection. + Shutdown + Custom(custom) +} + +/// The result of handling a WebSocket message. +/// +/// Return this from your `on_message` callback to indicate whether the +/// connection should continue or stop. +/// +pub type Next(state) { + /// Continue handling messages with the updated state. + Continue(state) + /// Stop the WebSocket connection gracefully. + Stop + /// Stop the WebSocket connection with an error message. + StopWithError(String) +} + +/// A WebSocket handler that defines the behavior of a WebSocket connection. +/// +/// This opaque type is created using `new` and encapsulates the initialization, +/// message handling, and cleanup logic for a WebSocket connection. +/// +/// You cannot construct this type directly - use `new` to create a handler. +/// +pub opaque type WebSocket(selector_message, state) { + WebSocket( + init: fn(Connection) -> #(State(state), Option(Selector(selector_message))), + handle: fn(State(state), Message(state), Connection) -> + WebSocketResult(state), + close: fn(State(state)) -> Nil, + ) +} + +/// The internal state of a WebSocket handler. +/// +/// This opaque type represents the current state of your WebSocket handler and +/// is managed internally by the framework. Your handler's state (defined in +/// `on_init` and updated in `on_message`) is stored within this type. +/// +pub opaque type State(any) { + WebSocketState(step: fn(WebSocketAction(any)) -> WebSocketResult(any)) +} + +type WebSocketAction(any) { + HandleMessage(Message(any), Connection) + Close +} + +type WebSocketResult(any) { + ContinueWith(State(any)) + StopNow + StopWithErrorResult(String) +} + +@internal +pub fn make_connection( + send_text: fn(String) -> Result(Nil, WebSocketError), + send_binary: fn(BitArray) -> Result(Nil, WebSocketError), + close: fn() -> Result(Nil, WebSocketError), +) -> Connection { + WebSocketConnection( + send_text: send_text, + send_binary: send_binary, + close: close, + ) +} + +/// Send a text message to the WebSocket client. +/// +/// This function sends a UTF-8 text message over the WebSocket connection. +/// Call this from your `on_message` or `on_init` callback to send messages +/// to the client. +/// +/// ## Example +/// +/// ```gleam +/// websocket.new( +/// on_init: fn(_conn) { 0 }, +/// on_message: fn(state, message, connection) { +/// case message { +/// websocket.Text(text) -> { +/// let response = "You said: " <> text +/// case websocket.send_text(connection, response) { +/// Ok(_) -> websocket.Continue(state) +/// Error(_) -> websocket.StopWithError("Failed to send message") +/// } +/// } +/// _ -> websocket.Continue(state) +/// } +/// }, +/// on_close: fn(_) { Nil }, +/// ) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(Nil)` - The message was sent successfully +/// - `Error(WebSocketError)` - Failed to send the message +/// +pub fn send_text( + connection: Connection, + message: String, +) -> Result(Nil, WebSocketError) { + connection.send_text(message) +} + +/// Send a binary message to the WebSocket client. +/// +/// This function sends raw binary data over the WebSocket connection. Use this +/// when you need to send non-text data like images, audio, or custom binary +/// protocols. +/// +/// ## Example +/// +/// ```gleam +/// websocket.new( +/// on_init: fn(_conn) { 0 }, +/// on_message: fn(state, message, connection) { +/// case message { +/// websocket.Binary(data) -> { +/// // Echo the binary data back +/// case websocket.send_binary(connection, data) { +/// Ok(_) -> websocket.Continue(state) +/// Error(_) -> websocket.StopWithError("Failed to send binary") +/// } +/// } +/// _ -> websocket.Continue(state) +/// } +/// }, +/// on_close: fn(_) { Nil }, +/// ) +/// ``` +/// +/// ## Returns +/// +/// - `Ok(Nil)` - The message was sent successfully +/// - `Error(WebSocketError)` - Failed to send the message +/// +pub fn send_binary( + connection: Connection, + message: BitArray, +) -> Result(Nil, WebSocketError) { + connection.send_binary(message) +} + +@internal +pub fn close_connection(connection: Connection) -> Result(Nil, WebSocketError) { + connection.close() +} + +/// Create a new WebSocket handler. +/// +/// This function defines the behavior of a WebSocket connection by providing +/// three callbacks: +/// +/// - `on_init`: Called when the connection is established. Return a tuple with +/// the initial state for this connection and an optional process selector for +/// handling custom messages. +/// - `on_message`: Called when a message is received. Return `Continue(state)` +/// to keep the connection open with updated state, `Stop` to close gracefully, +/// or `StopWithError(reason)` to close with an error. +/// - `on_close`: Called when the connection is closed. Use this for cleanup. +/// +/// ## Example +/// +/// ```gleam +/// // A simple echo server that counts messages +/// websocket.new( +/// on_init: fn(_connection) { +/// // Initialize with a count of 0, no custom selector +/// #(0, option.None) +/// }, +/// on_message: fn(count, message, connection) { +/// case message { +/// websocket.Text(text) -> { +/// let new_count = count + 1 +/// let response = "Message #" <> int.to_string(new_count) <> ": " <> text +/// case websocket.send_text(connection, response) { +/// Ok(_) -> websocket.Continue(new_count) +/// Error(_) -> websocket.StopWithError("Send failed") +/// } +/// } +/// websocket.Binary(data) -> { +/// websocket.send_binary(connection, data) +/// websocket.Continue(count) +/// } +/// websocket.Closed | websocket.Shutdown -> { +/// websocket.Stop +/// } +/// } +/// }, +/// on_close: fn(count) { +/// io.println("Connection closed after " <> int.to_string(count) <> " messages") +/// }, +/// ) +/// ``` +/// +/// ## Usage with Wisp +/// +/// Use this with `wisp.websocket` to handle WebSocket upgrade requests: +/// +/// ```gleam +/// pub fn handle_request(request: wisp.Request) -> wisp.Response { +/// case wisp.path_segments(request) { +/// ["ws"] -> { +/// wisp.websocket( +/// request, +/// on_init: fn(_conn) { #(MyState(...), option.None) }, +/// on_message: handle_ws_message, +/// on_close: fn(_state) { Nil }, +/// ) +/// } +/// _ -> wisp.not_found() +/// } +/// } +/// ``` +/// +pub fn new( + on_init: fn(Connection) -> #(state, Option(Selector(message))), + on_message: fn(state, Message(any), Connection) -> Next(state), + on_close: fn(state) -> Nil, +) -> WebSocket(message, any) { + WebSocket( + init: fn(connection) { + let #(state, selector) = on_init(connection) + #(new_state(state, on_message, on_close), selector) + }, + handle:, + close:, + ) +} + +fn handle( + state: State(any), + message: Message(any), + connection: Connection, +) -> WebSocketResult(any) { + state.step(HandleMessage(message, connection)) +} + +fn close(state: State(any)) -> Nil { + case state.step(Close) { + _ -> Nil + } +} + +fn new_state( + state: state, + on_message: fn(state, Message(any), Connection) -> Next(state), + on_close: fn(state) -> Nil, +) -> State(any) { + WebSocketState(step: fn(action) { + case action { + HandleMessage(message, connection) -> { + case on_message(state, message, connection) { + Continue(n_state) -> + ContinueWith(new_state(n_state, on_message, on_close)) + Stop -> StopNow + StopWithError(error) -> StopWithErrorResult(error) + } + } + Close -> { + on_close(state) + StopNow + } + } + }) +} + +@internal +pub fn extract_callbacks( + ws: WebSocket(message, any), +) -> #( + fn(Connection) -> #(State(any), Option(Selector(message))), + fn(State(any), Message(any), Connection) -> Next(State(any)), + fn(State(any)) -> Nil, +) { + #( + ws.init, + fn(state, message, connection) { + ws.handle(state, message, connection) + |> result_to_next + }, + ws.close, + ) +} + +fn result_to_next(result: WebSocketResult(any)) -> Next(State(any)) { + case result { + ContinueWith(state) -> Continue(state) + StopNow -> Stop + StopWithErrorResult(error) -> StopWithError(error) + } +} diff --git a/src/wisp/wisp_mist.gleam b/src/wisp/wisp_mist.gleam index 5e80dec..cc763b6 100644 --- a/src/wisp/wisp_mist.gleam +++ b/src/wisp/wisp_mist.gleam @@ -8,6 +8,7 @@ import gleam/string import mist import wisp import wisp/internal +import wisp/websocket // // Running the server @@ -43,18 +44,24 @@ pub fn handler( fn(request: HttpRequest(_)) { let connection = internal.make_connection(mist_body_reader(request), secret_key_base) - let request = request.set_body(request, connection) + let wisp_request = request.set_body(request, connection) use <- exception.defer(fn() { - let assert Ok(_) = wisp.delete_temporary_files(request) + let assert Ok(_) = wisp.delete_temporary_files(wisp_request) }) - let response = - request - |> handler - |> mist_response + let response = handler(wisp_request) - response + case response.body { + wisp.WebSocket(upgrade) -> { + mist_websocket_upgrade(request, upgrade) + } + wisp.Text(text) -> + response.set_body(response, mist.Bytes(bytes_tree.from_string(text))) + wisp.Bytes(bytes) -> response.set_body(response, mist.Bytes(bytes)) + wisp.File(path:, offset:, limit:) -> + response.set_body(response, mist_send_file(path, offset, limit)) + } } } @@ -79,16 +86,6 @@ fn wrap_mist_chunk( }) } -fn mist_response(response: wisp.Response) -> HttpResponse(mist.ResponseData) { - let body = case response.body { - wisp.Text(text) -> mist.Bytes(bytes_tree.from_string(text)) - wisp.Bytes(bytes) -> mist.Bytes(bytes) - wisp.File(path:, offset:, limit:) -> mist_send_file(path, offset, limit) - } - response - |> response.set_body(body) -} - fn mist_send_file( path: String, offset: Int, @@ -103,3 +100,60 @@ fn mist_send_file( } } } + +fn mist_websocket_upgrade( + request: HttpRequest(mist.Connection), + upgrade: wisp.WebSocketUpgrade, +) -> HttpResponse(mist.ResponseData) { + let ws = wisp.recover(upgrade) + let #(on_init, on_message, on_close) = websocket.extract_callbacks(ws) + + mist.websocket( + request: request, + on_init: fn(connection) { + let wisp_connection = + websocket.make_connection( + fn(text) { + mist.send_text_frame(connection, text) + |> result.replace_error(websocket.SendFailed) + }, + fn(binary) { + mist.send_binary_frame(connection, binary) + |> result.replace_error(websocket.SendFailed) + }, + fn() { Ok(Nil) }, + ) + + on_init(wisp_connection) + }, + handler: fn(user_state, message, connection) { + let wisp_connection = + websocket.make_connection( + fn(text) { + mist.send_text_frame(connection, text) + |> result.replace_error(websocket.SendFailed) + }, + fn(binary) { + mist.send_binary_frame(connection, binary) + |> result.replace_error(websocket.SendFailed) + }, + fn() { Ok(Nil) }, + ) + + let wisp_message = case message { + mist.Text(text) -> websocket.Text(text) + mist.Binary(binary) -> websocket.Binary(binary) + mist.Closed -> websocket.Closed + mist.Shutdown -> websocket.Shutdown + mist.Custom(inner) -> websocket.Custom(inner) + } + let result = on_message(user_state, wisp_message, wisp_connection) + case result { + websocket.Continue(new_state) -> mist.continue(new_state) + websocket.Stop -> mist.stop() + websocket.StopWithError(reason) -> mist.stop_abnormal(reason) + } + }, + on_close:, + ) +} diff --git a/test/wisp/simulate_test.gleam b/test/wisp/simulate_test.gleam index f7d871f..e5faf29 100644 --- a/test/wisp/simulate_test.gleam +++ b/test/wisp/simulate_test.gleam @@ -1,3 +1,4 @@ +import gleam/erlang/process import gleam/http import gleam/http/response import gleam/json @@ -7,6 +8,7 @@ import gleam/string import simplifile import wisp import wisp/simulate +import wisp/websocket pub fn request_test() { let request = simulate.request(http.Patch, "/wibble/woo") @@ -261,3 +263,72 @@ pub fn multipart_generation_validation_test() { <> "--\r\n" assert body == <> } + +pub fn websocket_handler_test() { + let state_subject = process.new_subject() + let handler = + websocket.new( + fn(_conn) { + let initial_state = "Initial State" + process.send(state_subject, initial_state) + #(initial_state, option.None) + }, + fn(state, message, connection) { + case message { + websocket.Text(text) -> { + let message = "Echo: " <> text + let new_state = state <> " | " <> message + let assert Ok(_) = websocket.send_text(connection, message) + process.send(state_subject, new_state) + websocket.Continue(new_state) + } + websocket.Binary(data) -> { + let new_state = state <> " | " <> "Binary" + let assert Ok(_) = websocket.send_binary(connection, data) + process.send(state_subject, new_state) + websocket.Continue(new_state) + } + websocket.Closed | websocket.Shutdown -> { + websocket.Stop + } + websocket.Custom(_) -> { + let new_state = state <> " | Custom" + process.send(state_subject, new_state) + websocket.Continue(new_state) + } + } + }, + fn(state) { process.send(state_subject, state) }, + ) + + let assert Ok(websocket) = simulate.create_websocket(handler) + let assert Ok("Initial State") = process.receive(state_subject, 1000) + + let assert Ok(websocket) = simulate.send_websocket_text(websocket, "Hello") + let assert [] = simulate.websocket_sent_binary_messages(websocket) + let assert ["Echo: Hello"] = simulate.websocket_sent_text_messages(websocket) + let assert Ok("Initial State | Echo: Hello") = + process.receive(state_subject, 1000) + + let assert Ok(websocket) = + simulate.send_websocket_binary(websocket, <<1, 2, 3>>) + let assert [<<1, 2, 3>>] = simulate.websocket_sent_binary_messages(websocket) + let assert ["Echo: Hello"] = simulate.websocket_sent_text_messages(websocket) + let assert Ok("Initial State | Echo: Hello | Binary") = + process.receive(state_subject, 1000) + + let assert Ok(Nil) = simulate.close_websocket(websocket) + let assert Ok("Initial State | Echo: Hello | Binary") = + process.receive(state_subject, 1000) + + let assert Ok(websocket) = + simulate.send_websocket_binary(websocket, <<4, 5, 6>>) + let assert [<<1, 2, 3>>] = simulate.websocket_sent_binary_messages(websocket) + + let websocket = simulate.reset_websocket(websocket) + let assert Ok("Initial State") = process.receive(state_subject, 1000) + let assert Ok(websocket) = + simulate.send_websocket_binary(websocket, <<6, 7, 8>>) + let assert [<<6, 7, 8>>] = simulate.websocket_sent_binary_messages(websocket) + let assert Ok("Initial State | Binary") = process.receive(state_subject, 1000) +}