packages feed

spire-websocket-0.1.0.0: src/Spire/WebSocket/Session.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Session type runtime for WebSocket protocols.
--
-- A 'Session' value is a phantom-typed handle whose type parameter
-- tracks the current protocol state. Each operation (send, recv,
-- offer, select) consumes the current session and produces the next
-- one, enforcing the correct message order at compile time.
--
-- The API is designed for single-use: each operation takes a
-- @Session s@ and returns a @Session s'@ for the continuation.
-- While Haskell's type system does not prevent reuse of the old
-- handle at runtime, the type-level state machine ensures that
-- only the correct sequence of operations type-checks.
--
-- @
-- type EchoProtocol = 'Send Text ('Recv Text 'End)
--
-- echoHandler :: Session EchoProtocol -> IO ()
-- echoHandler session = do
--   session'         <- send ("hello" :: Text) session
--   (msg, session'') <- recv session'
--   close session''
-- @
module Spire.WebSocket.Session
  ( -- * Session handle
    Session
    -- * Operations
  , send
  , recv
  , offer
  , select1
  , select2
  , close
  , recurse
  , loop
    -- * Running
  , withSession
    -- * WebSocket connection abstraction
  , WebSocketConn (..)
    -- * Errors
  , SessionError (..)
    -- * Type families
  , Unfold
  ) where

import Acolyte.Core.Session (SessionType (..))

import Control.Exception (Exception, throwIO)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as LBS


-- | Error thrown when a session operation fails (e.g. JSON decode error).
data SessionError = JsonDecodeError String
  deriving (Show, Eq)

instance Exception SessionError


-- | An abstraction over the underlying WebSocket transport.
--
-- Provide send\/recv\/close callbacks — the session runtime uses
-- these to exchange JSON-encoded messages with the peer.
data WebSocketConn = WebSocketConn
  { wsSend  :: !(LBS.ByteString -> IO ())
  , wsRecv  :: !(IO LBS.ByteString)
  , wsClose :: !(IO ())
  }


-- | A session handle. The phantom type parameter tracks the current
-- protocol state. Each operation transitions to the next state.
data Session (s :: SessionType) where
  Session :: !WebSocketConn -> Session s


-- | Send a JSON-encodable message. The session must be in 'Send' state.
-- Returns the continuation session.
send :: (Aeson.ToJSON a) => a -> Session ('Send a s) -> IO (Session s)
send val (Session conn) = do
  wsSend conn (Aeson.encode val)
  pure (Session conn)


-- | Receive a JSON-decodable message. The session must be in 'Recv' state.
-- Returns the decoded value paired with the continuation session.
recv :: (Aeson.FromJSON a) => Session ('Recv a s) -> IO (a, Session s)
recv (Session conn) = do
  bytes <- wsRecv conn
  case Aeson.eitherDecode bytes of
    Right val -> pure (val, Session conn)
    Left err  -> throwIO (JsonDecodeError err)


-- | Offer the peer a choice between two continuations.
-- The peer sends a selection tag; we branch accordingly.
offer :: Session ('Offer s1 s2) -> IO (Either (Session s1) (Session s2))
offer (Session conn) = do
  choice <- wsRecv conn
  if choice == "\"left\"" || choice == "1"
    then pure (Left (Session conn))
    else pure (Right (Session conn))


-- | Select the first option offered by the peer.
select1 :: Session ('Select s1 s2) -> IO (Session s1)
select1 (Session conn) = do
  wsSend conn "\"left\""
  pure (Session conn)


-- | Select the second option offered by the peer.
select2 :: Session ('Select s1 s2) -> IO (Session s2)
select2 (Session conn) = do
  wsSend conn "\"right\""
  pure (Session conn)


-- | Close the session. The protocol must be in 'End' state.
close :: Session 'End -> IO ()
close (Session conn) = wsClose conn


-- | Enter a recursive protocol. Unwraps 'Rec' to expose the body.
recurse :: Session ('Rec s) -> IO (Session s)
recurse (Session conn) = pure (Session conn)


-- | Jump back to the enclosing 'Rec' body. The return type @s@ is
-- universally quantified and must be determined by the calling context
-- (e.g. by passing the result to a function with a known session type).
--
-- __Soundness note:__ Haskell's type system cannot enforce that @s@
-- equals the body of the enclosing 'Rec' without linear types. In
-- practice, GHC infers the correct @s@ from context. Do not use an
-- explicit type application to override this — doing so can bypass
-- protocol tracking. Prefer letting GHC infer the type from a
-- recursive call or type-annotated binding.
loop :: Session 'Var -> IO (Session s)
loop (Session conn) = pure (Session conn)


-- | Run a session handler with the given 'WebSocketConn'.
-- The handler receives a 'Session' parameterised by the protocol type @s@.
withSession :: forall s a. WebSocketConn -> (Session s -> IO a) -> IO a
withSession conn f = f (Session conn)


-- | Unfold a recursive session type by substituting 'Var' with the
-- full @'Rec s@.
--
-- @
-- Unfold ('Rec ('Recv a ('Send a ('Offer 'Var 'End))))
--   ~ 'Recv a ('Send a ('Offer ('Rec ('Recv a ('Send a ('Offer 'Var 'End)))) 'End))
-- @
type Unfold :: SessionType -> SessionType
type family Unfold (s :: SessionType) :: SessionType where
  Unfold ('Rec s) = UnfoldWith ('Rec s) s

type UnfoldWith :: SessionType -> SessionType -> SessionType
type family UnfoldWith (rec :: SessionType) (s :: SessionType) :: SessionType where
  UnfoldWith rec ('Send a s)     = 'Send a (UnfoldWith rec s)
  UnfoldWith rec ('Recv a s)     = 'Recv a (UnfoldWith rec s)
  UnfoldWith rec ('Offer s1 s2)  = 'Offer (UnfoldWith rec s1) (UnfoldWith rec s2)
  UnfoldWith rec ('Select s1 s2) = 'Select (UnfoldWith rec s1) (UnfoldWith rec s2)
  UnfoldWith rec ('Rec s)        = 'Rec (UnfoldWith rec s)
  UnfoldWith rec 'Var            = rec
  UnfoldWith _   'End            = 'End