WebSockets

Managing the life cycle of a WebSocket connection can be tricky. In ff4s we benefit from abstractions in Cats Effect and fs2 to achieve this with ease and safety.

In this example we are connecting to a simple WS server that echos back any message we send to it. We only show the common case where the lifetime of the connection coincides with the lifetime of the app.

State

The state holds the user's input and the most recent server response.

case class State(
    userInput: Option[String] = None,
    serverResponse: Option[String] = None
)

Action

enum Action:
  case Send
  case SetServerResponse(response: String)
  case SetUserInput(input: Option[String])

Store

We use a Cats Effect Queue to hold outgoing messages. The connection itself runs on a separate fiber safely tied to the lifetime of the store using .background. The ff4s.WebsocketClient is a wrapper around the more powerful http4s client and intended for simple use-cases such as this one.

import cats.effect.*
import cats.effect.implicits.*
import cats.effect.std.*
import cats.syntax.all.*
import fs2.Stream

object Store:

  def apply[F[_]](using F: Async[F]) = for
    sendQ <- Queue.unbounded[F, String].toResource

    store <- ff4s.Store[F, State, Action](State()): _ =>
      case (Action.SetUserInput(input), state) =>
        state.copy(userInput = input) -> F.unit
      case (Action.Send, state) =>
        state -> state.userInput.foldMapM(sendQ.offer)
      case (Action.SetServerResponse(res), state) =>
        state.copy(serverResponse = res.some) -> F.unit

    _ <- ff4s
      .WebSocketClient[F]
      .bidirectionalText(
        "wss://ws.postman-echo.com/raw/",
        _.evalMap(res => store.dispatch(Action.SetServerResponse(res))),
        Stream.fromQueueUnterminated(sendQ)
      )
      .background
  yield store

View

import org.scalajs.dom

trait View:
  self: ff4s.Dsl[State, Action] =>

  import html.*

  val view =
    useState: state =>
      div(
        input(
          tpe := "text",
          placeholder := "your message here...",
          onInput := ((ev: dom.Event) =>
            val target = ev.target.asInstanceOf[dom.HTMLInputElement]
            if target.value.nonEmpty then
              Some(Action.SetUserInput(target.value.some))
            else Some(Action.SetUserInput(None))
          )
        ),
        button(
          "Send",
          disabled := state.userInput.isEmpty,
          onClick := (_ => Action.Send.some)
        ),
        state.serverResponse.fold(empty)(res => div(s"Server response: $res"))
      )

App

The boilerplate construction of ff4s.App and ff4s.IOEntryPoint is omitted.