WebSocket

A WebSocket (RFC 6455) is a full-duplex connection that carries messages rather than bytes and is opened by an HTTP request, and this module opens WebSockets as a client and answers them as a server.

A client connects to a ws:// or wss:// URL. A server receives a client's opening request as an Upgrade, either on a Socket.Connection it accepted (receiveUpgrade) or from an HTTP server (Http.Server.upgradeRequest), and answers it with accept or reject. Either way an open connection is a WebSocket.

WebSocket.connect (WebSocket.defaultConnectOptions "wss://example.com/chat")
    |> Task.andThen
        (\ws ->
            Stream.write (WebSocket.Text "hello") (WebSocket.writable ws)
                |> Task.mapError (\_ -> ...)
        )

Several modules define functions such as close, accept and readable, so import this module qualified:

import WebSocket

Messages. A message is text or binary data (Message). A connection is opened in one of two modes, and its type says which. In the whole mode (connect, accept) every message arrives in one piece, on readable or through onMessage, and a message larger than the maxMessageSize of its options fails the connection. In the streamed mode (connectStreamed, acceptStreamed) every message arrives as a stream of its own (StreamedMessage), whatever its size. A streamed message must be read to its end (Closed) or cancelled, because the connection delivers nothing after it until then. In both modes each value written to writable is one message, and sendText and sendBinary send one message whose content is read from a stream. While 1 MiB of received messages, or 1024 of them, wait unread, the connection stops reading from the network.

Ending. A connection normally ends with a closing handshake: one end sends a Close frame carrying a CloseCode and a reason, the other answers with its own, and the connection is then gone. This end can also fail a connection, for example on a protocol error, an invalid or oversized message, or a silent peer: it sends a Close frame with a code saying why and closes the connection without waiting for an answer. And the network connection can simply be lost. awaitClose and onClose report how a connection ended, as a CloseInfo.

The tasks of this module that can fail fail with this module's Error, which holds the failure of whichever layer failed: the connection underneath, TLS for wss://, or the WebSocket itself. Once a connection is open, a failure of the connection arrives on its streams instead, as the Stream module describes: a read of readable fails with Stream.Failed, and fromStreamFailure reads that failure back as an Error, such as ProtocolViolation when the peer broke the protocol.

Liveness. By default this end checks that the peer is still there: when nothing has arrived for 30 seconds it sends a ping, and when still nothing at all has arrived 30 seconds later it fails the connection (Heartbeat). Pings from the peer are answered until this end starts closing.

Compression. permessage-deflate (RFC 7692) compresses the content of each message. By default a client offers it and a server accepts the offer, both without context takeover: each message is compressed on its own, without referring back to earlier ones. With contextTakeover = True in the compression options of both sides a message may refer back into earlier messages, which compresses a run of similar messages better. A message written to writable that is shorter than the compression options' threshold bytes is sent uncompressed, while a non-empty message sent with sendText or sendBinary is compressed whatever its size. compression tells what the handshake agreed. Do not send secrets and data an attacker controls on one compressed connection: the compressed sizes can reveal the secrets (the CRIME and BREACH attacks).

A connection keeps the program running while an onMessage subscription exists and the connection is open, while an onClose subscription waits for its CloseInfo, while an awaitClose waits, during a closing handshake this end started, and while an operation on it, such as a read, a write or a ping, is waiting.

Client limits. A client opens a new connection for every connect, even to a host it is already connecting to, although RFC 6455 asks a client to wait for one opening handshake with a host to finish before it starts another. Redirects are not followed (a redirect status fails the handshake), and no proxy is used.

On native Windows, WebSockets are not supported: connecting, receiving an opening request and accepting one fail with SocketError of kind Socket.NotSupported.

Connections

type alias WebSocket mode =
    WebSocket mode

An open WebSocket connection, in the mode mode: Whole or Streamed.

A value is made only by connect, connectStreamed, accept and acceptStreamed, and its mode is the mode that function opened the connection in, so a function that takes only one mode can rely on it. The value stays usable after the connection has ended.

type Whole

The mode of a connection whose messages arrive whole, each as one Message. It has no values; it is only used as the mode of a WebSocket.

type Streamed

The mode of a connection whose messages arrive as streams, each a StreamedMessage. It has no values; it is only used as the mode of a WebSocket.

type Message
    = Text String
    | Binary Bytes

A message: Text carries text and Binary carries bytes. Both kinds can be sent and received on one connection, in any order.

A received Text has been checked to be valid UTF-8: a text message that is not fails the connection with InvalidData. On the native backend a lone surrogate in a sent Text goes out as bytes that are not valid UTF-8; the JavaScript backend sends U+FFFD in its place.

type StreamedMessage
    = StreamedText (Readable String)
    | StreamedBinary (Readable Bytes)

A message received in the streamed mode, whose content arrives as a stream, chunk by chunk, as the peer sends it, decompressed when the message was compressed.

StreamedText carries a stream of text whose chunks always end on a whole character; text that is not valid UTF-8 fails the connection with InvalidData. StreamedBinary carries a stream of bytes.

The next message is delivered only once this one's stream has been read to Closed, or cancelled with Stream.cancelReadable, which drops the rest of the message. When the connection ends or fails before the message is complete, what had arrived can still be read, and then the stream fails with Stream.Failed, which fromStreamFailure reads as the Error that ended the connection.

readable : WebSocket Whole -> Readable Message

Returns the stream of the messages the peer sends, in order.

Once the peer's Close frame has arrived, reads fail with Closed after the messages received before it. When the connection fails or is lost instead, reads fail with Stream.Failed, again after the messages received before it. fromStreamFailure reads the failure as an Error: ProtocolViolation when the peer broke the protocol, MessageTooLarge for a message over maxMessageSize, HeartbeatTimedOut when the heartbeat found the peer silent, and SocketError when the connection underneath failed or was closed. awaitClose gives the close code and reason.

While the connection has an onMessage subscription, messages go to the subscription instead, and a read fails with Locked.

readableStreamed : WebSocket Streamed -> Readable StreamedMessage

Returns the stream of the messages of a streamed connection, each a StreamedMessage. It ends and fails as readable does.

writable : WebSocket mode -> Writable Message

Returns the stream that sends messages: each value written is one message, sent after the messages written or streamed before it.

Closing it with Stream.closeWritable starts the closing handshake with Normal and no reason, as close does. Cancelling it with Stream.cancelWritable fails the connection with InternalError. Once the connection is closing or has ended, writes fail with Stream.Closed.

sendText : Readable String -> WebSocket mode -> Task Error ()

Sends one text message whose content is read from source as it arrives, so that a message too large to hold in memory can be sent. The task succeeds once source has ended and the message's last frame has been written.

The message takes its place among the outgoing messages when the task starts: messages written to writable, or sent with sendText or sendBinary, after that go out after it, while pings and pongs are not held up. Each chunk of source is sent as one or more pieces of the message, compressed when the connection uses compression. An empty source sends an empty message.

If source fails after part of the message has gone out, the message cannot be completed and the connection fails with InternalError; if nothing of it has gone out yet, nothing is sent. Either way the task fails with SocketError of kind Socket.Cancelled. It fails with SocketError of kind Socket.Busy when source is locked, and of kind Socket.Cancelled when the connection is closing or has ended, or ends before the message is complete.

sendBinary : Readable Bytes -> WebSocket mode -> Task Error ()

Sends one binary message whose content is read from source as it arrives, exactly as sendText does for text.

protocol : WebSocket mode -> Maybe String

Returns the subprotocol the handshake agreed on (Sec-WebSocket-Protocol), or Nothing when it agreed none.

compression : WebSocket mode -> Maybe Negotiated

Returns the permessage-deflate settings the handshake agreed on, or Nothing when it agreed none.

A Just does not mean that every message is compressed: a message written to writable that is shorter than the threshold is still sent uncompressed, and what the peer compresses is up to the peer.

type alias Negotiated =
    { serverNoContextTakeover : Bool
    , clientNoContextTakeover : Bool
    , serverMaxWindowBits : Int
    , clientMaxWindowBits : Int
    }

The permessage-deflate settings the two ends of a connection have agreed on.

serverNoContextTakeover and clientNoContextTakeover are True when that end, the server or the client, compresses every message on its own. serverMaxWindowBits and clientMaxWindowBits are the largest window that end compresses with, in window bits (as ClientCompression explains them); in the settings compression returns, they are from 8 to 15.

localEndpoint : WebSocket mode -> Endpoint

Returns this end of the connection.

remoteEndpoint : WebSocket mode -> Endpoint

Returns the peer's end of the connection.

Events and closing

onMessage : WebSocket Whole -> (Message -> msg) -> Sub msg

Creates a subscription to the messages of a connection.

While a connection has subscriptions, every message goes to all of them, in subscription order, and reads of readable fail with Locked. Messages that arrive while it has none wait on the readable. A subscription does not start while a Stream.read of the readable is waiting.

While the connection is open, an onMessage subscription keeps the program running.

onClose : WebSocket mode -> (CloseInfo -> msg) -> Sub msg

Creates a subscription to the end of a connection. Its CloseInfo is delivered once, to every onClose subscription the connection has at that moment. A subscription made after it was delivered receives nothing.

Until its CloseInfo has been delivered, an onClose subscription keeps the program running.

ping : WebSocket mode -> Task Error Int

Sends a ping and waits for its pong, giving the round trip time in milliseconds.

Fails with SocketError of kind Socket.Cancelled when the connection is closing or has ended, or ends before the pong arrives, and of kind Socket.TimedOut when no pong arrives within the heartbeat's timeout (30 seconds when heartbeat is Nothing or its timeout is 0 or less). While the connection has stopped reading because messages wait unread, that wait is suspended, and it starts again when reading resumes. A pong also answers every earlier ping still waiting.

close : CloseCode -> String -> WebSocket mode -> Task Error ()

Starts the closing handshake with code and reason: the messages already written or being streamed are sent first, then a Close frame. On the JavaScript backend the reason is cut to its longest start that is at most 123 bytes of UTF-8; on the native backend a character outside the Basic Multilingual Plane is counted as 6 bytes, so the reason can be cut shorter, and the cut can fall inside such a character. The task succeeds once the Close frame has been written. The connection ends when the peer's Close frame arrives, or 30 seconds after this end started closing if it does not, which is reported as Abnormal.

From then on, messages that arrive are dropped (a streamed message being received fails with Stream.Failed), and writes to writable fail with Stream.Closed.

Fails with SocketError of kind Socket.InvalidInput for a code that cannot be sent: only 1000 to 1003, 1007 to 1014 and 3000 to 4999 can, so NoStatus, Abnormal, and Other with a code outside those ranges cannot. Closing a connection that is closing or has ended, with a code that can be sent, succeeds and sends nothing more.

awaitClose : WebSocket mode -> Task x CloseInfo

Waits until the connection has ended and returns how it ended. Once it has ended, it returns at once. On the native backend, a connection that ended before more than 4096 other connections ended may be reported as Abnormal, with an empty reason and clean False, whatever its real end.

A waiting awaitClose keeps the program running.

type CloseCode
    = Normal
    | GoingAway
    | ProtocolError
    | UnsupportedData
    | NoStatus
    | Abnormal
    | InvalidData
    | PolicyViolation
    | MessageTooBig
    | MandatoryExtension
    | InternalError
    | Other Int

The code a Close frame carries to say why a connection is closing, from the codes RFC 6455 defines.

Normal, GoingAway, ProtocolError and UnsupportedData are the codes 1000 to 1003, and InvalidData, PolicyViolation, MessageTooBig, MandatoryExtension and InternalError the codes 1007 to 1011.

NoStatus (1005) and Abnormal (1006) are never sent; they describe how a connection ended. NoStatus is reported when the peer's Close frame carried no code. Abnormal is reported when, for example, the network connection was lost, the peer did not answer a Close frame in time, or the heartbeat found the peer silent.

Other carries any other code, such as an application's own code from 4000 to 4999. A received code that has a constructor of its own is always given that constructor, never Other.

type alias CloseInfo =
    { code : CloseCode, reason : String, clean : Bool }

How a connection ended.

When the peer's Close frame arrived, code and reason are the ones it carried and clean is True, whichever end started closing. When this end failed the connection, they are the code and reason of the failure, and clean is False; a heartbeat that found the peer silent is the exception, reported as Abnormal with an empty reason. When the connection ended in any other way, code is Abnormal, reason is empty and clean is False.

type alias Heartbeat =
    { interval : Int, timeout : Int }

The liveness check of a connection, both times in milliseconds.

When nothing has arrived from the peer for interval, a ping is sent. If nothing at all arrives within a further timeout, the connection is failed: a Close frame with GoingAway is sent, the connection is closed, its end is reported as Abnormal, and reads fail with Stream.Failed, which fromStreamFailure reads as HeartbeatTimedOut. Anything received counts, not only the pong. While the connection has stopped reading because messages wait unread, the check is suspended.

An interval of 0 or less turns the check off, as heartbeat = Nothing does, while a timeout of 0 or less means 30 seconds. Both are plain Ints, so the compiler accepts any value.

Clients

type alias ConnectOptions =
    { url : String
    , headers : List ( String, String )
    , protocols : List String
    , verification : Verification
    , timeout : Maybe Int
    , maxMessageSize : Int
    , heartbeat : Maybe Heartbeat
    , compression : Maybe ClientCompression
    , http2 : Bool
    }

How a client connects.

  • url: ws://host[:port]/path?query, or wss://... for a connection over TLS. An IPv6 address is written in brackets, and a zone in it may be written %25, as in ws://[fe80::1%25eth0]/. A fragment (#) or user information makes the URL invalid.
  • headers: extra request headers, such as Authorization. A name must be an HTTP token and a value may not hold a carriage return, line feed or NUL. Host, Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version, Sec-WebSocket-Accept, Sec-WebSocket-Protocol, Sec-WebSocket-Extensions, Content-Length and Transfer-Encoding are refused, in any case.
  • protocols: the subprotocols to offer, in order; each must be an HTTP token and be listed once. protocol tells which one the server chose.
  • verification: which server certificates a wss connection accepts.
  • timeout: the time allowed, in milliseconds, for connecting, TLS and the HTTP exchange together; the name lookup before them is not limited. Nothing, or a value of 0 or less, is no limit.
  • maxMessageSize: the largest message accepted, in bytes after decompression; a larger one fails the connection with MessageTooBig. A value of 0 or less accepts only empty messages. It does not apply to a streamed connection.
  • heartbeat: the Heartbeat, or Nothing for none.
  • compression: the permessage-deflate offer, or Nothing to offer none.
  • http2: for a wss URL, try a WebSocket over HTTP/2 (RFC 8441) first; a ws URL always uses HTTP/1.1. The TLS handshake then offers h2. When the server chooses it and its settings allow WebSockets (SETTINGS_ENABLE_CONNECT_PROTOCOL), the WebSocket runs on one stream of a new HTTP/2 connection. A server that chooses HTTP/1.1 gets the HTTP/1.1 handshake on the same connection, and an HTTP/2 server whose settings do not allow WebSockets is left and dialed again over HTTP/1.1, within the same timeout.
type alias ClientCompression =
    { clientMaxWindowBits : Maybe (Maybe Int)
    , serverMaxWindowBits : Maybe Int
    , contextTakeover : Bool
    , threshold : Int
    }

What a client offers for permessage-deflate.

Window bits is the size of a compressor's window, the distance back it may refer, as a power of two: from 8 (256 bytes) to 15 (32 KiB). A window size outside 8 to 15 is clamped into that range.

  • clientMaxWindowBits: Just (Just n) limits the client's own window to n window bits. Just Nothing sends the parameter without a value, which lets the server choose a limit for the client's window. Nothing leaves the parameter out, and a server that answers with a limit for the client's window then fails the handshake.
  • serverMaxWindowBits: Just n asks the server to compress with at most n window bits; a server that answers with more fails the handshake.
  • contextTakeover: False offers client_no_context_takeover and server_no_context_takeover, and the client then compresses every message on its own. True offers neither, and the server may still answer with them.
  • threshold: a message written to writable that is shorter than this many bytes is sent uncompressed.
defaultConnectOptions : String -> ConnectOptions

Returns the options for connecting to url: no extra headers or subprotocols, Socket.Tls.SystemCertificates, a 30 second timeout, messages up to 16 MiB, the default heartbeat (a ping after 30 seconds without anything received, then 30 seconds for anything to arrive), permessage-deflate offered without context takeover, with client_max_window_bits sent without a value and a threshold of 64 bytes, and http2 = False.

connect : ConnectOptions -> Task Error (WebSocket Whole)

Opens a connection in the whole mode to options.url.

A host name is looked up first; an address written in the URL is used as it is. The addresses are tried in order until a connection is made, and the opening handshake then runs on that connection. When no connection can be made, the error of the last address tried is reported.

Fails with:

connectStreamed : ConnectOptions -> Task Error (WebSocket Streamed)

Opens a connection in the streamed mode; everything else is as for connect.

Servers

type alias Upgrade =
    Upgrade

A client's opening request that a server has received.

One is obtained from receiveUpgrade or Http.Server.upgradeRequest, read with the upgrade functions below, and answered with accept, acceptStreamed or reject.

receiveUpgrade : Connection -> Task Error Upgrade

Reads a client's opening request from connection, a connection a server accepted, such as one from a Socket.Tcp.listen or Socket.Tls.listen listener.

The connection is taken over: from then on its streams fail with Stream.Failed, which Socket.fromStreamFailure reads as kind Socket.ConnectionTakenOver, and bytes the client sent right behind its request are kept for the WebSocket. The request is only read as HTTP here; whether it is a valid opening request is checked by accept.

Fails with SocketError of kind Socket.Busy while a read or write is waiting on the connection's streams or when the connection has already been taken over, and of kind Socket.Cancelled when the connection, or its writable, has been closed; these leave the connection as it is. Otherwise it fails with SocketError of kind Socket.TimedOut when the request has not arrived within 30 seconds; with HandshakeFailed when what the client sends is not an HTTP request or has a head over 64 KiB (both answered with 400), or the client closes the connection before sending a request; and with SocketError holding the connection's own error when reading fails. After any of these the connection is closed.

upgradeTarget : Upgrade -> String

Returns the request target, the path and the query, for example "/chat?room=1", as the client sent it.

upgradeHeaders : Upgrade -> List ( String, String )

Returns the request headers in the order they were received, with lower-case names and one entry for each occurrence of a repeated header.

upgradeProtocols : Upgrade -> List String

Returns the subprotocols the client offers, in the order it lists them: the comma-separated elements of every Sec-WebSocket-Protocol header, trimmed, with empty ones dropped.

upgradeOrigin : Upgrade -> Maybe String

Returns the value of the first Origin header, or Nothing when the request has none.

upgradeRemote : Upgrade -> Endpoint

Returns the client's end of the connection.

type alias AcceptOptions =
    { protocol : Maybe String
    , headers : List ( String, String )
    , maxMessageSize : Int
    , heartbeat : Maybe Heartbeat
    , compression : Maybe ServerCompression
    }

How a server accepts an opening request.

  • protocol: the subprotocol to use, which must be one the client offers (upgradeProtocols), or Nothing for none.
  • headers: extra response headers, refused by the same rules as the headers of ConnectOptions.
  • maxMessageSize, heartbeat: as in ConnectOptions.
  • compression: the server's permessage-deflate policy, or Nothing to decline every offer.
type alias ServerCompression =
    { maxWindowBits : Int, contextTakeover : Bool, threshold : Int }

What a server agrees to for permessage-deflate. The first of the client's offers that is valid is accepted under it.

  • maxWindowBits: the largest window the server compresses with, in window bits (as ClientCompression explains them, clamped to 8 to 15), or the client's limit when that is smaller.
  • contextTakeover: False answers with server_no_context_takeover and client_no_context_takeover, which ask both ends to compress every message on its own. True keeps context takeover for each end whose no-context-takeover parameter the client did not send.
  • threshold: a message written to writable that is shorter than this many bytes is sent uncompressed.
defaultAcceptOptions : AcceptOptions

The default options for accepting: no subprotocol, no extra headers, messages up to 16 MiB, the default heartbeat (as in defaultConnectOptions), and permessage-deflate accepted without context takeover, with a 15-bit window and a threshold of 64 bytes.

accept : AcceptOptions -> Upgrade -> Task Error (WebSocket Whole)

Accepts an opening request in the whole mode: checks it, agrees on the subprotocol and the compression, answers it (101 over HTTP/1.1, 200 over HTTP/2), and opens the connection.

The request must be a valid opening request. Over HTTP/1.1 it is a GET of HTTP/1.1 or later with a Host header, Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13 and a valid Sec-WebSocket-Key; over HTTP/2 an extended CONNECT with Sec-WebSocket-Version: 13. Its subprotocols must be HTTP tokens and its extensions well formed. The checks are made in that order, and the first that fails decides the answer: 426, with Sec-WebSocket-Version: 13, for the version, and 400 for any other. The task then fails with HandshakeFailed.

A protocol the client did not offer, or a refused header in headers, fails with SocketError of kind Socket.InvalidInput and leaves the request unanswered. Accepting an Upgrade that has already been answered fails.

acceptStreamed :
    AcceptOptions
    -> Upgrade
    -> Task Error (WebSocket Streamed)

Accepts an opening request in the streamed mode; everything else is as for accept.

reject :
    Int
    -> List ( String, String )
    -> String
    -> Upgrade
    -> Task x ()

Refuses an opening request with an HTTP status, extra headers and a body, for example reject 403 [] "Forbidden", and succeeds once the answer has been written. Over HTTP/1.1 the connection is closed afterwards; over HTTP/2 the request's stream is ended.

A status outside 100 to 999 is sent as 400, and over HTTP/2 on the JavaScript backend a status below 200 is sent as 500. An Upgrade that has already been answered is left as it is.

Errors

type Error
    = SocketError Error
    | TlsError Problem
    | HandshakeRefused Int
    | HandshakeFailed
    | HeartbeatTimedOut
    | MessageTooLarge
    | ProtocolViolation String

Why a WebSocket task failed, or why a connection's message streams failed.

A WebSocket runs on layers: a connection, TLS for a wss:// URL, and the WebSocket protocol itself. Each layer's own failure appears here once, at the same depth, whichever URL scheme was used:

Failurews://wss://
The connection: refused, reset, timed out, host not foundSocketErrorSocketError
TLS: certificate rejected, handshake failed(cannot happen)TlsError
The opening handshakeHandshakeRefused, HandshakeFailedHandshakeRefused, HandshakeFailed

So a refused connection is SocketError with kind Socket.ConnectionRefused for both schemes, and one pattern handles it.

  • SocketError: the connection underneath failed, or a task was refused at the socket level, for example with kind Socket.InvalidInput for an invalid argument, Socket.Busy for a locked stream or Socket.Cancelled for a connection that is closing or has ended.
  • TlsError: the TLS of a wss:// connection failed, for example Socket.Tls.CertificateRejected. A peer that goes away during the TLS handshake is a SocketError, since only failures made by TLS itself are a TlsError. Socket.Tls.CertificateUnusable means the TrustedCertificates of the connection's verification hold no certificate that can be read.
  • HandshakeRefused status: the server answered the opening request with an HTTP status other than 101, or other than 2xx over HTTP/2, for example HandshakeRefused 403.
  • HandshakeFailed: the opening handshake failed for any other reason. A client gets it when the server's answer has missing or wrong handshake headers or a subprotocol or extension that was not offered, is not HTTP, or never comes because the server closes the connection. A server gets it from receiveUpgrade and accept when the client's request is not HTTP or not a valid opening request, or the client closes the connection before sending one.
  • HeartbeatTimedOut: the Heartbeat found the peer silent and failed the connection.
  • MessageTooLarge: the peer sent a message larger than the maxMessageSize of the connection's options, and the connection was failed with the close code MessageTooBig.
  • ProtocolViolation details: the peer broke the WebSocket protocol, for example with an invalid frame, and the connection was failed; details says how.

The tasks of this module, and Http.Server.upgradeRequest, fail with SocketError, TlsError, HandshakeRefused and HandshakeFailed. A connection's message streams fail with Stream.Failed, which fromStreamFailure reads as SocketError, HeartbeatTimedOut, MessageTooLarge or ProtocolViolation. One type covers both, so a program can handle them in one place, at the cost that each can hold cases the other never produces.

errorToString : Error -> String

Returns a description of the error for people to read. A SocketError or a TlsError is described as Socket.errorToString and Socket.Tls.errorToString describe it, and the others by what happened, such as "handshake refused with status 403".

fromStreamFailure : Failure -> Maybe Error

Reads a stream failure of a WebSocket's message streams, from readable, readableStreamed or a StreamedMessage, as an Error. Returns Nothing for a failure that another module's stream made.

case streamError of
    Stream.Failed failure ->
        case WebSocket.fromStreamFailure failure of
            Just WebSocket.HeartbeatTimedOut ->
                ...


            _ ->
                ...


    _ ->
        ...

The result is SocketError when the connection underneath failed or was closed, HeartbeatTimedOut, MessageTooLarge or ProtocolViolation when this end failed the connection, and TlsError when the TLS of a wss:// connection failed after the handshake.