Server Sent Events

Welcome

Server-sent events (SSE) make it possible to stream events to a client application. They are appropriate when you only need a unidirectional flow of events from the server to the client (if you need a bidirectional connection, then WebSockets are a better choice).

SSEs have largely been eclipsed by WebSockets, which Pedestal also supports.

What You Will Learn

After reading this guide, you will be able to:

  • Configure Pedestal to accept SSE connections.

  • Send events from Pedestal over the connection using core.async.

  • Connect to the server-sent event stream from a JavaScript client.

Guide Assumptions

This guide is for intermediate users who have worked through some sample applications or the first few Hello World guides. In particular, you should know how to run a build, start a REPL, and start and stop your server.

You do not need to know any other Clojure web frameworks, but if you do, you may find some of the comparisons useful. If not, that’s OK — just skip those sections.

Getting Help if You’re Stuck

We’ll take this in small steps. If you get stuck at any point in this guide, please submit an issue about this guide or, hop over to the Pedestal Users mailing list and raise your hand there. You can also get help from the #pedestal channel on the Clojurians Slack.

Where We Are Going

We’ll be creating a new Pedestal service project, adding a simple route to stream a counter using server-sent events, and adding a simple HTML page that uses a JavaScript EventSource to open a connection to the stream.

Before We Begin

This guide will use Leiningen to generate and build a template project. If you haven’t already installed it, please take a few minutes to set it up.

Generate Our New Project

$ lein new pedestal-service server-sent-events
Generating a pedestal-service application called server-sent-events.
$ cd server-sent-events

Add Hiccup

We will be using Hiccup to generate our index page, so we will need to add the dependency in our project.clj.

project.clj
  :dependencies [[org.clojure/clojure "1.10.1"]
                 [io.pedestal/pedestal.service "0.6.1"]

                 ;; Remove this line and uncomment one of the next lines to
                 ;; use Immutant or Tomcat instead of Jetty:
                 [io.pedestal/pedestal.jetty "0.6.1"]
                 ;; [io.pedestal/pedestal.immutant "0.6.1"]
                 ;; [io.pedestal/pedestal.tomcat "0.6.1"]

                 [ch.qos.logback/logback-classic "1.2.10" :exclusions [org.slf4j/slf4j-api]]
                 [org.slf4j/jul-to-slf4j "1.7.35"]
                 [org.slf4j/jcl-over-slf4j "1.7.35"]
                 [org.slf4j/log4j-over-slf4j "1.7.35"]
                 ;; Add hiccup dependency
                 [hiccup "1.0.5"]]

Clean Up

Here’s the service.clj file generated from the template:

src/server_sent_events/service.clj
(ns server-sent-events.service
  (:require [io.pedestal.http :as http]
            [io.pedestal.http.route :as route]
            [io.pedestal.http.body-params :as body-params]
            [ring.util.response :as ring-resp]))

(defn about-page
  [request]
  (ring-resp/response (format "Clojure %s - served from %s"
                              (clojure-version)
                              (route/url-for ::about-page))))

(defn home-page
  [request]
  (ring-resp/response "Hello World!"))

;; Defines "/" and "/about" routes with their associated :get handlers.
;; The interceptors defined after the verb map (e.g., {:get home-page}
;; apply to / and its children (/about).
(def common-interceptors [(body-params/body-params) http/html-body])

;; Tabular routes
(def routes #{["/" :get (conj common-interceptors `home-page)]
              ["/about" :get (conj common-interceptors `about-page)]})

;; Map-based routes
;(def routes `{"/" {:interceptors [(body-params/body-params) http/html-body]
;                   :get home-page
;                   "/about" {:get about-page}}})

;; Terse/Vector-based routes
;(def routes
;  `[[["/" {:get home-page}
;      ^:interceptors [(body-params/body-params) http/html-body]
;      ["/about" {:get about-page}]]]])


;; Consumed by server-sent-events.server/create-server
;; See http/default-interceptors for additional options you can configure
(def service {:env :prod
              ;; You can bring your own non-default interceptors. Make
              ;; sure you include routing and set it up right for
              ;; dev-mode. If you do, many other keys for configuring
              ;; default interceptors will be ignored.
              ;; ::http/interceptors []
              ::http/routes routes

              ;; Uncomment next line to enable CORS support, add
              ;; string(s) specifying scheme, host and port for
              ;; allowed source(s):
              ;;
              ;; "http://localhost:8080"
              ;;
              ;;::http/allowed-origins ["scheme://host:port"]

              ;; Tune the Secure Headers
              ;; and specifically the Content Security Policy appropriate to your service/application
              ;; For more information, see: https://content-security-policy.com/
              ;;   See also: https://github.com/pedestal/pedestal/issues/499
              ;;::http/secure-headers {:content-security-policy-settings {:object-src "'none'"
              ;;                                                          :script-src "'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:"
              ;;                                                          :frame-ancestors "'none'"}}

              ;; Root for resource interceptor that is available by default.
              ::http/resource-path "/public"

              ;; Either :jetty, :immutant or :tomcat (see comments in project.clj)
              ;;  This can also be your own chain provider/server-fn -- http://pedestal.io/reference/architecture-overview#_chain_provider
              ::http/type :jetty
              ;;::http/host "localhost"
              ::http/port 8080
              ;; Options to pass to the container (Jetty)
              ::http/container-options {:h2c? true
                                        :h2? false
                                        ;:keystore "test/hp/keystore.jks"
                                        ;:key-password "password"
                                        ;:ssl-port 8443
                                        :ssl? false}})

Let’s simplify things by commenting out the 'Terse/Vector-based routes' section and removing unnecessary code.

src/server_sent_events/service.clj
(ns server-sent-events.service
  (:require [io.pedestal.http :as http]
            [io.pedestal.http.route :as route]
            [io.pedestal.http.body-params :as body-params]
            [ring.util.response :as ring-resp]))

(defn home-page
  [request]
  (ring-resp/response "Hello World!"))

(def routes
  `[[["/" {:get home-page}]]])

(def service {:env :prod
              ::http/routes routes
              ::http/type :jetty
              ::http/port 8080})

Server-Sent Event Route

Now let’s add our server-sent event route. First Lets require SSE and core.async.

src/server_sent_events/service.clj
(ns server-sent-events.service
  (:require [io.pedestal.http :as http]
            [io.pedestal.http.route :as route]
    ;; require SSE
            [io.pedestal.http.sse :as sse]
            [io.pedestal.http.body-params :as body-params]
            [ring.util.response :as ring-resp]
    ;; require core.async
            [clojure.core.async :as async]
            [clojure.core.async.impl.protocols :as chan]))

Now we’ll add our counter route. We create the /counter route by calling start-event-stream and passing in our stream-ready handler.

This creates an interceptor that will handle setting up the connection and call the provided callback, stream-ready.

The stream-ready callback receives a core.async event channel and the SSE context as parameters. The SSE context is the context map with additional keys related to the event stream.

The callback must send at least one event in order to properly initialize the stream. If it doesn’t, the client will think the stream is broken and keep reconnecting.

An event stream is a particular kind of asynchronous response. The event stream interceptor, returned by start-event-stream, will return a context with a response map, which will trigger a response being sent to the client. However, the response will include whatever series of values are put into the provided response channel.

You send events on the channel using regular core.async functions, such as put! or >!!, and ultimately terminate the connection by calling clojure.core.async/close! on the channel.

Here our event consists the :name and :data keys, but could also contain an :id+for the event.

Importantly, it is not the job of your callback to send all of the events; your callback is supposed set in motion the concurrent machinery that sends the events. Events will continue to be sent to the client even after the callback returns, until either the client closes the connection, or your code closes the channel.

src/server_sent_events/service.clj
(defn stream-ready [event-chan context]
  (dotimes [i 10]
    (when-not (chan/closed? event-chan)
      (async/>!! event-chan {:name "counter" :data i})
      (Thread/sleep 1000)))
  (async/close! event-chan))

(def routes
  `[[["/" {:get home-page}
      ["/counter" {:get [::send-counter (sse/start-event-stream stream-ready)]}]]]])

Now let’s start up our server.

> lein run
INFO  org.eclipse.jetty.util.log  - Logging initialized @3094ms to org.eclipse.jetty.util.log.Slf4jLog

Creating your server...
INFO  org.eclipse.jetty.server.Server  - jetty-9.4.52.v20230823; built: 2023-08-23T19:29:37.669Z; git: abdcda73818a1a2c705da276edb0bf6581e7997e; jvm 11.0.19+7-LTS
INFO  o.e.j.server.handler.Con[textHandler  - Started o.e.j.s.ServletContextHandler@38f183e9{/,null,AVAILABLE}
INFO  o.e.jetty.server.AbstractConnector  - Started ServerConnector@b439e50{HTTP/1.1, (http/1.1, h2c)}{localhost:8080}
INFO  org.eclipse.jetty.server.Server  - Started @3196ms

We can check out our event stream in another terminal using curl.

> curl http://localhost:8080/counter
event: counter
data: 0

event: counter
data: 1

event: counter
data: 2

event: counter
data: 3

event: counter
data: 4

event: counter
data: 5

event: counter
data: 6

event: counter
data: 7

event: counter
data: 8

event: counter
data: 9
>

The events above will arrive at one second intervals.

SSE data will always be received as a string. If you want to send JSON (or any other data format), it’s up to the sender to encode it and the receiver to decode it accordingly. This agreement must be arranged out-of-band.

None of the Pedestal interceptors are invoked when sending SSE events. The interceptors are used for the initial connection request from the client, but not on the events themselves.

Now let’s stop our server with Ctrl+C and update our /index route to return an HTML page that connects to our counter route. Since we are adding inline JS we’ll also update our service configuration not to include secure headers, which include 'unsafe-inline' by default (this is an example, not production code after all).

src/server_sent_events/service.clj
(ns server-sent-events.service
  (:require [io.pedestal.http :as http]
            [io.pedestal.http.route :as route]
    ;; require SSE
            [io.pedestal.http.sse :as sse]
            [io.pedestal.http.body-params :as body-params]
            [ring.util.response :as ring-resp]
    ;; require core.async
            [clojure.core.async :as async]
            [clojure.core.async.impl.protocols :as chan]
            [hiccup.core :as hiccup]))

(def js-string
  "
var eventSource = new EventSource(\"http://localhost:8080/counter\");
eventSource.addEventListener(\"counter\", function(e) {
  console.log(e);
  var counterEl = document.getElementById(\"counter\");
  counter.innerHTML = e.data;
});
")

(defn home-page
  [request]
  {:status 200
   :headers {"Content-Type" "text/html"}
   :body (hiccup/html [:html
                       [:head
                        [:script {:type "text/javascript"}
                         js-string]]
                       [:body
                        [:div
                         [:span "Counter: "]
                         [:span#counter]]]])})

(defn stream-ready [event-chan context]
  (dotimes [i 10]
    (when-not (chan/closed? event-chan)
      (async/>!! event-chan {:name "counter" :data i})
      (Thread/sleep 1000)))
  (async/close! event-chan))

(def routes
  `[[["/" {:get home-page}
      ["/counter" {:get [::send-counter (sse/start-event-stream stream-ready)]}]]]])

(def service {:env :prod
              ::http/routes routes
              ::http/type :jetty
              ::http/port 8080
              ;; we need this so we can use inline scripts
              ::http/secure-headers {:content-security-policy-settings {:object-src "none"}}})

Now we should be able to restart our server and navigate to http://localhost:8080 and watch our counter increment.

Coverage of the JavaScript EventSource (SSE) API is beyond the scope of this Pedestal guide. If you are unfamiliar with the API, consult this tutorial for a basic introduction.

Wrapping Up

We’ve covered a minimal setup for server-sent event configuration and usage and demonstrated how to receive events from JavaScript.

This guide was based on the examples in the server-sent-event sample project and the Server-Sent Events Reference.