Pedestal
Server Sent Events

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).

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

If you get stuck at any point in this guide, please create an issue or hop over to the mailing list and raise your questions there. You can often find help in the "#pedestal" channel of the Clojurians Slack team.

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.8.0"]
                 [io.pedestal/pedestal.service "0.5.3"]

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

                 [ch.qos.logback/logback-classic "1.1.8" :exclusions [org.slf4j/slf4j-api]]

                 [org.slf4j/jul-to-slf4j "1.7.22"]
                 [org.slf4j/jcl-over-slf4j "1.7.22"]
                 [org.slf4j/log4j-over-slf4j "1.7.22"]
                 ;; Add hiccup dependency
                 [hiccup "1.0.5"]]

Clean Up

Here’s the service.clj file generated for us:

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 sse/start-event-stream and passing in our stream-ready handler. This creates an interceptor that will handle setting up the connection and call the stream-ready handler. A stream-ready handler receives a core.async event channel and SSE context as parameters. The stream-ready handler 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.

You send events on the channel using regular core.async (such as async/>!!) functions and terminate the connection by calling async/close! on the channel. Here our event consists the :name and :data keys, but could also contain an :id for the event. Note that the events that you send on the channel can happen any time later. They don’t all have to be created and sent in the stream-ready handler.

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
...
Creating your server...
...
INFO  org.eclipse.jetty.server.Server - Started @4434ms

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

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.

Also, it’s important to note that 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 (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-Send Events Reference. Please see those references for further information.