ΛↃ LAMBDACOMBINE

Symbolic Systems Infrastructure

datastar-sdk

Common Lisp SDK · Datastar Hypermedia Framework · ADR Compliant

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

Systems

Loading

(ql:quickload :datastar-cl)             ; core + Hunchentoot + Clack backends
(ql:quickload :datastar-cl/hunchentoot)  ; Hunchentoot only
(ql:quickload :datastar-cl/clack)        ; Clack only

Optional 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-sub

Including Datastar JavaScript

Add 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")

Reading Signals

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

SSE Streaming: Hunchentoot

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)

SSE Streaming: Clack

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.

Snooze Integration

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

Keep-Alive and Connection Registry

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

Minimal Complete Example

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

Further Reading

Deployed In

(defroute datastar-sdk (:get :text/html))