Server-Sent Events

Welcome

Server-sent events (SSE) make it possible to stream events from a server 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 stream 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.

Server-Sent Events Overview

In typical client/server interactions, the client, usually a web browser, sends a single request to the server, and the server responds with a single response.

With SSEs, this is a bit different. The client still sends a single request, and receives a single response, but it doesn’t get the response all at once. The response body "stays open", and the server can keep appending more content to it.

With SSEs, the response content type is text/event-stream. This event stream consists of multiple "events", short text-based messages.

Where We Are Going

We’ll be creating a new Pedestal project, adding a simple route to stream a counter using server-sent events, and testing it with curl.

Setting Up

Create a new project directory with a deps.edn and a source file:

$ mkdir sse-demo
$ cd sse-demo
$ mkdir src
deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.http-kit {:mvn/version "0.8.2-beta-3"}
         org.slf4j/slf4j-simple        {:mvn/version "2.0.17"}}}

Starting Simple

Let’s start with a basic home page and the server plumbing, then add SSE support.

We’ll need several namespaces for SSE support, so let’s bring them all in from the start:

src/sse_demo.clj
(ns sse-demo
  (:require [clojure.core.async :as async]
            [io.pedestal.connector :as conn]
            [io.pedestal.http.http-kit :as hk]
            [io.pedestal.http.sse :as sse]
            [io.pedestal.interceptor :as interceptor]))

A simple handler to verify things are working:

src/sse_demo.clj
(defn home-page
  [_request]
  {:status 200 :body "Hello, World!"})

Start the REPL, load the namespace, and verify it works:

$ clj
Clojure 1.12
user=> (require 'sse-demo)
nil
user=> (sse-demo/start)
...
$ curl http://localhost:8890/
Hello, World!

Adding a Server-Sent Event Route

Now let’s add SSE support. We write a stream-ready callback; this function is called once the SSE connection is established. It receives a core.async channel and the SSE context as parameters.

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.

Each event is a map with :name and :data keys (and, optionally, an :id key). All values are converted to strings before being sent.

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.

Importantly, it is not the job of your callback to send all of the events; your callback should 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.

In this example, we send ten counter events at one-second intervals:

src/sse_demo.clj
(defn stream-ready [event-chan context]
  (future                                                   (1)
    (dotimes [i 10]
      (async/>!! event-chan {:name "counter" :data i})       (2)
      (Thread/sleep 1000))
    (async/close! event-chan)))                              (3)
1 The work is done in a future, so the callback returns immediately.
2 Each event is a map with :name and :data keys.
3 Closing the channel terminates the SSE connection.

Now we create an interceptor that calls start-stream to initiate the event stream:

src/sse_demo.clj
(def counter-interceptor
  (interceptor/interceptor
    {:name  ::counter
     :enter (fn [context]
              (sse/start-stream stream-ready context))}))    (1)
1 start-stream sets up the SSE connection and calls stream-ready once it’s established. It returns the context with a :response key, which is an open-ended streaming response.

An event stream is a particular kind of asynchronous response. start-stream returns a context with a response that "stays open", and the server keeps appending events from the channel to the response body.

Finally, add the /counter route:

src/sse_demo.clj
(def routes
  #{["/"        :get home-page]
    ["/counter" :get counter-interceptor]})

And the connector plumbing:

src/sse_demo.clj
(defn create-connector []
  (-> (conn/default-connector-map 8890)
      (conn/with-default-interceptors)
      (conn/with-routes routes)
      (hk/create-connector nil)))

(defonce *connector (atom nil))

(defn start []
  (reset! *connector (conn/start! (create-connector))))

(defn stop []
  (conn/stop! @*connector)
  (reset! *connector nil))

(defn restart []
  (when @*connector (stop))
  (start))

Trying It Out

Restart the connector to pick up the changes:

user=> (require :reload 'sse-demo)
nil
user=> (sse-demo/restart)
...

Now test the event stream with curl in another terminal:

$ curl http://localhost:8890/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 arrive at one-second intervals, then the connection closes.

SSE data is always 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.

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.

Connecting from JavaScript

To consume the event stream from a browser, use the standard EventSource API:

const eventSource = new EventSource("/counter");
eventSource.addEventListener("counter", (event) => {
    console.log("Counter:", event.data);
});

Coverage of the JavaScript EventSource (SSE) API is beyond the scope of this Pedestal guide. Consult the MDN documentation for a thorough introduction.

The Whole Shebang

For reference, here is the complete source:

src/sse_demo.clj
(ns sse-demo
  (:require [clojure.core.async :as async]
            [io.pedestal.connector :as conn]
            [io.pedestal.http.http-kit :as hk]
            [io.pedestal.http.sse :as sse]
            [io.pedestal.interceptor :as interceptor]))

(defn home-page
  [_request]
  {:status 200 :body "Hello, World!"})

(defn stream-ready [event-chan context]
  (future                                                   (1)
    (dotimes [i 10]
      (async/>!! event-chan {:name "counter" :data i})       (2)
      (Thread/sleep 1000))
    (async/close! event-chan)))                              (3)

(def counter-interceptor
  (interceptor/interceptor
    {:name  ::counter
     :enter (fn [context]
              (sse/start-stream stream-ready context))}))    (1)

(def routes
  #{["/"        :get home-page]
    ["/counter" :get counter-interceptor]})

(defn create-connector []
  (-> (conn/default-connector-map 8890)
      (conn/with-default-interceptors)
      (conn/with-routes routes)
      (hk/create-connector nil)))

(defonce *connector (atom nil))

(defn start []
  (reset! *connector (conn/start! (create-connector))))

(defn stop []
  (conn/stop! @*connector)
  (reset! *connector nil))

(defn restart []
  (when @*connector (stop))
  (start))
deps.edn
{:paths ["src"]
 :deps  {io.pedestal/pedestal.http-kit {:mvn/version "0.8.2-beta-3"}
         org.slf4j/slf4j-simple        {:mvn/version "2.0.17"}}}

Wrapping Up

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

For more details, see the Server-Sent Events Reference.