packages feed

servant-event-stream 0.3.2.1 → 0.4.0.0

raw patch · 5 files changed

+988/−108 lines, 5 filesdep +QuickCheckdep +aesondep +attoparsecdep ~bytestringPVP ok

version bump matches the API change (PVP)

Dependencies added: QuickCheck, aeson, attoparsec, servant-client-core

Dependency ranges changed: bytestring

API changes (from Hackage documentation)

+ Servant.API.EventStream: JsonData :: a -> JsonData a
+ Servant.API.EventStream: [eventComment] :: ServerEvent -> !Maybe ByteString
+ Servant.API.EventStream: [eventRetry] :: ServerEvent -> !Maybe Word
+ Servant.API.EventStream: [unJsonData] :: JsonData a -> a
+ Servant.API.EventStream: class FromServerEvent a
+ Servant.API.EventStream: commentEvent :: ByteString -> ServerEvent
+ Servant.API.EventStream: data PostServerSentEvents a
+ Servant.API.EventStream: data ServerEventFraming
+ Servant.API.EventStream: dataEvent :: ByteString -> ServerEvent
+ Servant.API.EventStream: decodeServerEvent :: ByteString -> Either String ServerEvent
+ Servant.API.EventStream: encodeServerEvent :: ServerEvent -> ByteString
+ Servant.API.EventStream: fromServerEvent :: FromServerEvent a => ServerEvent -> Either String a
+ Servant.API.EventStream: instance (Servant.API.EventStream.FromServerEvent chunk, Servant.Client.Core.RunClient.RunStreamingClient m, Servant.API.Stream.FromSourceIO chunk a) => Servant.Client.Core.HasClient.HasClient m (Servant.API.EventStream.PostServerSentEvents a)
+ Servant.API.EventStream: instance (Servant.API.EventStream.FromServerEvent chunk, Servant.Client.Core.RunClient.RunStreamingClient m, Servant.API.Stream.FromSourceIO chunk a) => Servant.Client.Core.HasClient.HasClient m (Servant.API.EventStream.ServerSentEvents a)
+ Servant.API.EventStream: instance (Servant.API.EventStream.FromServerEvent chunk, Servant.Client.Core.RunClient.RunStreamingClient m, Servant.API.Stream.FromSourceIO chunk a, Servant.API.ResponseHeaders.BuildHeadersTo h) => Servant.Client.Core.HasClient.HasClient m (Servant.API.EventStream.PostServerSentEvents (Servant.API.ResponseHeaders.Headers h a))
+ Servant.API.EventStream: instance (Servant.API.EventStream.FromServerEvent chunk, Servant.Client.Core.RunClient.RunStreamingClient m, Servant.API.Stream.FromSourceIO chunk a, Servant.API.ResponseHeaders.BuildHeadersTo h) => Servant.Client.Core.HasClient.HasClient m (Servant.API.EventStream.ServerSentEvents (Servant.API.ResponseHeaders.Headers h a))
+ Servant.API.EventStream: instance (Servant.API.EventStream.ToServerEvent chunk, Servant.API.Stream.ToSourceIO chunk a) => Servant.Server.Internal.HasServer (Servant.API.EventStream.PostServerSentEvents a) context
+ Servant.API.EventStream: instance (Servant.API.EventStream.ToServerEvent chunk, Servant.API.Stream.ToSourceIO chunk a, Servant.API.ResponseHeaders.GetHeaders (Servant.API.ResponseHeaders.Headers h a)) => Servant.Server.Internal.HasServer (Servant.API.EventStream.PostServerSentEvents (Servant.API.ResponseHeaders.Headers h a)) context
+ Servant.API.EventStream: instance Data.Aeson.Types.FromJSON.FromJSON a => Servant.API.EventStream.FromServerEvent (Servant.API.EventStream.JsonData a)
+ Servant.API.EventStream: instance Data.Aeson.Types.ToJSON.ToJSON a => Servant.API.EventStream.ToServerEvent (Servant.API.EventStream.JsonData a)
+ Servant.API.EventStream: instance GHC.Generics.Generic (Servant.API.EventStream.PostServerSentEvents a)
+ Servant.API.EventStream: instance Servant.API.EventStream.FromServerEvent Servant.API.EventStream.ServerEvent
+ Servant.API.EventStream: instance Servant.API.EventStream.FromServerEvent a => Servant.API.ContentTypes.MimeUnrender Servant.API.EventStream.EventStream a
+ Servant.API.EventStream: instance Servant.API.Stream.FramingUnrender Servant.API.EventStream.ServerEventFraming
+ Servant.API.EventStream: instance Servant.Links.HasLink (Servant.API.EventStream.PostServerSentEvents a)
+ Servant.API.EventStream: instance forall k (lang :: k) ftype a. Servant.Foreign.Internal.HasForeignType lang ftype a => Servant.Foreign.Internal.HasForeign lang ftype (Servant.API.EventStream.PostServerSentEvents a)
+ Servant.API.EventStream: jsonData :: FromJSON a => ServerEvent -> Either String a
+ Servant.API.EventStream: jsonEvent :: ToJSON a => Maybe ByteString -> Maybe ByteString -> a -> ServerEvent
+ Servant.API.EventStream: newtype JsonData a
+ Servant.API.EventStream: retryEvent :: Word -> ServerEvent
+ Servant.API.EventStream: serverEvent :: Maybe ByteString -> Maybe ByteString -> ByteString -> ServerEvent
- Servant.API.EventStream: ServerEvent :: !Maybe ByteString -> !Maybe ByteString -> !ByteString -> ServerEvent
+ Servant.API.EventStream: ServerEvent :: !Maybe ByteString -> !Maybe ByteString -> !ByteString -> !Maybe ByteString -> !Maybe Word -> ServerEvent

Files

CHANGELOG.md view
@@ -1,5 +1,40 @@ # Revision history for servant-event-stream +## 0.4.0.0 -- 2026-02-27++* **Breaking**: Add `eventComment` and `eventRetry` fields to `ServerEvent`.+  Existing code using the `ServerEvent` constructor must add the new fields,+  e.g. `ServerEvent typ eid dat Nothing Nothing`.+* **Breaking**: Drop `charset=utf-8` from the `Accept` instance. The content+  type is now `text/event-stream` (no parameters), matching the WHATWG spec.+* **New dependency**: `aeson` (for JSON helpers).+* **New dependency**: `servant-client-core` (for `HasClient` instances).+* Add `serverEvent` convenience constructor matching the old 3-field API for easy migration.+* Add `dataEvent` convenience constructor for simple data-only events.+* Add `commentEvent` convenience constructor for heartbeat keepalives.+* Add `retryEvent` convenience constructor for setting client reconnection delay.+* Export `encodeServerEvent` for direct use outside of Servant.+* Add `FromServerEvent` typeclass and `decodeServerEvent` for parsing SSE events.+* Add `MimeUnrender EventStream` and `FramingUnrender ServerEventFraming` instances+  for consuming SSE streams.+* Add `HasClient` instances for `ServerSentEvents` and `PostServerSentEvents`,+  enabling client-side SSE consumption via `servant-client`.+* Add `PostServerSentEvents` combinator for POST SSE endpoints (e.g. OpenAI's+  streaming API), with `HasServer`, `HasClient`, and `HasForeign` instances.+* Add `JsonData` newtype for deriving `ToServerEvent` and `FromServerEvent` via+  JSON encoding using `DerivingVia`.+* Add `jsonEvent` helper to construct events with JSON-encoded data payloads.+* Add `jsonData` helper to decode JSON from an event's data field.+* Strip leading UTF-8 BOM from SSE input, per the WHATWG spec.+* Export `ServerEventFraming` (needed for `StreamPost` SSE endpoints).+* Always emit at least one `data:` field, even when `eventData` is empty.+  Previously, empty data produced no output and the event was silently+  dropped by clients.+* Sanitize `eventType`, `eventId`, and `eventComment` by stripping CR and LF+  characters to prevent malformed SSE output.+* Strip NULL characters from `eventId` (the SSE spec silently ignores ids+  containing NULL).+ ## 0.3.2.1 -- 2026-02-27  * Add guard space after colon in SSE field encoding. Without it, field values
README.md view
@@ -1,20 +1,87 @@-servant-event-stream-====================+# servant-event-stream -This library adds necessary type combinators to support [Server Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)-within [Servant ecosystem](https://github.com/haskell-servant/).+[Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) (SSE) support for [Servant](https://github.com/haskell-servant/). Stream events from your server to connected clients, or consume third-party SSE streams (such as [OpenAI](https://platform.openai.com/docs/api-reference/streaming)) from Haskell. -Development Environment------------------------+## Quick start -Development environment uses [Nix](https://nixos.org) flakes.+Define a domain type and a `ToServerEvent` instance. Use `jsonEvent` to JSON-encode the data payload, or `serverEvent` for raw bytestrings: -To enter the dev shell, run-```bash-nix develop+```haskell+data ChatEvent+  = ContentDelta Text+  | ContentDone Text+  | ChatDone++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] ``` -You can build the project with+For POST endpoints (e.g. OpenAI chat completions), use `PostServerSentEvents`:++```haskell+type MyApi = "chat" :> ReqBody '[JSON] ChatRequest+                    :> PostServerSentEvents (SourceIO ChatEvent)+```++To consume an SSE stream, provide a `FromServerEvent` instance. Use `jsonData` to decode JSON from the data field:++```haskell+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"+```++For types that serialise entirely as JSON (no event type dispatch), use `DerivingVia`:++```haskell+data Temperature = Temperature { celsius :: Double }+  deriving (Generic, ToJSON, FromJSON)+  deriving (ToServerEvent, FromServerEvent) via JsonData Temperature+```++See the [Haddock documentation](https://hackage.haskell.org/package/servant-event-stream/docs/Servant-API-EventStream.html) for the full API reference, or the [upgrading guide](docs/upgrading-from-0.3.md) if you're coming from 0.3.++## Coming from servant's built-in SSE++Servant 0.20.3.0 introduced its own `ServerSentEvents` type in+`Servant.API.ServerSentEvents`. If you'd like to try this library instead,+here's how the concepts map:++| servant built-in | servant-event-stream |+|---|---|+| `Servant.API.ServerSentEvents` | `Servant.API.EventStream` |+| `ServerSentEvents 'JsonEvent MyEvent` | `ServerSentEvents (SourceIO MyEvent)` |+| `EventKind` (`RawEvent` / `JsonEvent`) | `ToServerEvent` / `FromServerEvent` typeclasses |++This library takes a typeclass approach: `ToServerEvent` and `FromServerEvent`+control how your domain types map to SSE fields, giving you access to event+names, ids, comments, and retry directives in a single `ServerEvent` record.+It also provides `PostServerSentEvents` for POST endpoints, and JSON helpers+(`jsonEvent`, `jsonData`, `JsonData`) for APIs that encode payloads as JSON.++The built-in module focuses on client-side consumption (`HasClient` instances),+while this library provides both `HasServer` and `HasClient` instances.+See the [side-by-side examples](docs/coming-from-servant-sse.md) for a more+detailed walkthrough.++## Development++Uses [Nix](https://nixos.org) flakes for the development environment.+ ```bash-nix build+nix develop       # enter dev shell+nix build         # full build+nix flake check   # CI-equivalent check+cabal test        # run tests (inside dev shell) ```
servant-event-stream.cabal view
@@ -1,6 +1,6 @@ cabal-version: >=1.10 name: servant-event-stream-version: 0.3.2.1+version: 0.4.0.0 stability: alpha synopsis: Servant support for Server-Sent events category: Servant, Web@@ -32,10 +32,14 @@     OverloadedStrings    build-depends:+    aeson >=1.4 && <2.3,+    attoparsec >=0.13 && <0.15,     base >=4.12 && <4.22,-    bytestring >=0.11.1.0 && <0.13,+    bytestring >=0.10.8 && <0.13,     http-media >=0.7.1.3 && <0.9,     lens >=4.17 && <5.4,+    servant >=0.15 && <0.21,+    servant-client-core >=0.15 && <0.21,     servant-foreign >=0.15 && <0.17,     servant-server >=0.15 && <0.21,     text >=1.2.3 && <2.2@@ -52,8 +56,10 @@   hs-source-dirs: tests   default-language: Haskell2010   build-depends:+    aeson >=1.4 && <2.3,     base >=4.12 && <4.22,-    bytestring >=0.11.1.0 && <0.13,+    bytestring >=0.10.8 && <0.13,     hspec >=2.7 && <2.12,+    QuickCheck >=2.12 && <2.16,     servant >=0.15 && <0.21,     servant-event-stream
src/Servant/API/EventStream.hs view
@@ -3,6 +3,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE PolyKinds #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-}@@ -11,68 +12,149 @@  {- | Module: Servant.API.EventStream-Description: Server Sent Events for Servant Streams+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),+t'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 (-  -- * Server-Sent Events+  -- * API combinator+  ServerSentEvents,+  PostServerSentEvents,+  EventStream, -  -- | Event streams are implemented using servant's 'Stream' endpoint.-  -- You should provide a handler that returns a stream of events that implements-  -- 'ToSourceIO' where events have a 'ToServerEvent' instance.-  ---  -- Example:-  ---  -- > type MyApi = "books" :> ServerSentEvents (SourceIO Book)-  -- >-  -- > instance ToServerEvent Book where-  -- >   toServerEvent book = ...-  -- >-  -- > server :: Server MyApi-  -- > server = streamBooks-  -- >   where streamBooks :: Handler (SourceIO Book)-  -- >         streamBooks = pure $ source [book1, ...]+  -- * Events   ServerEvent (..),+  serverEvent,+  dataEvent,+  commentEvent,+  retryEvent,++  -- * Sending events   ToServerEvent (..),-  ServerSentEvents,-  EventStream,+  encodeServerEvent, -  -- * Recommended headers for Server-Sent Events+  -- * Receiving events+  FromServerEvent (..),+  decodeServerEvent, -  -- | This is mostly to guide reverse-proxies like-  --   <https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering nginx>.-  ---  --   Example:-  ---  --   > type MyApi = "books" :> ServerSentEvents (RecommendedEventSourceHeaders (SourceIO Book))-  --   >-  --   > server :: Server MyApi-  --   > server = streamBooks-  --   >   where streamBooks :: Handler (RecommendedEventSourceHeaders (SourceIO Book))-  --   >         streamBooks = pure $ recommendedEventSourceHeaders $ source [book1, ...]+  -- * JSON helpers+  JsonData (..),+  jsonEvent,+  jsonData,++  -- * Recommended headers   RecommendedEventSourceHeaders,   recommendedEventSourceHeaders,++  -- * Framing+  ServerEventFraming, ) where -import Control.Lens+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 Network.HTTP.Media ((//))+ import qualified Servant as S-import qualified Servant.Foreign 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) -{- | A ServerSentEvents endpoint emits an event stream using the format described at-  <https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format>+-- ---------------------------------------------------------------------------+-- 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)@@ -81,48 +163,269 @@   type MkLink (ServerSentEvents a) r = r   toLink toA _ = toA --- | Represents an event sent from the server to the client in Server-Sent Events (SSE).+{- | 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)-  -- ^ Optional field specifying the type of event. Can be used to distinguish between different kinds of events.+  -- ^ The @event:@ field, used by clients to dispatch via @addEventListener@.   , eventId :: !(Maybe LBS.ByteString)-  -- ^ Optional field providing an identifier for the event. Useful for clients to keep track of the last received event.+  -- ^ The @id:@ field. Sent as @Last-Event-ID@ on reconnection.   , eventData :: !LBS.ByteString-  -- ^ The payload or content of the event. This is the main data sent to the client.+  -- ^ 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) -{- | This typeclass allows you to define custom event types that can be-  transformed into the t'ServerEvent' type, which is used to represent events in-  the Server-Sent Events (SSE) protocol.+{- | 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 -{- 1. Field names must not contain LF, CR or COLON characters.-   2. Values must not contain LF or CR characters.-      Multple consecutive `data:` fields will be joined with LFs on the client.--}---- | Encodes a t'ServerEvent' into a 'LBS.ByteString' that can be sent to the client.+-- | 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 "event:" (eventType e)-    <> optional "id:" (eventId e)-    <> mconcat (map (field "data:") (safelines (eventData 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" -  -- discard CR and split LFs into multiple data values+  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') -instance ToServerEvent ServerEvent where-  toServerEvent = id+-- ---------------------------------------------------------------------------+-- 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 =@@ -141,10 +444,72 @@     S.hoistServerWithContext       (S.Proxy :: S.Proxy (S.StreamGet ServerEventFraming EventStream (S.Headers h a))) --- | a helper instance for <https://hackage.haskell.org/package/servant-foreign-0.15.3/docs/Servant-Foreign.html servant-foreign>+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-  (S.HasForeignType lang ftype a) =>-  S.HasForeign lang ftype (ServerSentEvents a)+  (SF.HasForeignType lang ftype a) =>+  SF.HasForeign lang ftype (ServerSentEvents a)   where   type Foreign ftype (ServerSentEvents a) = SFI.Req ftype @@ -157,25 +522,18 @@     retType = SFI.typeFor lang (S.Proxy :: S.Proxy ftype) (S.Proxy :: S.Proxy a)     method = S.reflectMethod (S.Proxy :: S.Proxy S.GET) -{- | A type representation of an event stream. It's responsible for setting proper content-type-  and buffering headers, as well as for providing parser implementations for the streams.-  Read more on <https://docs.servant.dev/en/stable/tutorial/Server.html#streaming-endpoints Servant Streaming Docs>--}-data EventStream--instance S.Accept EventStream where-  contentType _ = "text" // "event-stream" /: ("charset", "utf-8")---- | Recommended headers for Server-Sent Events.-type RecommendedEventSourceHeaders (a :: Type) = S.Headers '[S.Header "X-Accel-Buffering" Text, S.Header "Cache-Control" Text] a---- | Add the recommended headers for Server-Sent Events to the response.-recommendedEventSourceHeaders :: a -> RecommendedEventSourceHeaders a-recommendedEventSourceHeaders = S.addHeader @"X-Accel-Buffering" "no" . S.addHeader @"Cache-Control" "no-store"---- | A framing strategy for Server-Sent Events.-data ServerEventFraming+-- | 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 --- | Frames the server events by joining chunks with a newline.-instance S.FramingRender ServerEventFraming where-  framingRender _ f = fmap (\x -> f x <> "\n")+  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)
tests/Spec.hs view
@@ -1,55 +1,469 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DerivingVia #-}+{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-}  module Main (main) where +import Data.Aeson (FromJSON, ToJSON) import qualified Data.ByteString.Lazy as LBS+import qualified Data.ByteString.Lazy.Char8 as C8 import Data.Proxy (Proxy (..))-import Servant.API.ContentTypes (mimeRender)+import GHC.Generics (Generic)+import Servant.API.ContentTypes (mimeRender, mimeUnrender) import Servant.API.EventStream import Test.Hspec+import Test.Hspec.QuickCheck (prop)+import Test.QuickCheck +-- A domain type mirroring the haddock usage example.+data ChatEvent+  = ContentDelta LBS.ByteString+  | ContentDone LBS.ByteString+  | ChatDone+  deriving (Show, Eq)++instance ToServerEvent ChatEvent where+  toServerEvent (ContentDelta d) =+    serverEvent (Just "response.content.delta") Nothing d+  toServerEvent (ContentDone t) =+    serverEvent (Just "response.content.done") Nothing t+  toServerEvent ChatDone =+    serverEvent (Just "response.done") Nothing ""++instance FromServerEvent ChatEvent where+  fromServerEvent ev = case eventType ev of+    Just "response.content.delta" -> Right (ContentDelta (eventData ev))+    Just "response.content.done" -> Right (ContentDone (eventData ev))+    Just "response.done" -> Right ChatDone+    _ -> Left "unknown event"++-- A simple type using DerivingVia for JSON-based SSE.+data Temperature = Temperature {celsius :: Double}+  deriving (Show, Eq, Generic)+  deriving (ToJSON, FromJSON)+  deriving (ToServerEvent, FromServerEvent) via JsonData Temperature++-- Arbitrary instance for roundtrip property testing.+-- Generates "canonical" ServerEvent values that survive encode/decode:+--   - no CR or LF in single-line fields (type, id, comment)+--   - no NULL in id (spec says to ignore id fields containing NULL)+--   - no CR in data, no trailing LF in data+-- Includes non-ASCII bytes (128-255) to exercise the full byte range.+instance Arbitrary ServerEvent where+  arbitrary =+    ServerEvent+      <$> genMaybe genSafeLine+      <*> genMaybe genSafeId+      <*> genSafeData+      <*> genMaybe genSafeLine+      <*> arbitrary+  shrink (ServerEvent typ eid dat com ret) =+    concat+      [ [ServerEvent Nothing eid dat com ret | Just _ <- [typ]]+      , [ServerEvent typ Nothing dat com ret | Just _ <- [eid]]+      , [ServerEvent typ eid "" com ret | dat /= ""]+      , [ServerEvent typ eid dat Nothing ret | Just _ <- [com]]+      , [ServerEvent typ eid dat com Nothing | Just _ <- [ret]]+      ]++genMaybe :: Gen a -> Gen (Maybe a)+genMaybe g = frequency [(1, pure Nothing), (3, Just <$> g)]++-- | Generate an arbitrary byte that is not CR (0x0D), LF (0x0A), or NULL (0x00).+genSafeByte :: Gen Char+genSafeByte =+  frequency+    [ (10, elements ['a' .. 'z'])+    , (3, pure ' ')+    , (2, pure ':')+    , (1, elements "!@#$%^&*")+    , (4, toEnum <$> choose (128, 255))+    ]++{- | Single-line value with no CR or LF (safe for type and comment fields).+May contain NULL and non-ASCII bytes.+-}+genSafeLine :: Gen LBS.ByteString+genSafeLine = C8.pack <$> listOf genSafeByte++-- | Single-line value with no CR, LF, or NULL (safe for the id field).+genSafeId :: Gen LBS.ByteString+genSafeId = C8.pack <$> listOf (genSafeByte `suchThat` (/= '\0'))++{- | Multi-line data: no CR, no trailing LF.+Lines may contain non-ASCII bytes and NULL.+-}+genSafeData :: Gen LBS.ByteString+genSafeData =+  frequency+    [ (1, pure "")+    ,+      ( 5+      , do+          leading <- listOf genSafeLine+          final <- genSafeLine `suchThat` (not . LBS.null)+          pure (C8.intercalate "\n" (leading ++ [final]))+      )+    ]+ render :: ServerEvent -> LBS.ByteString render = mimeRender (Proxy :: Proxy EventStream) +decode :: LBS.ByteString -> Either String ServerEvent+decode = mimeUnrender (Proxy :: Proxy EventStream)++-- | UTF-8 Byte Order Mark (U+FEFF), encoded as 3 bytes: @EF BB BF@.+bom :: LBS.ByteString+bom = "\xEF\xBB\xBF"+ main :: IO () main = hspec $ do   describe "MimeRender EventStream ServerEvent" $ do     it "encodes data-only event" $-      render (ServerEvent Nothing Nothing "hello")+      render (dataEvent "hello")         `shouldBe` "data: hello\n"      it "encodes event with all fields" $-      render (ServerEvent (Just "update") (Just "1") "payload")-        `shouldBe` "event: update\nid: 1\ndata: payload\n"+      render (ServerEvent (Just "update") (Just "1") "payload" (Just "note") (Just 5000))+        `shouldBe` ": note\nretry: 5000\nevent: update\nid: 1\ndata: payload\n"      it "encodes multi-line data as multiple data fields" $-      render (ServerEvent Nothing Nothing "line1\nline2\nline3")+      render (serverEvent Nothing Nothing "line1\nline2\nline3")         `shouldBe` "data: line1\ndata: line2\ndata: line3\n"      it "preserves leading space in data value" $-      render (ServerEvent Nothing Nothing " How")+      render (serverEvent Nothing Nothing " How")         `shouldBe` "data:  How\n"      it "preserves leading space in event type" $-      render (ServerEvent (Just " custom") Nothing "x")+      render (serverEvent (Just " custom") Nothing "x")         `shouldBe` "event:  custom\ndata: x\n"      it "preserves leading space in event id" $-      render (ServerEvent Nothing (Just " 42") "x")+      render (serverEvent Nothing (Just " 42") "x")         `shouldBe` "id:  42\ndata: x\n" -    it "encodes empty data as empty output" $-      render (ServerEvent Nothing Nothing "")-        `shouldBe` ""+    it "encodes empty data as a single data field" $+      render (serverEvent Nothing Nothing "")+        `shouldBe` "data: \n" +    it "encodes empty data with event type" $+      render (serverEvent (Just "ping") Nothing "")+        `shouldBe` "event: ping\ndata: \n"++    it "handles trailing newline in data" $+      render (dataEvent "hello\n")+        `shouldBe` "data: hello\n"++    it "handles data that is only a newline" $+      render (dataEvent "\n")+        `shouldBe` "data: \n"++    it "handles data that is only CR" $+      render (dataEvent "\r")+        `shouldBe` "data: \n"+     it "strips CR characters from data" $-      render (ServerEvent Nothing Nothing "hello\r\nworld")+      render (serverEvent Nothing Nothing "hello\r\nworld")         `shouldBe` "data: hello\ndata: world\n" +    it "emits empty event type" $+      render (serverEvent (Just "") Nothing "x")+        `shouldBe` "event: \ndata: x\n"++    it "emits empty event id" $+      render (serverEvent Nothing (Just "") "x")+        `shouldBe` "id: \ndata: x\n"+     it "omits event field when Nothing" $-      render (ServerEvent Nothing (Just "1") "x")+      render (serverEvent Nothing (Just "1") "x")         `shouldBe` "id: 1\ndata: x\n"      it "omits id field when Nothing" $-      render (ServerEvent (Just "ping") Nothing "x")+      render (serverEvent (Just "ping") Nothing "x")         `shouldBe` "event: ping\ndata: x\n"++  describe "comment support" $ do+    it "encodes a comment-only event" $+      render (commentEvent "keepalive")+        `shouldBe` ": keepalive\ndata: \n"++    it "encodes comment with data" $+      render (ServerEvent Nothing Nothing "hello" (Just "debug") Nothing)+        `shouldBe` ": debug\ndata: hello\n"++    it "encodes empty comment" $+      render (commentEvent "")+        `shouldBe` ": \ndata: \n"++    it "strips CR and LF from comment" $+      render (commentEvent "line1\r\nline2")+        `shouldBe` ": line1line2\ndata: \n"++  describe "retry support" $ do+    it "encodes a retry-only event" $+      render (retryEvent 3000)+        `shouldBe` "retry: 3000\ndata: \n"++    it "encodes retry zero" $+      render (retryEvent 0)+        `shouldBe` "retry: 0\ndata: \n"++    it "encodes retry with data" $+      render (ServerEvent Nothing Nothing "hello" Nothing (Just 1000))+        `shouldBe` "retry: 1000\ndata: hello\n"++  describe "field sanitization" $ do+    it "strips LF from event type" $+      render (serverEvent (Just "bad\ntype") Nothing "x")+        `shouldBe` "event: badtype\ndata: x\n"++    it "strips CR from event type" $+      render (serverEvent (Just "bad\rtype") Nothing "x")+        `shouldBe` "event: badtype\ndata: x\n"++    it "strips CRLF from event id" $+      render (serverEvent Nothing (Just "bad\r\nid") "x")+        `shouldBe` "id: badid\ndata: x\n"++    it "strips NULL from event id" $+      render (serverEvent Nothing (Just "abc\0def") "x")+        `shouldBe` "id: abcdef\ndata: x\n"++  describe "convenience constructors" $ do+    it "dataEvent is equivalent to serverEvent Nothing Nothing" $+      dataEvent "hello" `shouldBe` serverEvent Nothing Nothing "hello"++    it "commentEvent sets comment field" $+      commentEvent "hi" `shouldBe` ServerEvent Nothing Nothing "" (Just "hi") Nothing++    it "retryEvent sets retry field" $+      retryEvent 5000 `shouldBe` ServerEvent Nothing Nothing "" Nothing (Just 5000)++  describe "decodeServerEvent" $ do+    it "decodes data-only event" $+      decode "data: hello\n"+        `shouldBe` Right (dataEvent "hello")++    it "decodes event with all fields" $+      decode ": note\nretry: 5000\nevent: update\nid: 1\ndata: payload\n"+        `shouldBe` Right (ServerEvent (Just "update") (Just "1") "payload" (Just "note") (Just 5000))++    it "decodes multi-line data" $+      decode "data: line1\ndata: line2\ndata: line3\n"+        `shouldBe` Right (dataEvent "line1\nline2\nline3")++    it "strips exactly one leading space from values" $+      decode "data: hello\n"+        `shouldBe` Right (dataEvent "hello")++    it "handles no space after colon" $+      decode "data:hello\n"+        `shouldBe` Right (dataEvent "hello")++    it "preserves leading space beyond the first" $+      decode "data:  How\n"+        `shouldBe` Right (dataEvent " How")++    it "decodes comment" $+      decode ": keepalive\ndata: x\n"+        `shouldBe` Right (ServerEvent Nothing Nothing "x" (Just "keepalive") Nothing)++    it "decodes comment with space stripping" $+      decode ":keepalive\n"+        `shouldBe` Right (ServerEvent Nothing Nothing "" (Just "keepalive") Nothing)++    it "decodes retry" $+      decode "retry: 3000\ndata: x\n"+        `shouldBe` Right (ServerEvent Nothing Nothing "x" Nothing (Just 3000))++    it "ignores retry with non-digit value" $+      decode "retry: abc\ndata: x\n"+        `shouldBe` Right (dataEvent "x")++    it "ignores retry with empty value" $+      decode "retry:\ndata: x\n"+        `shouldBe` Right (dataEvent "x")++    it "ignores id containing NULL" $+      decode "id: abc\0def\ndata: x\n"+        `shouldBe` Right (dataEvent "x")++    it "decodes CRLF line endings" $+      decode "data: hello\r\ndata: world\r\n"+        `shouldBe` Right (dataEvent "hello\nworld")++    it "decodes empty data" $+      decode "data: \n"+        `shouldBe` Right (dataEvent "")++    it "decodes empty data without space" $+      decode "data:\n"+        `shouldBe` Right (dataEvent "")++    it "decodes event type" $+      decode "event: update\ndata: x\n"+        `shouldBe` Right (serverEvent (Just "update") Nothing "x")++    it "decodes event id" $+      decode "id: 42\ndata: x\n"+        `shouldBe` Right (serverEvent Nothing (Just "42") "x")++    it "ignores unknown fields" $+      decode "foo: bar\ndata: hello\n"+        `shouldBe` Right (dataEvent "hello")++    it "handles field name with no colon as empty value" $+      decode "data\n"+        `shouldBe` Right (dataEvent "")++    it "uses last comment when multiple present" $+      decode ": first\n: second\ndata: x\n"+        `shouldBe` Right (ServerEvent Nothing Nothing "x" (Just "second") Nothing)++    it "uses last event type when multiple present" $+      decode "event: a\nevent: b\ndata: x\n"+        `shouldBe` Right (serverEvent (Just "b") Nothing "x")++    it "decodes empty input as empty event" $+      decode ""+        `shouldBe` Right (ServerEvent Nothing Nothing "" Nothing Nothing)++  describe "BOM handling" $ do+    it "strips UTF-8 BOM from input" $+      decode (bom <> "data: hello\n")+        `shouldBe` Right (dataEvent "hello")++    it "strips BOM with event fields" $+      decode (bom <> "event: update\ndata: x\n")+        `shouldBe` Right (serverEvent (Just "update") Nothing "x")++    it "handles input that is only a BOM" $+      decode bom+        `shouldBe` Right (ServerEvent Nothing Nothing "" Nothing Nothing)++  describe "roundtrip (encode then decode)" $ do+    it "roundtrips data-only event" $+      decode (render (dataEvent "hello"))+        `shouldBe` Right (dataEvent "hello")++    it "roundtrips event with type and id" $+      decode (render (serverEvent (Just "update") (Just "1") "payload"))+        `shouldBe` Right (serverEvent (Just "update") (Just "1") "payload")++    it "roundtrips event with all fields" $+      let e = ServerEvent (Just "update") (Just "1") "payload" (Just "note") (Just 5000)+       in decode (render e) `shouldBe` Right e++    it "roundtrips multi-line data" $+      decode (render (dataEvent "line1\nline2\nline3"))+        `shouldBe` Right (dataEvent "line1\nline2\nline3")++    it "roundtrips comment event" $+      decode (render (commentEvent "keepalive"))+        `shouldBe` Right (commentEvent "keepalive")++    it "roundtrips retry event" $+      decode (render (retryEvent 3000))+        `shouldBe` Right (retryEvent 3000)++    it "roundtrips empty data" $+      decode (render (dataEvent ""))+        `shouldBe` Right (dataEvent "")++    it "roundtrips event with leading space in data" $+      decode (render (dataEvent " hello"))+        `shouldBe` Right (dataEvent " hello")++  describe "custom type (ToServerEvent / FromServerEvent)" $ do+    let renderC :: ChatEvent -> LBS.ByteString+        renderC = mimeRender (Proxy :: Proxy EventStream)+        decodeC :: LBS.ByteString -> Either String ChatEvent+        decodeC = mimeUnrender (Proxy :: Proxy EventStream)++    it "encodes ContentDelta" $+      renderC (ContentDelta "Hel")+        `shouldBe` "event: response.content.delta\ndata: Hel\n"++    it "encodes ContentDone" $+      renderC (ContentDone "Hello!")+        `shouldBe` "event: response.content.done\ndata: Hello!\n"++    it "encodes ChatDone" $+      renderC ChatDone+        `shouldBe` "event: response.done\ndata: \n"++    it "decodes ContentDelta" $+      decodeC "event: response.content.delta\ndata: Hel\n"+        `shouldBe` Right (ContentDelta "Hel")++    it "decodes ContentDone" $+      decodeC "event: response.content.done\ndata: Hello!\n"+        `shouldBe` Right (ContentDone "Hello!")++    it "decodes ChatDone" $+      decodeC "event: response.done\ndata: \n"+        `shouldBe` Right ChatDone++    it "roundtrips ContentDelta" $+      decodeC (renderC (ContentDelta "Hel"))+        `shouldBe` Right (ContentDelta "Hel")++    it "roundtrips ContentDone" $+      decodeC (renderC (ContentDone "Hello!"))+        `shouldBe` Right (ContentDone "Hello!")++    it "roundtrips ChatDone" $+      decodeC (renderC ChatDone) `shouldBe` Right ChatDone++    it "rejects unknown event type" $+      decodeC "event: other\ndata: hello\n"+        `shouldBe` Left "unknown event"++    it "rejects missing event type" $+      decodeC "data: hello\n"+        `shouldBe` Left "unknown event"++  describe "JSON helpers" $ do+    let renderT :: Temperature -> LBS.ByteString+        renderT = mimeRender (Proxy :: Proxy EventStream)+        decodeT :: LBS.ByteString -> Either String Temperature+        decodeT = mimeUnrender (Proxy :: Proxy EventStream)++    it "encodes via JsonData" $+      renderT (Temperature 36.6)+        `shouldBe` "data: {\"celsius\":36.6}\n"++    it "decodes via JsonData" $+      decodeT "data: {\"celsius\":36.6}\n"+        `shouldBe` Right (Temperature 36.6)++    it "roundtrips via JsonData" $+      decodeT (renderT (Temperature 100.0))+        `shouldBe` Right (Temperature 100.0)++    it "jsonData decodes event data as JSON" $+      jsonData (serverEvent (Just "update") Nothing "{\"celsius\":20.0}")+        `shouldBe` Right (Temperature 20.0)++    it "jsonData reports JSON parse errors" $+      (jsonData (dataEvent "not json") :: Either String Temperature)+        `shouldSatisfy` \case Left _ -> True; Right _ -> False++    it "jsonEvent sets event type and JSON-encodes data" $+      jsonEvent (Just "update") Nothing (Temperature 25.5)+        `shouldBe` serverEvent (Just "update") Nothing "{\"celsius\":25.5}"++    it "jsonEvent sets event id" $+      jsonEvent Nothing (Just "42") (Temperature 25.5)+        `shouldBe` serverEvent Nothing (Just "42") "{\"celsius\":25.5}"++    it "jsonEvent roundtrips with jsonData" $+      let ev = jsonEvent (Just "update") (Just "1") (Temperature 37.0)+       in jsonData ev `shouldBe` Right (Temperature 37.0)++  describe "roundtrip properties" $ do+    prop "encode then decode is identity for canonical ServerEvent" $ \e ->+      decode (render e) === Right (e :: ServerEvent)