require "./client" require "./headers" # NOTE: To use `WebSocket`, you must explicitly import it with `require "http/web_socket"` class HTTP::WebSocket getter? closed = false # :nodoc: def initialize(io : IO, sync_close = true) initialize(Protocol.new(io, sync_close: sync_close)) end # :nodoc: def initialize(@ws : Protocol) @buffer = Bytes.new(4096) @current_message = IO::Memory.new end # Opens a new websocket using the information provided by the URI. This will also handle the handshake # and will raise an exception if the handshake did not complete successfully. This method will also raise # an exception if the URI is missing the host and/or the path. # # Please note that the scheme will only be used to identify if TLS should be used or not. Therefore, schemes # apart from `wss` and `https` will be treated as the default which is `ws`. # # ``` # require "http/web_socket" # # HTTP::WebSocket.new(URI.parse("ws://websocket.example.com/chat")) # Creates a new WebSocket to `websocket.example.com` # HTTP::WebSocket.new(URI.parse("wss://websocket.example.com/chat")) # Creates a new WebSocket with TLS to `websocket.example.com` # HTTP::WebSocket.new(URI.parse("http://websocket.example.com:8080/chat")) # Creates a new WebSocket to `websocket.example.com` on port `8080` # HTTP::WebSocket.new(URI.parse("ws://websocket.example.com/chat"), # Creates a new WebSocket to `websocket.example.com` with an Authorization header # HTTP::Headers{"Authorization" => "Bearer authtoken"}) # HTTP::WebSocket.new( # URI.parse("ws://user:password@websocket.example.com/chat")) # Creates a new WebSocket to `websocket.example.com` with an HTTP basic auth Authorization header # ``` def self.new(uri : URI | String, headers : HTTP::Headers = HTTP::Headers.new) : self new(Protocol.new(uri, headers: headers)) end # Opens a new websocket to the target host. This will also handle the handshake # and will raise an exception if the handshake did not complete successfully. # # ``` # require "http/web_socket" # # HTTP::WebSocket.new("websocket.example.com", "/chat") # Creates a new WebSocket to `websocket.example.com` # HTTP::WebSocket.new("websocket.example.com", "/chat", tls: true) # Creates a new WebSocket with TLS to `ẁebsocket.example.com` # ``` def self.new(host : String, path : String, port : Int32? = nil, tls : HTTP::Client::TLSContext = nil, headers : HTTP::Headers = HTTP::Headers.new) : self new(Protocol.new(host, path, port, tls, headers)) end # Called when a PING frame is received. def on_ping(&@on_ping : String ->) end # Called when a PONG frame is received. # # An unsolicited PONG frame should not be responded to. def on_pong(&@on_pong : String ->) end # Called when a text message is received. def on_message(&@on_message : String ->) : Proc(String, Nil) end # Called when a binary message is received. def on_binary(&@on_binary : Bytes ->) : Proc(Bytes, Nil) end # Called when the connection is closed by the other party. def on_close(&@on_close : CloseCode, String ->) : Proc(HTTP::WebSocket::CloseCode, String, Nil) end protected def check_open raise IO::Error.new "Closed socket" if closed? end # Sends a message payload (message). def send(message) : Nil check_open @ws.send(message) end # Sends a PING frame. Received pings will call `#on_ping`. # # The receiving party must respond with a PONG. def ping(message = nil) check_open @ws.ping(message) end # Sends a PONG frame, which must be in response to a previously received PING frame from `#on_ping`. def pong(message = nil) : Nil check_open @ws.pong(message) end # Stream data as one message with automatically fragmentation handling # with respect to given `frame_size`. # When the io is closed, current data in the buffer is sent in a FIN frame. # The io is closed when the block returns. # # The method accepts a block with an `io` argument. # The io object can call on `IO#write` method. # The `write` method accepts `Bytes` (`Slice(UInt8)`) and sends the data in chunks of *frame_size* bytes. # For further information, see the `HTTP::WebSocket::Protocol::StreamIO` class. # # ``` # # Open websocket connection # ws = HTTP::WebSocket.new("websocket.example.com", "/chat") # # # Open stream # ws.stream(false, frame_size: 4) do |io| # io.write "foo".encode("UTF-8") # Nothing is sent, buffer not full # io.write "bar".encode("UTF-8") # Will send a first frame with "foob" # end # io is closed, a FIN frame with "bar" is sent # ``` def stream(binary = true, frame_size = 1024, &) check_open @ws.stream(binary: binary, frame_size: frame_size) do |io| yield io end end # Sends a close frame, and closes the connection. # The close frame may contain a body (message) that indicates the reason for closing. def close(code : CloseCode | Int? = nil, message = nil) : Nil return if closed? @closed = true @ws.close(code, message) end # Continuously receives messages and calls previously set callbacks until the websocket is closed. # Ping and pong messages are automatically handled. # # ``` # # Open websocket connection # ws = HTTP::WebSocket.new("websocket.example.com", "/chat") # # # Set callback # ws.on_message do |msg| # ws.send "response" # end # # # Start infinite loop # ws.run # ``` def run : Nil loop do begin info = @ws.receive(@buffer) rescue @on_close.try &.call(CloseCode::AbnormalClosure, "") @closed = true break end case info.opcode in .ping? @current_message.write @buffer[0, info.size] if info.final message = @current_message.to_s do_ping(message) @current_message.clear end in .pong? @current_message.write @buffer[0, info.size] if info.final @on_pong.try &.call(@current_message.to_s) @current_message.clear end in .text? @current_message.write @buffer[0, info.size] if info.final @on_message.try &.call(@current_message.to_s) @current_message.clear end in .binary? @current_message.write @buffer[0, info.size] if info.final @on_binary.try &.call(@current_message.to_slice) @current_message.clear end in .close? @current_message.write @buffer[0, info.size] if info.final @current_message.rewind if @current_message.size >= 2 code = @current_message.read_bytes(UInt16, IO::ByteFormat::NetworkEndian).to_i code = CloseCode.new(code) else code = CloseCode::NoStatusReceived end message = @current_message.gets_to_end do_close(code, message) @current_message.clear break end in .continuation? # TODO: (asterite) I think this is good, but this case wasn't originally handled end end end private def do_close(code, message) @on_close.try &.call(code, message) close end private def do_ping(message) @on_ping.try &.call(message) pong(message) unless closed? end end require "./web_socket/*"