packages feed

request-0.5.0.0: src/Network/HTTP/Request/Internal/Sse.hs

{-# LANGUAGE OverloadedStrings #-}

module Network.HTTP.Request.Internal.Sse
  ( SseParser,
    newSseParser,
    feedSse,
  )
where

import qualified Data.ByteString as BS
import Data.List (foldl')
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Network.HTTP.Request.Internal.Types (SseEvent (..))

-- | Incremental SSE parser state. Feed it chunks with 'feedSse'. Whatever is
-- still buffered when the stream ends is an incomplete event and is dropped.
data SseParser = SseParser
  { -- | Whether the leading UTF-8 BOM has been looked for.
    bomChecked :: Bool,
    -- | Pieces of the current unterminated line, newest first.
    pendingLine :: [BS.ByteString],
    -- | The previous line ended with CR, so a directly following LF belongs to it.
    skipLf :: Bool,
    -- | Fields of the current event, newest first.
    pendingFields :: [(T.Text, T.Text)]
  }

newSseParser :: SseParser
newSseParser = SseParser False [] False []

-- | Feed one chunk and get the events it completed. Each chunk is scanned
-- only once, so the cost is linear in the size of the stream.
feedSse :: SseParser -> BS.ByteString -> (SseParser, [SseEvent])
feedSse parser chunk
  | bomChecked parser = go parser chunk []
  | BS.length buf < BS.length bom && buf `BS.isPrefixOf` bom = (parser {pendingLine = [buf]}, [])
  | otherwise = go parser {bomChecked = True, pendingLine = []} (fromMaybe buf (BS.stripPrefix bom buf)) []
  where
    bom = "\xEF\xBB\xBF"
    buf = BS.concat (reverse (chunk : pendingLine parser))

    go p bs acc
      | BS.null bs = (p, reverse acc)
      | skipLf p = go p {skipLf = False} (if BS.head bs == lf then BS.drop 1 bs else bs) acc
      | BS.null rest = (p {pendingLine = h : pendingLine p}, reverse acc)
      | BS.null line = go p' {pendingFields = []} (BS.drop 1 rest) (maybe acc (: acc) event)
      | otherwise = go p' {pendingFields = maybe id (:) (parseSseField line) (pendingFields p)} (BS.drop 1 rest) acc
      where
        (h, rest) = BS.break (\c -> c == cr || c == lf) bs
        line = BS.concat (reverse (h : pendingLine p))
        p' = p {pendingLine = [], skipLf = BS.head rest == cr}
        event = buildSseEvent (reverse (pendingFields p))

    cr = 13
    lf = 10

parseSseField :: BS.ByteString -> Maybe (T.Text, T.Text)
parseSseField raw
  | T.null line = Nothing
  | T.head line == ':' = Nothing
  | otherwise =
      let (name, rest) = T.breakOn ":" line
          value
            | T.null rest = ""
            | otherwise = case T.stripPrefix " " (T.drop 1 rest) of
                Just v -> v
                Nothing -> T.drop 1 rest
       in Just (name, value)
  where
    line = T.decodeUtf8Lenient raw

buildSseEvent :: [(T.Text, T.Text)] -> Maybe SseEvent
buildSseEvent fields =
  let dataFields = [v | (k, v) <- fields, k == "data"]
      dataVal = T.intercalate "\n" dataFields
      lastField name = foldl' (\current (k, v) -> if k == name then Just v else current) Nothing fields
      typeVal = lastField "event"
      idVal = lastField "id"
   in if null dataFields then Nothing else Just (SseEvent dataVal typeVal idVal)