packages feed

servant-event-stream-0.4.0.1: src/Servant/API/EventStream.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

{- |
Module: Servant.API.EventStream
Description: Server-Sent Events for Servant
Copyright: (c) 2026 Shaun Sharples
License: BSD3
Stability: alpha

<https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events Server-Sent Events>
(SSE) support for Servant. Stream events from your server to connected clients,
or consume third-party SSE streams (such as
<https://platform.openai.com/docs/api-reference/streaming OpenAI>) from
Haskell.

== Sending events

Define a domain type for your events and a 'ToServerEvent' instance that maps
each constructor to an SSE event. Many streaming APIs encode the @data:@ field
as JSON — use 'jsonEvent' for this, or 'serverEvent' for raw bytestrings:

> data ChatEvent
>   = ContentDelta Text  -- ^ incremental text chunk
>   | ContentDone Text   -- ^ final assembled text
>   | ChatDone           -- ^ stream finished
>
> type MyApi = "chat" :> ServerSentEvents (SourceIO ChatEvent)
>
> instance ToServerEvent ChatEvent where
>   toServerEvent (ContentDelta d) = jsonEvent (Just "response.content.delta") Nothing d
>   toServerEvent (ContentDone t) = jsonEvent (Just "response.content.done") Nothing t
>   toServerEvent ChatDone = jsonEvent (Just "response.done") Nothing ()
>
> server :: Server MyApi
> server = pure $ source
>   [ContentDelta "Hel", ContentDelta "lo!", ContentDone "Hello!", ChatDone]

Some APIs accept a request body and respond with an event stream (e.g. OpenAI
chat completions). Use 'PostServerSentEvents' for these @POST@ endpoints:

> type MyApi = "chat" :> ReqBody '[JSON] ChatRequest :> PostServerSentEvents (SourceIO ChatEvent)

== Receiving events

To consume an SSE stream, provide a 'FromServerEvent' instance. Dispatch on
'eventType' to determine which constructor to use, and 'jsonData' to decode
JSON from the @data:@ field:

> instance FromServerEvent ChatEvent where
>   fromServerEvent ev = case eventType ev of
>     Just "response.content.delta" -> ContentDelta <$> jsonData ev
>     Just "response.content.done"  -> ContentDone <$> jsonData ev
>     Just "response.done"          -> Right ChatDone
>     _                             -> Left "unknown event"

== JSON via DerivingVia

When every event carries the same JSON structure (no @event:@ type dispatch),
'JsonData' derives both 'ToServerEvent' and 'FromServerEvent' automatically:

> data Temperature = Temperature { celsius :: Double }
>   deriving (Generic, ToJSON, FromJSON)
>   deriving (ToServerEvent, FromServerEvent) via JsonData Temperature

== Reverse-proxy headers

Reverse proxies like nginx buffer responses by default, which prevents events
from reaching the client in real time. Wrap your stream in
'RecommendedEventSourceHeaders' to disable buffering:

> type MyApi = "chat" :> ServerSentEvents (RecommendedEventSourceHeaders (SourceIO ChatEvent))
-}
module Servant.API.EventStream (
  -- * API combinator
  ServerSentEvents,
  PostServerSentEvents,
  EventStream,

  -- * Events
  ServerEvent (..),
  serverEvent,
  dataEvent,
  commentEvent,
  retryEvent,

  -- * Sending events
  ToServerEvent (..),
  encodeServerEvent,

  -- * Receiving events
  FromServerEvent (..),
  decodeServerEvent,

  -- * JSON helpers
  JsonData (..),
  jsonEvent,
  jsonData,

  -- * Recommended headers
  RecommendedEventSourceHeaders,
  recommendedEventSourceHeaders,

  -- * Framing
  ServerEventFraming,
)
where

import Control.Lens ((%~), (&), (.~), (?~))
import Control.Monad ((<=<))
import qualified Data.Aeson as Aeson
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as C8S
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as C8
import Data.Char (digitToInt, isDigit)
import Data.Kind (Type)
#if !MIN_VERSION_base(4,20,0)
import Data.List (foldl')
#endif
#if !MIN_VERSION_base(4,11,0)
import Data.Semigroup
#endif
import Data.Text (Text)
import GHC.Generics (Generic)
import Network.HTTP.Media ((//))

import qualified Servant as S
import qualified Servant.Client.Core as SC
import qualified Servant.Foreign as SF
import qualified Servant.Foreign.Internal as SFI
import Servant.Types.SourceT (transformWithAtto)

-- ---------------------------------------------------------------------------
-- Content type

-- | The @text\/event-stream@ content type.
data EventStream

instance S.Accept EventStream where
  contentType _ = "text" // "event-stream"

-- ---------------------------------------------------------------------------
-- API combinator

{- | Use this in place of a @Verb@ in your API type to define an SSE endpoint.
Streams events to clients over a long-lived HTTP connection.
-}
data ServerSentEvents (a :: Type)
  deriving (Generic)

instance S.HasLink (ServerSentEvents a) where
  type MkLink (ServerSentEvents a) r = r
  toLink toA _ = toA

{- | Like 'ServerSentEvents' but for @POST@ endpoints. Use this when the
client sends a request body and the server responds with an event stream
(e.g. OpenAI's streaming API).
-}
data PostServerSentEvents (a :: Type)
  deriving (Generic)

instance S.HasLink (PostServerSentEvents a) where
  type MkLink (PostServerSentEvents a) r = r
  toLink toA _ = toA

-- ---------------------------------------------------------------------------
-- Events

-- | A single SSE event — the intermediate representation between your domain types and the wire format.
data ServerEvent = ServerEvent
  { eventType :: !(Maybe LBS.ByteString)
  -- ^ The @event:@ field, used by clients to dispatch via @addEventListener@.
  , eventId :: !(Maybe LBS.ByteString)
  -- ^ The @id:@ field. Sent as @Last-Event-ID@ on reconnection.
  , eventData :: !LBS.ByteString
  -- ^ The @data:@ payload. Multi-line values are split across multiple @data:@ fields on the wire.
  , eventComment :: !(Maybe LBS.ByteString)
  -- ^ A @:@ comment line. Commonly used as a keepalive heartbeat.
  , eventRetry :: !(Maybe Word)
  -- ^ The @retry:@ field — reconnection delay in milliseconds.
  }
  deriving (Show, Eq, Generic)

{- | Construct an event with a type, id, and data payload.
Mirrors the pre-0.4 @ServerEvent@ constructor for easy migration.
-}
serverEvent :: Maybe LBS.ByteString -> Maybe LBS.ByteString -> LBS.ByteString -> ServerEvent
serverEvent typ eid dat = ServerEvent typ eid dat Nothing Nothing

-- | An event carrying only a data payload, with no event type or id.
dataEvent :: LBS.ByteString -> ServerEvent
dataEvent dat = ServerEvent Nothing Nothing dat Nothing Nothing

-- | Construct a comment-only event, useful as a keepalive heartbeat.
commentEvent :: LBS.ByteString -> ServerEvent
commentEvent c = ServerEvent Nothing Nothing "" (Just c) Nothing

-- | Construct a retry event that sets the client's reconnection delay in milliseconds.
retryEvent :: Word -> ServerEvent
retryEvent ms = ServerEvent Nothing Nothing "" Nothing (Just ms)

-- ---------------------------------------------------------------------------
-- Sending events

-- | Convert a domain type to a t'ServerEvent' for sending over the wire.
class ToServerEvent a where
  toServerEvent :: a -> ServerEvent

instance ToServerEvent ServerEvent where
  toServerEvent = id

instance (ToServerEvent a) => S.MimeRender EventStream a where
  mimeRender _ = encodeServerEvent . toServerEvent

-- | Encode a t'ServerEvent' to its wire format. Called automatically by 'S.MimeRender'; use directly only for custom encoding needs.
encodeServerEvent :: ServerEvent -> LBS.ByteString
encodeServerEvent e =
  optional ":" (sanitize <$> eventComment e)
    <> maybe mempty (\ms -> "retry: " <> C8.pack (show ms) <> "\n") (eventRetry e)
    <> optional "event:" (sanitize <$> eventType e)
    <> optional "id:" (sanitizeId <$> eventId e)
    <> mconcat (map (field "data:") (safedata (eventData e)))
 where
  optional name = maybe mempty (field name)
  field name val = name <> " " <> val <> "\n"

  sanitize = C8.filter (\c -> c /= '\r' && c /= '\n')
  sanitizeId = C8.filter (\c -> c /= '\r' && c /= '\n' && c /= '\0')

  safedata bs = case safelines bs of
    [] -> [""]
    xs -> xs
  safelines = C8.lines . C8.filter (/= '\r')

-- ---------------------------------------------------------------------------
-- Receiving events

-- | Parse a received t'ServerEvent' back into a domain type.
class FromServerEvent a where
  fromServerEvent :: ServerEvent -> Either String a

instance FromServerEvent ServerEvent where
  fromServerEvent = Right

instance (FromServerEvent a) => S.MimeUnrender EventStream a where
  mimeUnrender _ = fromServerEvent <=< decodeServerEvent

{- | Decode a single SSE event block from its wire format. Called automatically
by 'S.MimeUnrender'; use directly only for custom decoding needs.

Parsing follows the
<https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation WHATWG SSE spec>.
-}
decodeServerEvent :: LBS.ByteString -> Either String ServerEvent
decodeServerEvent input =
  let ls = splitLines (stripBOM (LBS.toStrict input))
      (typ, eid, dataParts, comment, retry) = foldl' processLine (Nothing, Nothing, [], Nothing, Nothing) ls
      dat = case dataParts of
        [] -> ""
        _ -> LBS.fromStrict (BS.intercalate "\n" (reverse dataParts))
   in Right
        ServerEvent
          { eventType = LBS.fromStrict <$> typ
          , eventId = LBS.fromStrict <$> eid
          , eventData = dat
          , eventComment = LBS.fromStrict <$> comment
          , eventRetry = retry
          }
 where
  processLine acc@(typ, eid, dataParts, comment, retry) line
    | BS.null line = acc -- skip empty lines
    | C8S.head line == ':' =
        let val = stripOneSpace (BS.drop 1 line)
         in (typ, eid, dataParts, Just val, retry)
    | otherwise =
        let (name, val) = case C8S.elemIndex ':' line of
              Just i -> (BS.take i line, stripOneSpace (BS.drop (i + 1) line))
              Nothing -> (line, "")
         in if
              | name == "event" -> (Just val, eid, dataParts, comment, retry)
              | name == "data" -> (typ, eid, val : dataParts, comment, retry)
              | name == "id" ->
                  if C8S.elem '\0' val
                    then acc
                    else (typ, Just val, dataParts, comment, retry)
              | name == "retry" ->
                  if BS.null val || not (C8S.all isDigit val)
                    then acc
                    else (typ, eid, dataParts, comment, Just (parseWord val))
              | otherwise -> acc

  stripOneSpace bs
    | not (BS.null bs) && C8S.head bs == ' ' = BS.drop 1 bs
    | otherwise = bs

  parseWord = C8S.foldl' (\n c -> n * 10 + fromIntegral (digitToInt c)) 0

  splitLines bs = map stripCR (C8S.split '\n' bs)
   where
    stripCR s
      | BS.null s = s
      | C8S.last s == '\r' = BS.init s
      | otherwise = s

-- ---------------------------------------------------------------------------
-- JSON helpers

{- | Derive 'ToServerEvent' and 'FromServerEvent' for types that serialise
entirely as JSON in the @data:@ field, with no @event:@ type or @id:@.

For sum types that dispatch on 'eventType', write manual instances using
'jsonEvent' and 'jsonData' instead.

@
data Temperature = Temperature { celsius :: Double }
  deriving (ToJSON, FromJSON)
  deriving (ToServerEvent, FromServerEvent) via JsonData Temperature
@
-}
newtype JsonData a = JsonData {unJsonData :: a}

instance (Aeson.ToJSON a) => ToServerEvent (JsonData a) where
  toServerEvent (JsonData a) = jsonEvent Nothing Nothing a

instance (Aeson.FromJSON a) => FromServerEvent (JsonData a) where
  fromServerEvent = fmap JsonData . jsonData

{- | Construct a t'ServerEvent' with a JSON-encoded @data:@ payload.
The encoding counterpart to 'jsonData'.

@
instance ToServerEvent ChatEvent where
    toServerEvent (ContentDelta d) = jsonEvent (Just \"response.content.delta\") Nothing d
    toServerEvent ChatDone         = jsonEvent (Just \"response.done\") Nothing ()
@
-}
jsonEvent :: (Aeson.ToJSON a) => Maybe LBS.ByteString -> Maybe LBS.ByteString -> a -> ServerEvent
jsonEvent typ eid a = serverEvent typ eid (Aeson.encode a)

{- | Decode the 'eventData' field as JSON.
The decoding counterpart to 'jsonEvent'.

@
instance FromServerEvent ChatEvent where
    fromServerEvent ev = case eventType ev of
        Just \"response.content.delta\" -> ContentDelta \<$\> jsonData ev
        Just \"response.done\"          -> Right ChatDone
        _                             -> Left \"unknown event\"
@
-}
jsonData :: (Aeson.FromJSON a) => ServerEvent -> Either String a
jsonData = Aeson.eitherDecode . eventData

-- | Strip a leading UTF-8 BOM (@\\xEF\\xBB\\xBF@) if present, per the WHATWG SSE spec.
stripBOM :: BS.ByteString -> BS.ByteString
stripBOM bs
  | "\xEF\xBB\xBF" `BS.isPrefixOf` bs = BS.drop 3 bs
  | otherwise = bs

-- ---------------------------------------------------------------------------
-- Recommended headers

{- | Adds @X-Accel-Buffering: no@ and @Cache-Control: no-store@ headers to
prevent reverse proxies (e.g. nginx) from buffering the event stream.

> type MyApi = "events" :> ServerSentEvents (RecommendedEventSourceHeaders (SourceIO Event))
>
> server :: Server MyApi
> server = pure $ recommendedEventSourceHeaders $ source [event1, event2]
-}
type RecommendedEventSourceHeaders (a :: Type) = S.Headers '[S.Header "X-Accel-Buffering" Text, S.Header "Cache-Control" Text] a

-- | Wrap a streaming response with the recommended headers. See 'RecommendedEventSourceHeaders'.
recommendedEventSourceHeaders :: a -> RecommendedEventSourceHeaders a
recommendedEventSourceHeaders = S.addHeader @"X-Accel-Buffering" "no" . S.addHeader @"Cache-Control" "no-store"

-- ---------------------------------------------------------------------------
-- Framing

-- | SSE framing strategy. Applied automatically by 'ServerSentEvents' and 'PostServerSentEvents'.
data ServerEventFraming

instance S.FramingRender ServerEventFraming where
  framingRender _ f = fmap (\x -> f x <> "\n")

instance S.FramingUnrender ServerEventFraming where
  framingUnrender _ f = transformWithAtto (eventParser f)

eventParser :: (LBS.ByteString -> Either String a) -> A.Parser a
eventParser f = do
  ls <- collectLines
  case ls of
    [] -> fail "empty event"
    _ -> do
      let block = stripBOM (BS.intercalate "\n" ls <> "\n")
      case f (LBS.fromStrict block) of
        Left err -> fail err
        Right a -> pure a
 where
  collectLines = do
    atEnd <- A.atEnd
    if atEnd
      then pure []
      else do
        line <- A.takeWhile (\c -> c /= '\n' && c /= '\r')
        -- consume line ending: CRLF, CR, or LF
        _ <- A.option () (A.char '\r' *> pure ())
        _ <- A.option () (A.char '\n' *> pure ())
        if BS.null line
          then pure []
          else do
            rest <- collectLines
            pure (line : rest)

-- ---------------------------------------------------------------------------
-- Servant integration

instance {-# OVERLAPPABLE #-} (ToServerEvent chunk, S.ToSourceIO chunk a) => S.HasServer (ServerSentEvents a) context where
  type ServerT (ServerSentEvents a) m = S.ServerT (S.StreamGet ServerEventFraming EventStream a) m
  route S.Proxy =
    S.route
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream a))
  hoistServerWithContext S.Proxy =
    S.hoistServerWithContext
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream a))

instance {-# OVERLAPPING #-} (ToServerEvent chunk, S.ToSourceIO chunk a, S.GetHeaders (S.Headers h a)) => S.HasServer (ServerSentEvents (S.Headers h a)) context where
  type ServerT (ServerSentEvents (S.Headers h a)) m = S.ServerT (S.StreamGet ServerEventFraming EventStream (S.Headers h a)) m
  route S.Proxy =
    S.route
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream (S.Headers h a)))
  hoistServerWithContext S.Proxy =
    S.hoistServerWithContext
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream (S.Headers h a)))

instance {-# OVERLAPPABLE #-} (ToServerEvent chunk, S.ToSourceIO chunk a) => S.HasServer (PostServerSentEvents a) context where
  type ServerT (PostServerSentEvents a) m = S.ServerT (S.StreamPost ServerEventFraming EventStream a) m
  route S.Proxy =
    S.route
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream a))
  hoistServerWithContext S.Proxy =
    S.hoistServerWithContext
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream a))

instance {-# OVERLAPPING #-} (ToServerEvent chunk, S.ToSourceIO chunk a, S.GetHeaders (S.Headers h a)) => S.HasServer (PostServerSentEvents (S.Headers h a)) context where
  type ServerT (PostServerSentEvents (S.Headers h a)) m = S.ServerT (S.StreamPost ServerEventFraming EventStream (S.Headers h a)) m
  route S.Proxy =
    S.route
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream (S.Headers h a)))
  hoistServerWithContext S.Proxy =
    S.hoistServerWithContext
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream (S.Headers h a)))

instance {-# OVERLAPPABLE #-} (FromServerEvent chunk, SC.RunStreamingClient m, S.FromSourceIO chunk a) => SC.HasClient m (ServerSentEvents a) where
  type Client m (ServerSentEvents a) = SC.Client m (S.StreamGet ServerEventFraming EventStream a)
  clientWithRoute pm S.Proxy =
    SC.clientWithRoute
      pm
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream a))
  hoistClientMonad pm S.Proxy =
    SC.hoistClientMonad
      pm
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream a))

instance {-# OVERLAPPING #-} (FromServerEvent chunk, SC.RunStreamingClient m, S.FromSourceIO chunk a, S.BuildHeadersTo h) => SC.HasClient m (ServerSentEvents (S.Headers h a)) where
  type Client m (ServerSentEvents (S.Headers h a)) = SC.Client m (S.StreamGet ServerEventFraming EventStream (S.Headers h a))
  clientWithRoute pm S.Proxy =
    SC.clientWithRoute
      pm
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream (S.Headers h a)))
  hoistClientMonad pm S.Proxy =
    SC.hoistClientMonad
      pm
      (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream (S.Headers h a)))

instance {-# OVERLAPPABLE #-} (FromServerEvent chunk, SC.RunStreamingClient m, S.FromSourceIO chunk a) => SC.HasClient m (PostServerSentEvents a) where
  type Client m (PostServerSentEvents a) = SC.Client m (S.StreamPost ServerEventFraming EventStream a)
  clientWithRoute pm S.Proxy =
    SC.clientWithRoute
      pm
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream a))
  hoistClientMonad pm S.Proxy =
    SC.hoistClientMonad
      pm
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream a))

instance {-# OVERLAPPING #-} (FromServerEvent chunk, SC.RunStreamingClient m, S.FromSourceIO chunk a, S.BuildHeadersTo h) => SC.HasClient m (PostServerSentEvents (S.Headers h a)) where
  type Client m (PostServerSentEvents (S.Headers h a)) = SC.Client m (S.StreamPost ServerEventFraming EventStream (S.Headers h a))
  clientWithRoute pm S.Proxy =
    SC.clientWithRoute
      pm
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream (S.Headers h a)))
  hoistClientMonad pm S.Proxy =
    SC.hoistClientMonad
      pm
      (S.Proxy :: S.Proxy (S.StreamPost ServerEventFraming EventStream (S.Headers h a)))

-- | Enables <https://hackage.haskell.org/package/servant-foreign servant-foreign> code generation, prefixing function names with \"stream\".
instance
  (SF.HasForeignType lang ftype a) =>
  SF.HasForeign lang ftype (ServerSentEvents a)
  where
  type Foreign ftype (ServerSentEvents a) = SFI.Req ftype

  foreignFor lang S.Proxy S.Proxy req =
    req
      & SFI.reqFuncName . SFI._FunctionName %~ ("stream" :)
      & SFI.reqMethod .~ method
      & SFI.reqReturnType ?~ retType
   where
    retType = SFI.typeFor lang (S.Proxy :: S.Proxy ftype) (S.Proxy :: S.Proxy a)
    method = S.reflectMethod (S.Proxy :: S.Proxy S.GET)

-- | Enables <https://hackage.haskell.org/package/servant-foreign servant-foreign> code generation for 'PostServerSentEvents'.
instance
  (SF.HasForeignType lang ftype a) =>
  SF.HasForeign lang ftype (PostServerSentEvents a)
  where
  type Foreign ftype (PostServerSentEvents a) = SFI.Req ftype

  foreignFor lang S.Proxy S.Proxy req =
    req
      & SFI.reqFuncName . SFI._FunctionName %~ ("stream" :)
      & SFI.reqMethod .~ method
      & SFI.reqReturnType ?~ retType
   where
    retType = SFI.typeFor lang (S.Proxy :: S.Proxy ftype) (S.Proxy :: S.Proxy a)
    method = S.reflectMethod (S.Proxy :: S.Proxy S.POST)