packages feed

ema-0.12.0.0: src/Ema/Server/WebSocket.hs

module Ema.Server.WebSocket where

import Control.Monad.Logger
import Data.LVar (LVar)
import Data.LVar qualified as LVar
import Ema.Asset (
  Asset (AssetGenerated, AssetStatic),
  Format (Html, Other),
 )
import Ema.Route.Class (IsRoute (RouteModel, routePrism))
import Ema.Route.Prism (
  fromPrism_,
 )
import Ema.Server.Common
import Ema.Server.WebSocket.Options (EmaWsHandler (..))
import Ema.Site (EmaStaticSite)
import Network.WebSockets (ConnectionException)
import Network.WebSockets qualified as WS
import Optics.Core (review)
import UnliftIO.Async (race)
import UnliftIO.Exception (try)

wsApp ::
  forall r.
  (Eq r, Show r, IsRoute r, EmaStaticSite r) =>
  (Loc -> LogSource -> LogLevel -> LogStr -> IO ()) ->
  LVar (RouteModel r) ->
  EmaWsHandler r ->
  WS.PendingConnection ->
  IO ()
wsApp logger model emaWsHandler pendingConn = do
  conn :: WS.Connection <- WS.acceptRequest pendingConn
  WS.withPingThread conn 30 pass . flip runLoggingT logger $ do
    let log lvl (s :: Text) =
          logWithoutLoc "ema.ws" lvl s
    log LevelInfo "Connected"
    let wsHandler = unEmaWsHandler emaWsHandler conn
        sendRouteHtmlToClient path s = do
          decodeUrlRoute @r s path & \case
            Left err -> do
              log LevelError $ badRouteEncodingMsg err
              liftIO $ WS.sendTextData conn $ emaErrorHtmlResponse $ badRouteEncodingMsg err
            Right Nothing ->
              liftIO $ WS.sendTextData conn $ emaErrorHtmlResponse decodeRouteNothingMsg
            Right (Just r) -> do
              renderCatchingErrors s r >>= \case
                AssetGenerated Html html ->
                  liftIO $ WS.sendTextData conn $ html <> toLazy wsClientHtml
                -- HACK: We expect the websocket client should check for REDIRECT prefix.
                -- Not bothering with JSON response to avoid having to JSON parse every HTML dump.
                AssetStatic _staticPath ->
                  liftIO $ WS.sendTextData conn $ "REDIRECT " <> toText (review (fromPrism_ $ routePrism s) r)
                AssetGenerated Other _s ->
                  liftIO $ WS.sendTextData conn $ "REDIRECT " <> toText (review (fromPrism_ $ routePrism s) r)
              log LevelDebug $ " ~~> " <> show r
        -- @mWatchingRoute@ is the route currently being watched.
        loop mWatchingRoute = do
          -- Listen *until* either we get a new value, or the client requests
          -- to switch to a new route.
          currentModel <- LVar.get model
          race (LVar.listenNext model) (wsHandler currentModel) >>= \case
            Left newModel -> do
              -- The page the user is currently viewing has changed. Send
              -- the new HTML to them.
              sendRouteHtmlToClient mWatchingRoute newModel
              loop mWatchingRoute
            Right mNextRoute -> do
              -- The user clicked on a route link; send them the HTML for
              -- that route this time, ignoring what we are watching
              -- currently (we expect the user to initiate a watch route
              -- request immediately following this).
              sendRouteHtmlToClient mNextRoute =<< LVar.get model
              loop mNextRoute
    -- Wait for the client to send the first request with the initial route.
    mInitialRoute <- wsHandler =<< LVar.get model
    try (loop mInitialRoute) >>= \case
      Right () -> pass
      Left (connExc :: ConnectionException) -> do
        case connExc of
          WS.CloseRequest _ (decodeUtf8 -> reason) ->
            log LevelInfo $ "Closing websocket connection (reason: " <> reason <> ")"
          _ ->
            log LevelError $ "Websocket error: " <> show connExc