ΛↃ LAMBDACOMBINE

Symbolic Systems Infrastructure

lc-sse

Common Lisp · Server-Sent Events Library

General-purpose SSE library for Common Lisp, based on work started for the Datastar SDK. Thread-safe generator lifecycle, proxy-safe keep-alive with automatic client-disconnect detection, streaming compression (zstd built-in, brotli optional), and Hunchentoot and Clack backends. The SSE foundation used by datastar-cl, but self-contained and independent of any higher-level framework.

Repository: codeberg.org/fsm/lc-sse.

Sub-systems

The with-sse macro

Single entry point for all SSE patterns. The backend is selected at macroexpansion time from the second argument: a symbol (e.g. hunchentoot:*request*) selects Hunchentoot; a two-element list (env responder) selects Clack.

One-shot: fire events and return; connection closes when body exits:

(lc-sse:with-sse (gen hunchentoot:*request*)
  (lc-sse:send-event gen "message" '("hello from lc-sse")))

Pure-push: empty body with :keep-alive parks the handler thread. Broadcast from any other thread via the registry. When the socket dies, the next heartbeat write raises client-disconnected, unwinds the macro, and calls :on-disconnect:

(defvar *clients* (lc-sse.registry:make-sse-registry "updates"))

(hunchentoot:define-easy-handler (subscribe :uri "/updates") ()
  (lc-sse:with-sse (gen hunchentoot:*request*
                    :keep-alive    lc-sse:*default-keep-alive-interval*
                    :on-connect    (lambda (g) (lc-sse.registry:register *clients* g))
                    :on-disconnect (lambda (g) (lc-sse.registry:unregister *clients* g)))))

(defun broadcast-update (content)
  (lc-sse.registry:notify-subscribers
   *clients*
   (lambda (gen) (lc-sse:send-event gen "update" (list content)))))

Long-lived body with manual heartbeat -body drives its own writes; keep-sse-alive heartbeats the proxy and probes for disconnect:

(lc-sse:with-sse (gen hunchentoot:*request*
                    :on-connect    #'log-connection
                    :on-disconnect #'log-disconnection)
  (lc-sse:send-event gen "init" (list (initial-render)))
  (loop
    (sleep 5)
    (lc-sse:send-event gen "update" (list (next-update)))
    (lc-sse:keep-sse-alive gen)))

Clack -pass environment and responder as a two-element list:

;; One-shot
(lambda (env)
  (lambda (responder)
    (lc-sse:with-sse (gen (env responder))
      (lc-sse:send-event gen "message" '("hello")))))

;; Pure-push CQRS (empty body + :keep-alive)
(lambda (env)
  (lambda (responder)
    (lc-sse:with-sse (gen (env responder)
                     :keep-alive    lc-sse:*default-keep-alive-interval*
                     :on-connect    (lambda (g) (lc-sse.registry:register *clients* g))
                     :on-disconnect (lambda (g) (lc-sse.registry:unregister *clients* g))))))

Note on Woo and CQRS: the pure-push pattern above will block Woo's event loop when used with upstream Woo, defeating the purpose of an async server. CQRS with Clack/Woo requires the cqrs branch of the woo fork: it registers generators as user channels inside libev, allowing thousands of long-lived SSE streams without blocking the worker thread (this is being discussed with upstream). Hunchentoot is thread-per-connection and is not affected.

Connection Registry

The registry is a thread-safe store of live generators. notify-subscribers snapshots the list, iterates, and auto-unregisters dead generators.

Broadcast to all connected clients:

(defparameter *clients* (lc-sse.registry:make-sse-registry "my-app"))

(lc-sse.registry:notify-subscribers *clients*
  (lambda (gen)
    (lc-sse:send-event gen "update" '("tick"))))

Unicast by key (e.g. session id):

(defparameter *sessions*
  (lc-sse.registry:make-keyed-sse-registry "sessions"))

;; Register with a key:
:on-connect (lambda (g) (lc-sse.registry:register *sessions* g :key session-id))

;; Unicast to one session:
(lc-sse.registry:notify-subscriber *sessions* session-id
  (lambda (gen) (lc-sse:send-event gen "update" (list payload))))

Compression

zstd is built-in. Brotli is optional via cl-brotli. Compression algorithm is negotiated from the client's Accept-Encoding header automatically.

(ql:quickload :lc-sse/brotli)  ; prepends :br to the negotiation priority list

;; Override globally:
(setf lc-sse:*default-compression-priority* '(:br :zstd))  ; brotli preferred
(setf lc-sse:*default-compression-priority* '())            ; disable compression

;; Per-request override:
(make-hunchentoot-sse-generator request :compression-priority '(:br :zstd))
(make-hunchentoot-sse-generator request :disable-compression t)

TCP_NODELAY

Without it, Nagle's algorithm adds a ~40 ms stall on small back-to-back chunked SSE writes (Linux delayed-ACK timer). Two helpers in lc-sse/hunchentoot:

;; Drop-in for hunchentoot:easy-acceptor:
(hunchentoot:start
  (make-instance 'lc-sse:tcp-nodelay-easy-acceptor :port 8080))

;; Mixin for custom acceptors:
(defclass my-acceptor (lc-sse:tcp-nodelay-mixin hunchentoot:ssl-acceptor) ())

Conditions

client-disconnected (inherits sse-error and stream-error) is the normal disconnect signal: raised on the next write or keep-sse-alive call after the socket closes, unwinds the with-sse body, and triggers :on-disconnect. compression-error signals a failure in the compressor. serve-sse is the low-level function underlying with-sse for custom lifecycle wrappers.

License: GNU Lesser General Public License v3 or later.

(defroute lc-sse (:get :text/html))