Current section

Files

Jump to
stratus src stratus.gleam
Raw

src/stratus.gleam

import gleam/bit_array
import gleam/bytes_builder.{type BytesBuilder}
import gleam/crypto
import gleam/erlang.{rescue}
import gleam/erlang/charlist
import gleam/erlang/process.{type Selector, type Subject}
import gleam/function
import gleam/http.{Http, Https}
import gleam/http/request.{type Request}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import gleam/result
import gleam/string
import gleam/uri
import gramps.{
type DataFrame, BinaryFrame, CloseFrame, Continuation, Control,
Data as DataFrame, PingFrame, PongFrame, TextFrame,
}
import logging
import stratus/internal/socket.{
type Socket, type SocketMessage, type SocketReason, Cacerts, Once, Pull,
Receive,
}
import stratus/internal/ssl
import stratus/internal/transport.{type Transport, Ssl, Tcp}
/// This holds some information needed to communicate with the WebSocket.
pub opaque type Connection {
Connection(socket: Socket, transport: Transport)
}
fn from_socket_message(msg: SocketMessage) -> InternalMessage(user_message) {
case msg {
socket.Data(bits) -> Data(bits)
socket.Closed -> Closed
socket.Err(reason) -> Err(reason)
}
}
/// These are the messages emitted or received by the underlying process. You
/// should only need to interact with `Message` below.
pub opaque type InternalMessage(user_message) {
Started
UserMessage(user_message)
Err(SocketReason)
Data(BitArray)
Closed
Shutdown
}
/// This is the type of message your handler might receive.
pub type Message(user_message) {
Text(String)
Binary(BitArray)
User(user_message)
}
pub opaque type Builder(state, user_message) {
Builder(
request: Request(String),
connect_timeout: Int,
init: fn() -> #(state, Option(Selector(user_message))),
loop: fn(Message(user_message), state, Connection) ->
actor.Next(user_message, state),
on_close: fn(state) -> Nil,
)
}
// and `on_close`.
/// This creates a builder to set up a WebSocket actor. This will use default
/// values for the connection initialization timeout, and provide an empty
/// function to be called when the server closes the connection. If you want to
/// customize either of those, see the helper functions `with_connect_timeout`
pub fn websocket(
request req: Request(String),
init init: fn() -> #(state, Option(Selector(user_message))),
loop loop: fn(Message(user_message), state, Connection) ->
actor.Next(user_message, state),
) -> Builder(state, user_message) {
Builder(
request: req,
connect_timeout: 5000,
init: init,
loop: loop,
on_close: fn(_state) { Nil },
)
}
/// This sets the maximum amount of time you are willing to wait for both
/// connecting to the server and receiving the upgrade response. This means
/// that it may take up to `timeout * 2` to begin sending or receiving messages.
/// This value defaults to 5 seconds.
pub fn with_connect_timeout(
builder: Builder(state, user_message),
timeout: Int,
) -> Builder(state, user_message) {
Builder(..builder, connect_timeout: timeout)
}
/// You can provide a function to be called when the connection is closed. This
/// function receives the last value for the state of the WebSocket.
///
/// NOTE: If you manually call `stratus.close`, this function will not be
/// called. I'm unsure right now if this is a bug or working as intended. But
/// you will be in the loop with the state value handy.
pub fn on_close(
builder: Builder(state, user_message),
on_close: fn(state) -> Nil,
) -> Builder(state, user_message) {
Builder(..builder, on_close: on_close)
}
type State(state, user_message) {
State(
buffer: BitArray,
incomplete: Option(gramps.Frame),
self: Subject(InternalMessage(user_message)),
socket: Option(Socket),
user_state: state,
)
}
/// This opens the WebSocket connection with the provided `Builder`. It makes
/// some assumptions about the request if you do not provide it. It will use
/// ports 80 or 443 for `ws` or `wss` respectively.
///
/// It will open the connection and perform the WebSocket handshake. If this
/// fails, the actor will fail to start with the given reason as a string value.
///
/// After that, received messages will be passed to your loop, and you can use
/// the helper functions to send messages to the server. The `close` method will
/// send a close frame and end the connection.
pub fn initialize(
builder: Builder(state, user_message),
) -> Result(Subject(InternalMessage(user_message)), actor.StartError) {
let transport = case builder.request.scheme {
Https -> Ssl
_ -> Tcp
}
actor.start_spec(
actor.Spec(
init: fn() {
let subj = process.new_subject()
let started_selector =
process.selecting(process.new_selector(), subj, function.identity)
logging.log(logging.Debug, "Calling user initializer")
let #(user_state, user_selector) = builder.init()
let selector = case user_selector {
Some(selector) -> {
selector
|> process.map_selector(UserMessage)
|> process.merge_selector(started_selector)
|> process.merge_selector(process.map_selector(
socket.selector(),
from_socket_message,
))
}
_ ->
started_selector
|> process.merge_selector(process.map_selector(
socket.selector(),
from_socket_message,
))
}
process.send(subj, Started)
actor.Ready(
State(
buffer: <<>>,
incomplete: None,
self: subj,
socket: None,
user_state: user_state,
),
selector,
)
},
init_timeout: 1000,
loop: fn(msg, state) {
case msg {
Started -> {
logging.log(
logging.Debug,
"Attempting handshake to "
<> uri.to_string(request.to_uri(builder.request)),
)
perform_handshake(
builder.request,
transport,
builder.connect_timeout,
)
|> result.then(fn(pair) {
logging.log(logging.Debug, "Handshake successful")
transport.set_opts(
transport,
pair.0,
socket.convert_options([Receive(Once)]),
)
|> result.replace(pair)
|> result.map_error(Sock)
})
|> result.map(fn(pair) {
let #(socket, buffer) = pair
logging.log(
logging.Debug,
"WebSocket process ready to start receiving",
)
let _ = case buffer {
<<>> -> Nil
data -> process.send(state.self, Data(data))
}
actor.continue(
State(..state, socket: Some(socket), buffer: buffer),
)
})
|> result.map_error(fn(err) {
let msg = "Failed to connect to server: " <> string.inspect(err)
logging.log(logging.Error, msg)
actor.Stop(process.Abnormal(msg))
})
|> result.unwrap_both
}
UserMessage(user_message) -> {
let assert Some(socket) = state.socket
let conn = Connection(socket, transport)
let res =
rescue(fn() {
builder.loop(User(user_message), state.user_state, conn)
})
case res {
// TODO: de-dupe this
Ok(actor.Continue(user_state, user_selector)) -> {
let new_state = State(..state, user_state: user_state)
case user_selector {
Some(user_selector) -> {
let selector =
user_selector
|> process.map_selector(UserMessage)
|> process.merge_selector(process.map_selector(
socket.selector(),
from_socket_message,
))
actor.Continue(new_state, Some(selector))
}
_ -> actor.continue(new_state)
}
}
Ok(actor.Stop(reason)) -> actor.Stop(reason)
Error(reason) -> {
logging.log(
logging.Error,
"Caught error in user handler: " <> string.inspect(reason),
)
actor.continue(state)
}
}
}
Err(reason) -> {
actor.Stop(process.Abnormal(string.inspect(reason)))
}
Data(bits) -> {
let assert Some(socket) = state.socket
let conn = Connection(socket, transport)
let #(frames, rest) =
gramps.get_messages(bit_array.append(state.buffer, bits), [])
let frames = gramps.aggregate_frames(frames, state.incomplete, [])
case frames {
Error(Nil) -> actor.continue(state)
Ok(frames) -> {
list.fold_until(frames, actor.continue(state), fn(acc, frame) {
let assert actor.Continue(prev_state, _selector) = acc
case
handle_frame(builder, transport, prev_state, conn, frame)
{
actor.Continue(..) as next -> list.Continue(next)
actor.Stop(..) as err -> list.Stop(err)
}
})
}
}
|> fn(next) {
case next {
actor.Stop(..) as stop -> stop
actor.Continue(state, selector) -> {
let assert Ok(_) =
transport.set_opts(
transport,
socket,
socket.convert_options([Receive(Once)]),
)
actor.Continue(State(..state, buffer: rest), selector)
}
}
}
}
Closed -> {
logging.log(logging.Debug, "Received closed frame")
builder.on_close(state.user_state)
actor.Stop(process.Normal)
}
// TODO: handle shutdown better?
Shutdown -> {
logging.log(logging.Debug, "Received shutdown messag")
actor.Stop(process.Normal)
}
}
},
),
)
}
fn handle_frame(
builder: Builder(user_state, user_message),
transport: Transport,
state: State(user_state, user_message),
conn: Connection,
frame: gramps.Frame,
) -> actor.Next(InternalMessage(user_message), State(user_state, user_message)) {
let assert Some(socket) = state.socket
case frame {
DataFrame(TextFrame(payload: data, ..)) -> {
let assert Ok(str) = bit_array.to_string(data)
let res = rescue(fn() { builder.loop(Text(str), state.user_state, conn) })
case res {
// TODO: de-dupe this
Ok(actor.Continue(user_state, user_selector)) -> {
let new_state = State(..state, user_state: user_state)
case user_selector {
Some(user_selector) -> {
let selector =
user_selector
|> process.map_selector(UserMessage)
|> process.merge_selector(process.map_selector(
socket.selector(),
from_socket_message,
))
actor.Continue(new_state, Some(selector))
}
_ -> actor.continue(new_state)
}
}
Ok(actor.Stop(reason)) -> actor.Stop(reason)
Error(reason) -> {
logging.log(
logging.Error,
"Caught error in user handler: " <> string.inspect(reason),
)
actor.continue(state)
}
}
}
DataFrame(BinaryFrame(payload: data, ..)) -> {
let res =
rescue(fn() { builder.loop(Binary(data), state.user_state, conn) })
case res {
// TODO: de-dupe this
Ok(actor.Continue(user_state, user_selector)) -> {
let new_state = State(..state, user_state: user_state)
case user_selector {
Some(user_selector) -> {
let selector =
user_selector
|> process.map_selector(UserMessage)
|> process.merge_selector(process.map_selector(
socket.selector(),
from_socket_message,
))
actor.Continue(new_state, Some(selector))
}
_ -> actor.continue(new_state)
}
}
Ok(actor.Stop(reason)) -> actor.Stop(reason)
Error(reason) -> {
logging.log(
logging.Error,
"Caught error in user handler: " <> string.inspect(reason),
)
actor.continue(state)
}
}
}
Control(PingFrame(payload, payload_length)) -> {
let frame =
gramps.frame_to_bytes_builder(
gramps.Control(gramps.PongFrame(payload, payload_length)),
Some(<<0:unit(8)-size(4)>>),
)
let _ = transport.send(conn.transport, conn.socket, frame)
actor.continue(state)
}
Control(PongFrame(..)) -> {
actor.continue(state)
}
Control(CloseFrame(length, payload)) -> {
let size = length - 2
case payload {
<<_reason:int-size(2)-unit(8), message:bytes-size(size)>> -> {
let msg = "WebSocket closing: " <> string.inspect(message)
logging.log(logging.Debug, msg)
}
_ -> Nil
}
builder.on_close(state.user_state)
actor.Stop(process.Normal)
}
Continuation(..) -> {
actor.continue(state)
}
}
}
/// Since the actor receives the raw data from the WebSocket, it needs a less
/// ergonomic message type. You probably don't want (read: shouldn't be able to)
/// send `Data(bits)` to the process, so that message type is opaque.
///
/// To get around that, this helper method lets you provide your custom message
/// type to the actor.
///
/// This is likely what you want if you want to be able to tell the actor to
/// send data to the server. Your message type would be -- in plain language --
/// "this thing happened", and your loop would then send whatever relevant data
/// corresponds to that event.
pub fn send_message(
subject: Subject(InternalMessage(user_message)),
message: user_message,
) -> Nil {
process.send(subject, UserMessage(message))
}
/// From within the actor loop, this is how you send a WebSocket text frame.
/// This must be valid UTF-8, so it is a `String`.
pub fn send_text_message(
conn: Connection,
msg: String,
) -> Result(Nil, SocketReason) {
let frame = gramps.to_text_frame(msg, True)
transport.send(conn.transport, conn.socket, frame)
}
/// From within the actor loop, this is how you send a WebSocket text frame.
pub fn send_binary_message(
conn: Connection,
msg: BitArray,
) -> Result(Nil, SocketReason) {
let frame = gramps.to_binary_frame(msg, True)
transport.send(conn.transport, conn.socket, frame)
}
/// Send a ping frame with some data.
pub fn send_ping(conn: Connection, data: BitArray) -> Result(Nil, SocketReason) {
let size = bit_array.byte_size(data)
let mask = case size {
0 -> <<0:size(4)>>
_n -> crypto.strong_random_bytes(4)
}
let frame =
gramps.frame_to_bytes_builder(
gramps.Control(gramps.PingFrame(size, data)),
Some(mask),
)
transport.send(conn.transport, conn.socket, frame)
}
/// This will close the WebSocket connection.
pub fn close(conn: Connection) -> Result(Nil, SocketReason) {
let frame =
gramps.frame_to_bytes_builder(
gramps.Control(gramps.CloseFrame(0, <<>>)),
Some(crypto.strong_random_bytes(4)),
)
transport.send(conn.transport, conn.socket, frame)
}
fn make_upgrade(req: Request(String), origin: String) -> BytesBuilder {
let user_headers =
req.headers
|> list.filter(fn(pair) {
let assert #(key, _value) = pair
key != "host"
&& key != "upgrade"
&& key != "connection"
&& key != "sec-websocket-key"
&& key != "sec-websocket-version"
&& key != "origin"
})
|> list.map(fn(pair) {
let assert #(key, value) = pair
key <> ": " <> value
})
|> string.join("\r\n")
let path = case req.path {
"" -> "/"
path -> path
}
let query =
req
|> request.get_query
|> result.map(uri.query_to_string)
|> fn(str) {
case str {
Ok("") -> ""
Ok(str) -> "?" <> str
_ -> ""
}
}
bytes_builder.new()
|> bytes_builder.append_string("GET " <> path <> query <> " HTTP/1.1\r\n")
|> bytes_builder.append_string("Host: " <> req.host <> "\r\n")
|> bytes_builder.append_string("Upgrade: websocket\r\n")
|> bytes_builder.append_string("Connection: Upgrade\r\n")
|> bytes_builder.append_string(
"Sec-WebSocket-Key: " <> gramps.websocket_client_key <> "\r\n",
)
|> bytes_builder.append_string("Sec-WebSocket-Version: 13\r\n")
|> bytes_builder.append_string("Origin: " <> origin <> "\r\n")
|> bytes_builder.append_string(user_headers)
|> bytes_builder.append_string("\r\n")
}
type HandshakeError {
Sock(SocketReason)
Protocol(BitArray)
}
fn perform_handshake(
req: Request(String),
transport: Transport,
timeout: Int,
) -> Result(#(Socket, BitArray), HandshakeError) {
let certs = case req.scheme {
Https -> {
let assert Ok(_ok) = ssl.start()
[Cacerts(socket.get_certs()), socket.get_custom_matcher()]
}
Http -> []
}
let opts =
socket.convert_options(
list.append(socket.default_options, [Receive(Pull), ..certs]),
)
let port =
option.lazy_unwrap(req.port, fn() {
case transport {
Ssl -> 443
Tcp -> 80
}
})
let origin = case req.scheme, port {
Https, 443 -> "https://" <> req.host
Http, 80 -> "http://" <> req.host
Https, _ -> "https://" <> req.host <> ":" <> int.to_string(port)
_, _ -> "http://" <> req.host <> ":" <> int.to_string(port)
}
logging.log(
logging.Debug,
"Making request to " <> req.host <> " at " <> int.to_string(port),
)
use socket <- result.try(result.map_error(
transport.connect(
transport,
charlist.from_string(req.host),
port,
opts,
timeout,
),
Sock,
))
use _nil <- result.try(result.map_error(
transport.send(transport, socket, make_upgrade(req, origin)),
Sock,
))
logging.log(
logging.Debug,
"Sent upgrade request, waiting " <> int.to_string(timeout),
)
use resp <- result.try(result.map_error(
transport.receive_timeout(transport, socket, 0, timeout),
Sock,
))
case gramps.read_response(resp) {
Ok(#(_resp, rest)) -> {
Ok(#(socket, rest))
}
Error(_reason) -> Error(Protocol(resp))
}
}