Elm-inspired Scala UI library.

Networking

Out of the box, Tyrian supports two flavors of networking, HTTP and Web Sockets, and we have examples you can run of both. Please see the instructions in the README file.

It should be said that both implementations are quite primitive at the time of writing. Contributions in the form of issues and improvements are very welcome in this area. No doubt they will be improved as the need arises.

Http

tyrian.http.Http is a built-in Cmd that defines the following method:

object Http:
  def send[F[_]: Async, A, Msg](
    request: Request[A],
    resultToMessage: Decoder[Msg]
  ): Cmd[F, Msg]

Additionally, Tyrian also integrates with http4s-dom.

Fetch random GIF via HTTP

Assuming the following imports:

import cats.effect.IO
import cats.syntax.either.*
import io.circe.HCursor
import io.circe.parser.*
import tyrian.*
import tyrian.cmds.*
import tyrian.http.*
import tyrian.Html.*

Let's walk through this example starting with the Model1 and Msg1 types.

final case class Model1(topic: String, gifUrl: String)

enum Msg1:
  case MorePlease              extends Msg1
  case NewGif(result: String)  extends Msg1
  case GifError(error: String) extends Msg1

Followed by a Decoder[Msg1] needed to parse the HTTP responses.

object Msg1:
  def jsonDecode(hcursor: HCursor) =
    hcursor
      .downField("data")
      .downField("images")
      .downField("downsized_medium")
      .get[String]("url")
      .toOption
      .toRight("wrong json format")

  private val onResponse: Response => Msg1 = { response =>
    parse(response.body)
      .leftMap(_.message)
      .flatMap(j => jsonDecode(j.hcursor))
      .fold(Msg1.GifError(_), Msg1.NewGif(_))
  }

  private val onError: HttpError => Msg1 =
    e => Msg1.GifError(e.toString)

  def fromHttpResponse: Decoder[Msg1] =
    Decoder[Msg1](onResponse, onError)

Next we have an HttpHelper that invokes the Http.send method using Giphy's API.

object HttpHelper:
  def url(topic: String) =
    s"https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=$topic"

  def getRandomGif(topic: String): Cmd[IO, Msg1] =
    Http.send(Request.get(url(topic)), Msg1.fromHttpResponse)

Ultimately, we can use it in our init and update methods, or anywhere where a Cmd[IO, Msg1] is expected. For example:

object HttpMain:
  def init(flags: Map[String, String]): (Model1, Cmd[IO, Msg1]) =
    (Model1("cats", "waiting.gif"), HttpHelper.getRandomGif("cats"))

  def update(model: Model1): Msg1 => (Model1, Cmd[IO, Msg1]) =
    case Msg1.MorePlease     => (model, HttpHelper.getRandomGif(model.topic))
    case Msg1.NewGif(newUrl) => (model.copy(gifUrl = newUrl), Cmd.None)
    case Msg1.GifError(_)    => (model, Cmd.None)

You can find the full code on the examples directory linked at the top.

Http4s-dom integration

To use http4s-dom instead, we only need to replace the HttpHelper with the following implementation.

import io.circe.{ Decoder as JsonDecoder, DecodingFailure }
import org.http4s.circe.CirceEntityCodec.*
import org.http4s.dom.FetchClientBuilder

object Http4sDomHelper:
  private val client = FetchClientBuilder[IO].create

  given JsonDecoder[Msg1] = JsonDecoder.instance { c =>
    Msg1.jsonDecode(c).map(Msg1.NewGif(_)).leftMap(e => DecodingFailure(e, c.history))
  }

  def getRandomGif(topic: String): Cmd[IO, Msg1] =
    val fetchGif: IO[Msg1] =
      client
        .expect[Msg1](HttpHelper.url(topic))
        .handleError(e => Msg1.GifError(e.getMessage))

    Cmd.Run(fetchGif)

Web Sockets

Another built-in command is tyrian.websocket.WebSocket, which has a more complex API.

final class WebSocket[F[_]: Async](liveSocket: LiveSocket[F]):
  def disconnect[Msg]: Cmd[F, Msg]
  def publish[Msg](message: String): Cmd[F, Msg]
  def subscribe[Msg](f: WebSocketEvent => Msg): Sub[F, Msg]

/** The running instance of the WebSocket */
final class LiveSocket[F[_]: Async](val socket: dom.WebSocket, val subs: Sub[F, WebSocketEvent])

object WebSocket:
  def connect[F[_]: Async, Msg](
    address: String,
    onOpenMessage: String,
    keepAliveSettings: KeepAliveSettings
  )(
    resultToMessage: WebSocketConnect[F] => Msg
  ): Cmd[F, Msg]

Having a WebSocket instance allows us to publish messages (Cmd[F, Msg]), subscribe to events (Sub[F, Msg]), and explicitly disconnect (another Cmd[F, Msg]) from the server. To initiate a connection, we can use any of the available connect methods defined on its companion object.

WS echo server

The following example demonstrates the usage of WebSocket, starting with the following imports:

import cats.effect.IO
import tyrian.Html.*
import tyrian.*
import tyrian.cmds.Logger
import tyrian.websocket.*

Next we have the Msg and Model types.

enum Msg:
  case FromSocket(message: String)
  case ToSocket(message: String)
  case WebSocketStatus(status: EchoSocket.Status)

final case class Model(echoSocket: EchoSocket, log: List[String])

object Model:
  val init: Model =
    Model(EchoSocket.init, Nil)

Followed by a custom EchoSocket class that handles the connection by reacting to EchoSocket.Status messages. The handling of the Connecting message is of particular interest, as it initiates the socket connection via WebSocket.connect, including keep-alive settings.

final case class EchoSocket(socketUrl: String, socket: Option[WebSocket[IO]]):

  def connectDisconnectButton =
    if socket.nonEmpty then
      button(onClick(EchoSocket.Status.Disconnecting.asMsg))("Disconnect")
    else button(onClick(EchoSocket.Status.Connecting.asMsg))("Connect")

  def update(status: EchoSocket.Status): (EchoSocket, Cmd[IO, Msg]) =
    status match
      case EchoSocket.Status.ConnectionError(err) =>
        (this, Logger.error(s"Failed to open WebSocket connection: $err"))

      case EchoSocket.Status.Connected(ws) =>
        (this.copy(socket = Some(ws)), Cmd.None)

      case EchoSocket.Status.Connecting =>
        val connect =
          WebSocket.connect[IO, Msg](
            address = socketUrl,
            onOpenMessage = "Connect me!",
            keepAliveSettings = KeepAliveSettings.default
          ) {
            case WebSocketConnect.Error(err) =>
              EchoSocket.Status.ConnectionError(err).asMsg

            case WebSocketConnect.Socket(ws) =>
              EchoSocket.Status.Connected(ws).asMsg
          }

        (this, connect)

      case EchoSocket.Status.Disconnecting =>
        val log = Logger.info[IO]("Graceful shutdown of EchoSocket connection")
        val cmds =
          socket.map(ws => Cmd.Batch(log, ws.disconnect)).getOrElse(log)

        (this.copy(socket = None), cmds)

      case EchoSocket.Status.Disconnected =>
        (this, Logger.info("WebSocket not connected yet"))

  def publish(message: String): Cmd[IO, Msg] =
    socket.map(_.publish(message)).getOrElse(Cmd.None)

  def subscribe(toMessage: WebSocketEvent => Msg): Sub[IO, Msg] =
    socket.fold(Sub.emit[IO, Msg](EchoSocket.Status.Disconnected.asMsg)) {
      _.subscribe(toMessage)
    }

object EchoSocket:

  val init: EchoSocket =
    EchoSocket("wss://ws.ifelse.io/", None)

  enum Status:
    case Connecting
    case Connected(ws: WebSocket[IO])
    case ConnectionError(msg: String)
    case Disconnecting
    case Disconnected

    def asMsg: Msg = Msg.WebSocketStatus(this)

At last, we can see how to handle socket messages and status changes in our update method.

def update(model: Model): Msg => (Model, Cmd[IO, Msg]) =
  case Msg.WebSocketStatus(status) =>
    val (nextWS, cmds) = model.echoSocket.update(status)
    (model.copy(echoSocket = nextWS), cmds)

  case Msg.FromSocket(message) =>
    val logWS = Logger.info[IO]("Got: " + message)
    (model.copy(log = message :: model.log), logWS)

  case Msg.ToSocket(message) =>
    val cmds: Cmd[IO, Msg] =
      Cmd.Batch(
        Logger.info("Sent: " + message),
        model.echoSocket.publish(message)
      )

    (model, cmds)

Moreover, the subscriptions method handles Web Socket events.

  def subscriptions(model: Model): Sub[IO, Msg] =
    model.echoSocket.subscribe {
      case WebSocketEvent.Error(errorMesage) =>
        Msg.FromSocket(errorMesage)

      case WebSocketEvent.Receive(message) =>
        Msg.FromSocket(message)

      case WebSocketEvent.Open =>
        Msg.FromSocket("<no message - socket opened>")

      case WebSocketEvent.Close(code, reason) =>
        Msg.FromSocket(s"<socket closed> - code: $code, reason: $reason")

      case WebSocketEvent.Heartbeat =>
        Msg.ToSocket("<💓 heartbeat 💓>")
    }

The full code can be found on the examples linked at the top.

Furthermore, you may also find the trading project useful: a full-stack application with a Web Socket client sharing the back-end domain model.