Server Sent Events (sse) with Snap

Good morning my Haskell friends,

I am trying to use server sent events with snap and just can’t seem to get there. Here is the code:

#!/usr/bin/env cabal
{- cabal:
build-depends: base
  , snap
  , snap-core
  , text
  , bytestring
  , neat-interpolation
-}

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
{-# LANGUAGE OverloadedStrings #-}

-- ghci -package snap -package snap-core -package text -package bytestring -package neat-interpolation s.hs

import Control.Applicative
import Snap
import Snap.Util.FileServe
import Data.Text
import Data.Text.Encoding
import Data.ByteString
import Control.Monad.IO.Class
import NeatInterpolation
import Control.Concurrent

main :: IO ()
main = quickHttpServe site

site :: Snap ()
site =
    ifTop (writeBS indexHtml) <|>
    route [ ("foo", writeBS "bar")
          , ("favicon.ico", return ())
          , ("sse", handleSse)
          ] <|>
    dir "static" (serveDirectory ".")

handleSse :: Snap ()
handleSse = do
    go 0
      where
        toBS :: String -> ByteString
        toBS = encodeUtf8 . Data.Text.pack
        go n = do
          let msg = "data: " <> show n <> "\n\n"
          liftIO $ putStrLn msg
          liftIO $ threadDelay 1000000 -- Delay for 1 second
          modifyResponse $ setHeader "Content-Type" "text/event-stream"
                         . setHeader "Cache-Control" "no-cache"
                         . setHeader "Connection" "keep-alive"
          writeBS (toBS msg)
--          getResponse >>= finishWith
          go (n + 1)

indexHtml :: ByteString  
indexHtml = encodeUtf8 [trimming|
<!DOCTYPE html>
<head>
<script>
var source;
function c() {
  console.log("enter c");
  source = new EventSource('sse'); 

  source.addEventListener('message', (e) => {
    console.log('messge: ' +  e.data);
  });

  source.addEventListener('open', (e) => {
    console.log("connection opened");
  });

  source.addEventListener('error', (e) => {
    if (e.readyState == EventSource.CLOSED) {
    console.log("connection closed");
    }
  });
  console.log("exit c");
};
</script>
</head>
<body>
<button onclick="c()">click here to connect</button>
</body
|]

The problem seems to that with {getResponse >>= finishWith} commented out, nothing is received by the browser (firefox), and when {getResponse >>= finishWith} executing, the connection is closed and the sse endpoint is re-opened. I have tried probably 20 different permutations of various things with no joy. Has anyone ever gotten server sent events to work with Snap? Please help, hilfe, ayuda, помощь.

Best wishes,
Henry Laxen

Funny, I did SSE with Servant just the other day: my post.

At one point, I experienced the same thing as you did - the client didn’t seem to receive anything. However, when I forcefully shut down the server all sent messages were displayed! It turns out that Firefox wouldn’t show the continuous stream of messages until the connection was closed.

You can check out the final handler sseH here: link to handler, source. I know nothing about Snap, however.

1 Like

Caveats regarding SSE WebSockets vs Server-Sent-Events vs Long-Polling vs WebRTC vs WebTransport | Lobsters

1 Like

Good morning again Haskell friends,

First I would like to thank everyone who took the time to reply to my initial message. Chris thank you for pointing out the limitations of sse over HTTP 1.1. In my particular use case, that shouldn’t matter. Also thank you to Jasper Van der Jeugt who wrote the websockets-snap package which I largely stole to get SSE working. I’m posting the result here, in case anyone has a similar issue. To run this, just copy it into an empty directory and type ./Main.hs (I’m assuming cabal is in your search path). Then open a browser window to localhost:8000 after you click on the button, you should see the current date and time updating every second in the body of your window. The updates are being made with server sent events.

#!/usr/bin/env cabal
{- cabal:
build-depends: base
  , snap
  , snap-core
  , text
  , time
  , bytestring
  , bytestring-builder
  , io-streams
  , neat-interpolation
-}

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
{-# LANGUAGE OverloadedStrings #-}

-- ghci -package snap -package snap-core -package text -package time -package bytestring -package bytestring-builder -package io-streams -package neat-interpolation Main.hs

import Control.Applicative ( Alternative((<|>)) )
import Control.Concurrent ( threadDelay )
import Control.Monad ( forever )
import Control.Monad.IO.Class ( MonadIO(liftIO) )
import Data.ByteString ( ByteString )
import Data.Text ( pack )
import Data.Text.Encoding ( encodeUtf8 )
import Data.Time.Clock ( getCurrentTime )
import NeatInterpolation ( trimming )
import Snap
    ( escapeHttp,
      getRequest,
      ifTop,
      writeBS,
      emptyResponse,
      setHeader,
      route,
      simpleHttpServe,
      commandLineConfig,
      defaultConfig,
      Snap,
      Config )
import Snap.Util.FileServe ()
import System.IO.Streams ()
import qualified Data.ByteString.Builder as BSBuilder
    ( byteString )
import qualified Data.ByteString.Builder.Extra as BSBuilder
    ( flush )
import qualified System.IO.Streams as Streams ( write )

main :: IO ()
main = do
  conf <- commandLineConfig (defaultConfig :: Config Snap a)
  simpleHttpServe conf site

site :: Snap ()
site =
    ifTop (writeBS indexHtml) <|>
    route [ ("favicon.ico", return ())
          , ("sse", handleSse)
          ] 

toBS :: String -> ByteString
toBS = encodeUtf8 . Data.Text.pack

handleSse :: Snap ()
handleSse = do
  liftIO $ putStrLn "enter handleSse"
  r <- getRequest
  liftIO . print $ r
  liftIO $ putStrLn "Escaping HTTP"
  
  escapeHttp $ \tickle _ writeEnd -> do
    tickle (max (60*60))  -- keep it open for an hour
    Streams.write (Just (BSBuilder.byteString headersBS)) writeEnd
    Streams.write (Just BSBuilder.flush) writeEnd
    forever $ do
      threadDelay 1000000 -- Delay for 1 second
      now <- getCurrentTime
      let
        msg = toBS $ "data: " <> show now <> "\n\n"
      Streams.write (Just (BSBuilder.byteString msg)) writeEnd
      Streams.write (Just BSBuilder.flush) writeEnd

headersBS :: ByteString
headersBS = toBS . show $
    setHeader "Content-Type" "text/event-stream"
  . setHeader "Cache-Control" "no-cache"
  . setHeader "Connection" "keep-alive" $ emptyResponse

indexHtml :: ByteString  
indexHtml = encodeUtf8 $ [trimming|
<!DOCTYPE html>
<head>
</head>
<body>
<button onclick="c()">click here to connect</button>
<script>
function c() {
  var source;
  console.log("enter c");
  source = new EventSource('sse'); 
  console.log("source " + source);
  source.addEventListener('message', (e) => {
    document.body.innerHTML = e.data;
  });

  source.addEventListener('open', (e) => {
    console.log("connection opened " + JSON.stringify(e));
  });

  source.addEventListener('error', (e) => {
    console.log("connection closed " + JSON.stringify(e));
    });
  console.log("exit c");
  };
</script>
</body
|]

Now I still have a mystery here, which I cannot explain. If I try to run this with the ghci comment on line 18, I get bunch of compiler errors which make no sense to me:

Main.hs:74:59: error: [GHC-83865]
    • Couldn't match expected type: System.IO.Streams.Internal.OutputStream
                                      Data.ByteString.Builder.Internal.Builder
                  with actual type: io-streams-1.5.2.2:System.IO.Streams.Internal.OutputStream
                                      Data.ByteString.Builder.Internal.Builder
      NB: ‘System.IO.Streams.Internal.OutputStream’
            is defined in ‘System.IO.Streams.Internal’
                in package ‘io-streams-1.5.2.2’
          ‘io-streams-1.5.2.2:System.IO.Streams.Internal.OutputStream’
            is defined in ‘System.IO.Streams.Internal’
                in package ‘io-streams-1.5.2.2’
    • In the second argument of ‘Streams.write’, namely ‘writeEnd’
      In a stmt of a 'do' block:
        Streams.write (Just (BSBuilder.byteString headersBS)) writeEnd
      In the expression:
        do tickle (max (60 * 60))
           Streams.write (Just (BSBuilder.byteString headersBS)) writeEnd
           Streams.write (Just BSBuilder.flush) writeEnd
           forever
             $ do threadDelay 1000000
                  now <- getCurrentTime
                  ....



ghci --version
The Glorious Glasgow Haskell Compilation System, version 9.6.3

cabal --version
cabal-install version 3.10.2.1
compiled using version 3.10.2.1 of the Cabal library

If someone could explain what causes that error, I would be grateful. It does not happen when I just run the program from the command line with ./Main.hs

I hope this will be helpful to someone in the future.
Best wishes,
Henry Laxen

try changing this import line

import System.IO.Streams ()

to

import System.IO.Streams (OutputStream)

?

@henrylaxen What I think is going on: your program imports the OutputStream in two different ways, one of them via a “hidden” module (a dependency of your program imports it) of io-streams. By saying import .. () you are telling GHC to not import OutputStream from Sytem.IO.Streams, but some of the functions you use from io-streams do need it to be imported from that module, which causes the GHC error.

Thank you Ocramz for your suggestion. I tried adding both OutputStream and OutputStream(…) to the import list, but neither made any difference.

@henrylaxen that’s odd.

Another hypothesis: declaring dependencies in your GHCi command interferes with how Cabal does it at the top of the file. To be honest I never have single-file Haskell projects like this so I can’t comment on this particular failure mode, but the error message points at a mismatch in versions, or the compiler trying to pick from a “hidden” dependency.

Could you try setting up a proper cabal with this file as Main, dependencies only declared in the .cabal file, and build it with cabal build?

Yes, I have done that and it works fine. Here is my cabal file:

cabal-version:      2.4
name:               htmxExperiments
version:            0.1.0.0
license:            NONE
author:             Henry Laxen
maintainer:         nadine.and.henry@pobox.com
category:           Web
build-type:         Simple
extra-doc-files:    CHANGELOG.md
common warnings
    ghc-options: -Wall
Executable hx
    main-is: Main.hs           
    build-depends:
        base
      , bytestring
      , bytestring-builder
      , hostname
      , io-streams
      , neat-interpolation
      , snap
      , snap-core
      , text
      , time
    hs-source-dirs:
        src
    default-language: Haskell2010
    ghc-options:
      -O0
      -Wall
      -funbox-strict-fields
      -fwarn-tabs
      -threaded
      -ddump-minimal-imports
      -fno-warn-unused-imports

Sanity has been restored :slight_smile:

Ok, but the command:

ghci -package base -package snap -package snap-core -package text -package time -package bytestring -package bytestring-builder -package io-streams -package neat-interpolation Main.hs

still fails with:

Main.hs:73:59: error: [GHC-83865]
    • Couldn't match expected type: OutputStream
                                      Data.ByteString.Builder.Internal.Builder
                  with actual type: io-streams-1.5.2.2:System.IO.Streams.Internal.OutputStream
                                      Data.ByteString.Builder.Internal.Builder

Shouldn’t it compile if cabal build compiles?

You should probably use cabal repl instead of ghci, I think the latter doesn’t use the information from .cabal at all.