bloodhound-1.0.0.0: src/Database/Bloodhound/Internal/Utils/SSE.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Database.Bloodhound.Internal.Utils.SSE
-- Description : Server-Sent Events parser over an http-client BodyReader
--
-- A small, dependency-light Server-Sent Events (SSE) parser that pulls
-- bytes incrementally from a 'Network.HTTP.Client.BodyReader' (the
-- streaming response body exposed by 'Database.Bloodhound.Client.Cluster.withStreamingResponse').
-- It is /not/ a general-purpose SSE server; it is the minimum needed
-- to consume SSE-style responses from the OpenSearch ML Commons
-- Execute Stream Agent API
-- (@POST /_plugins/_ml/agents/{agent_id}/_execute/stream@), whose
-- wire format is one @data: <JSON>\\n\\n@ frame per agent chunk.
--
-- The parser implements the subset of the SSE specification that
-- matters for parsing a byte stream into events:
--
-- * Lines are terminated by @\\n@, optionally with a preceding @\\r@.
-- * An event is terminated by a blank line (the canonical @\\n\\n@
-- separator, tolerating @\\r\\n\\r\\n@).
-- * Lines starting with @:@ are comments and are ignored.
-- * A line of the form @field: value@ sets the named field; the
-- leading single space after the colon is stripped (per spec),
-- further spaces are preserved. A line with no colon sets the
-- field to the empty string with an empty value.
-- * Multiple @data:@ lines within a single event are joined with
-- @\\n@ to form the final 'sseData' (per spec).
-- * The @event@, @id@, and @retry@ fields are parsed (so a future
-- endpoint that emits them round-trips), but the Execute Stream
-- Agent API only ever emits @data:@.
--
-- The parser yields 'SSEEvent' values one at a time; the caller
-- decides when to stop (e.g. on a chunk whose JSON carries an
-- @is_last@ flag, or an AG-UI @RUN_FINISHED@ discriminator). There is
-- no synthetic end-of-stream event: when the 'BodyReader' is
-- exhausted, 'readSSEEvent' returns 'Nothing'.
module Database.Bloodhound.Internal.Utils.SSE
( SSEEvent (..),
SSEState,
newSSEState,
readSSEEvent,
sseSource,
)
where
import Conduit (ConduitT, MonadResource, liftIO, yield)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error (lenientDecode)
import Network.HTTP.Client (BodyReader, brRead)
import Numeric (readDec)
import Prelude hiding (head, tail)
-- | A single parsed Server-Sent Event. Mirrors the fields the spec
-- defines; 'sseEvent' is 'Nothing' when no @event:@ line was present
-- (the default-event case, which is what the Execute Stream Agent API
-- always produces).
data SSEEvent = SSEEvent
{ sseEvent :: Maybe Text,
sseData :: Text,
sseId :: Maybe Text,
sseRetry :: Maybe Int
}
deriving stock (Eq, Show)
-- | The empty event used to begin accumulating a new frame.
emptyEvent :: SSEEvent
emptyEvent = SSEEvent Nothing "" Nothing Nothing
-- | Whether an accumulated event carries any content (at least one
-- field was set). A blank-line terminator on an empty event is a
-- keep-alive heartbeat; the parser still dispatches such events so
-- the caller can observe heartbeats if it cares, but at end-of-stream
-- a trailing empty event is dropped.
eventHasContent :: SSEEvent -> Bool
eventHasContent ev =
not (T.null (sseData ev))
|| isJust (sseEvent ev)
|| isJust (sseId ev)
|| isJust (sseRetry ev)
-- | Mutable parser state: the unconsumed byte buffer (leftover bytes
-- after the last dispatched event) paired with the underlying
-- 'BodyReader'. Carrying the buffer in an 'IORef' lets 'readSSEEvent'
-- retain leftover bytes across calls without re-reading from the
-- network.
data SSEState = SSEState
{ sseStateBuffer :: IORef ByteString,
sseStateReader :: BodyReader
}
-- | Allocate a fresh parser state over a 'BodyReader'.
newSSEState :: BodyReader -> IO SSEState
newSSEState reader = SSEState <$> newIORef BS.empty <*> pure reader
-- | Pull the next complete SSE event from the stream. Returns
-- 'Nothing' when the body is fully consumed and no partial event with
-- content remains. See module docs for the parsing rules.
readSSEEvent :: SSEState -> IO (Maybe SSEEvent)
readSSEEvent st = go emptyEvent
where
go event = do
buf <- readIORef (sseStateBuffer st)
case breakLine buf of
Just (line, rest) -> do
writeIORef (sseStateBuffer st) rest
case applyLine line event of
Dispatch ev -> pure (Just ev)
Continue ev' -> go ev'
Nothing -> do
chunk <- brRead (sseStateReader st)
if BS.null chunk
then do
-- End of stream. The buffer may still hold a trailing
-- partial line (one with no terminating @\\n@); feed it
-- through 'applyLine' as a final line before deciding
-- whether to dispatch, so an unterminated @data: ...@
-- frame is not lost.
leftover <- readIORef (sseStateBuffer st)
let finalEvent =
if BS.null leftover
then event
else case applyLine leftover event of
Dispatch ev -> ev
Continue ev' -> ev'
if eventHasContent finalEvent
then pure (Just finalEvent)
else pure Nothing
else do
modifyIORef' (sseStateBuffer st) (<> chunk)
go event
-- | The result of interpreting a single line: either the accumulated
-- event is complete and should be dispatched, or the accumulator
-- should be updated and parsing continue.
data LineResult
= Dispatch SSEEvent
| Continue SSEEvent
-- | Interpret one SSE line against the current accumulator. A blank
-- line dispatches (per spec); a comment line (@:@) is a no-op
-- continue; any other line updates the named field and continues.
applyLine :: ByteString -> SSEEvent -> LineResult
applyLine line event
| BS.null line = Dispatch event
| BS.head line == colon = Continue event -- comment
| otherwise = Continue (applyField field value event)
where
(field, value) = splitField line
colon = toEnum 58
-- | Split a line into @(field, value)@ on the first colon. A leading
-- single space in the value is stripped per spec. A line with no colon
-- has the field as the whole line and an empty value.
splitField :: ByteString -> (ByteString, ByteString)
splitField line =
case BS.elemIndex colon line of
Just i ->
let rawValue = BS.drop (i + 1) line
value = stripOneSpace rawValue
in (BS.take i line, value)
Nothing -> (line, BS.empty)
where
colon = toEnum 58
stripOneSpace :: ByteString -> ByteString
stripOneSpace s = case BS.uncons s of
Just (c, rest) | c == toEnum 32 -> rest -- ' '
_ -> s
applyField :: ByteString -> ByteString -> SSEEvent -> SSEEvent
applyField field value event
| field == "data" =
event
{ sseData =
if T.null (sseData event)
then decodeUtf8Lenient value
else sseData event <> "\n" <> decodeUtf8Lenient value
}
| field == "event" = event {sseEvent = Just (decodeUtf8Lenient value)}
| field == "id" = event {sseId = Just (decodeUtf8Lenient value)}
| field == "retry" = event {sseRetry = parseRetry value}
| otherwise = event
decodeUtf8Lenient :: ByteString -> Text
decodeUtf8Lenient = TE.decodeUtf8With lenientDecode
parseRetry :: ByteString -> Maybe Int
parseRetry v = case readDec (charStr v) of
(n, _) : _ -> Just n
[] -> Nothing
where
-- readDec works on String; convert losslessly.
charStr = map (toEnum . fromIntegral) . BS.unpack
-- | Split the buffer on the first line terminator (@\\n@, optionally
-- preceded by @\\r@). Returns the line (without the terminator) and
-- the remaining buffer, or 'Nothing' if no terminator is present yet.
breakLine :: ByteString -> Maybe (ByteString, ByteString)
breakLine buf =
case BS.elemIndex newline buf of
Nothing -> Nothing
Just i ->
let termStart = if i > 0 && BS.index buf (i - 1) == carriage then i - 1 else i
line = BS.take termStart buf
rest = BS.drop (i + 1) buf
in Just (line, rest)
where
newline = toEnum 10
carriage = toEnum 13
-- | A conduit source over a 'BodyReader' that yields 'SSEEvent's
-- until the body is exhausted. The caller (typically wrapped in
-- 'Data.Conduit.runConduitRes') decides when to stop consuming based
-- on event content; the source itself terminates when the body ends.
sseSource :: (MonadResource m) => BodyReader -> ConduitT () SSEEvent m ()
sseSource reader = do
st <- liftIO (newSSEState reader)
loop st
where
loop st = do
next <- liftIO (readSSEEvent st)
case next of
Just ev -> yield ev >> loop st
Nothing -> pure ()
-- $setup
-- (No doctest setup; the module is exercised via @Test.MLModelSpec@.)