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.
tcp-nodelay-easy-acceptor and tcp-nodelay-mixinSingle 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.
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))))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)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) ())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))