[ RECORDING PENDING ]
REPL DEMO
datastar-cl / lc-sse
CLASS: INTERACTIVE
ADR-compliant Common Lisp implementation of the Datastar hypermedia framework SDK. Provides SSE streaming, HTML element patching, reactive signal propagation, and CQRS patterns across Hunchentoot, Clack (with Hunchentoot and Woo backends).
The SDK is built on top of lc-sse, a standalone SSE library. Snooze routing integration is provided as an optional sub-system. The Hypermedia Guide walks through every concept live, from a single push to full CQRS, using the SDK at each step.
(ql:quickload :datastar-cl) ; core + Hunchentoot + Clack backends
(ql:quickload :datastar-cl/hunchentoot) ; Hunchentoot only
(ql:quickload :datastar-cl/clack) ; Clack onlyOptional sub-systems:
(ql:quickload :datastar-cl/brotli) ; brotli compression
(ql:quickload :datastar-cl/snooze) ; Snooze routing integration
(ql:quickload :datastar-cl/registry) ; connection registry for pub-subAdd the Datastar client script from Spinneret using the SDK helper:
(:script :type "module" :src (datastar-cl:datastar-url))Pin a version:
(datastar-cl:datastar-url :version "1.0.0-RC.8")Self-host by setting a local path:
(setf datastar-cl:*datastar-source* "/static/datastar.js")Datastar sends client-side signal values as JSON on POST requests. Read a single signal by name:
(datastar-cl:read-signal hunchentoot:*request* "sid")Read multiple signals in one pass with defaults:
(datastar-cl:with-signals ((name "name") (sid "sid" (make-uuid)))
hunchentoot:*request*
(register-subscriber sid name))with-sse opens the event stream and binds a generator object. patch-elements sends an HTML fragment to the browser, targeting a CSS selector. patch-signals pushes reactive variable updates as a JSON Merge Patch:
(datastar-cl:with-sse (gen hunchentoot:*request*)
(loop
(datastar-cl:patch-elements gen
"<span>new content</span>"
:selector "#target"
:mode :inner)
(sleep 1)))(datastar-cl:with-sse (gen hunchentoot:*request*)
(let ((n 0))
(loop
(datastar-cl:patch-signals gen (list "count" (incf n)))
(sleep 1))))Build the HTML string with Spinneret before passing it:
(datastar-cl:patch-elements gen
(spinneret:with-html-string (:span "content"))
:selector "#target"
:mode :inner)Pass environment and responder as a two-element list. Backend is auto-detected at macroexpansion time:
(lambda (responder)
(datastar-cl:with-sse (gen (env responder))
(loop
(datastar-cl:patch-signals gen (list "time" (get-universal-time)))
(sleep 1))))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. A part of the work on the Datastar SDK, we have added user-channels support in Woo that currently 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 datastar-cl/snooze package integrates with Snooze's URL routing. defresource declares a resource that accepts Datastar signal parameters; with-signals reads them from the current route environment without explicit request passing:
(datastar-cl/snooze:defresource greet (verb content-type &key datastar))(snooze:defroute greet (:post :application/json &key datastar)
(declare (ignore datastar))
(datastar-cl/snooze:with-signals ((salutation "salutation" "Hello")
(name "name" "World"))
(datastar-cl:with-sse (gen hunchentoot:*request*)
(datastar-cl:patch-signals gen
(list "greeting" (format nil "~a, ~a!" salutation name))))))For SSE routes, backend auto-detection works without an explicit request argument:
(snooze:defroute my-stream (:get :text/event-stream)
(datastar-cl/snooze:with-sse (gen)
(loop
(datastar-cl:patch-signals gen (list "tick" (get-universal-time)))
(sleep 1))))For CQRS and pub-sub: the GET handler parks with an empty body and :keep-alive; the registry holds live generators; any thread broadcasts. When the socket dies, the next keep-alive write raises client-disconnected, which unwinds with-sse and calls :on-disconnect:
(defvar *clients*
(datastar-cl.registry:make-sse-registry "my-app"))
(datastar-cl:with-sse (gen hunchentoot:*request*
:keep-alive t
:on-connect (lambda (g)
(datastar-cl.registry:register *clients* g))
:on-disconnect (lambda (g)
(datastar-cl.registry:unregister *clients* g))))(defun broadcast-update (payload)
(datastar-cl.registry:notify-subscribers
*clients*
(lambda (gen) (datastar-cl:patch-signals gen payload))))A self-contained SSE clock. Load with sbcl --load clock.lisp and visit http://localhost:8989:
(ql:quickload '(:hunchentoot :spinneret :datastar-cl/hunchentoot))
(defpackage #:clock (:use #:cl #:hunchentoot)
(:local-nicknames (:sp :spinneret) (:d* :datastar-cl)))
(in-package #:clock)
(define-easy-handler (index :uri "/") ()
(setf (hunchentoot:content-type*) "text/html")
(sp:with-html-string
(:doctype)
(:html
(:head (:script :type "module" :src (d*:datastar-url)))
(:body :data-init (d*:sse-get "/sse")
(:h1 "datastar-cl SSE Clock")
(:div :id "clock" "Waiting...")))))
(define-easy-handler (sse-handler :uri "/sse") ()
(d*:with-sse (gen hunchentoot:*request*)
(loop
(multiple-value-bind (s m h)
(decode-universal-time (get-universal-time))
(d*:patch-elements gen
(format nil "<span>~2,'0d:~2,'0d:~2,'0d</span>" h m s)
:selector "#clock" :mode :inner))
(sleep 1))))
(start (make-instance 'd*:tcp-nodelay-easy-acceptor :port 8989))(defroute datastar-sdk (:get :text/html))