ΛↃ LAMBDACOMBINE

Symbolic Systems Infrastructure

04 · Interaction

DATA-BIND · READ-SIGNAL · WITH-SIGNALS · INIT-SIGNALS
Luna 16
Luna 16

Signals flow in both directions. The client declares them with data-signals; the server pushes new values with patch-signals. Input elements create signals with data-bind: the attribute value is the signal name.

(:input :data-bind "name")  ; creates $name, two-way bound to the input

By design, Datastar sends all current signal values with every request, except those staring with _: the server always has the required client state.

Reading signals on the server

Three options, from simplest to most ergonomic (especially for multiple signals):

; 1) One signal by key string
(d*:read-signal hunchentoot:*request* "name")

; 2) All signals into a hash-table
(let ((signals (d*:read-signals hunchentoot:*request*)))
  (gethash "name" signals))

; 3) Multiple bindings with defaults in one call
(d*:with-signals ((name     "name"     "stranger")
                  (greeting "greeting" "Hello"))
    hunchentoot:*request*
  (format nil "~A, ~A!" greeting name))

init-signals

init-signals builds a data-signals JSON string from a plist, converting keyword names to camelCase:

(:body :data-signals
       (d*:init-signals :time "" :ticks 0 :name "" :message ""))

Clack backend

On Clack the API is identical; pass the environment instead of the Hunchentoot request:

(defun hi-handler (env)
  (lambda (responder)
    (let ((name (d*:read-signal env "name")))
      (d*:with-sse (gen (env responder))
        (d*:patch-signals gen
          (list "message" (format nil "Hello, ~A!" name)))))))

Live Demo

Choose a greeting and enter a name. The server reads both signals and patches the message.

Full Source (standalone Hunchentoot)

;;;; -*- Mode: LISP; fill-column: 80; coding: utf-8 -*-

;;;; DEMO.LISP --- Minimal Hunchentoot example
;;;;
;;;; Copyright (C) 2025, 2026 Frederico Muñoz / ΛↃ lambda combine
;;;;
;;;; This file is part of datastar-cl, the Common Lisp SDK for Datastar
;;;;
;;;; License: MIT

(ql:quickload '(:hunchentoot :spinneret :datastar-cl/hunchentoot))

(defpackage #:clock
  (:use #:cl #:hunchentoot)
  (:local-nicknames (:sp :spinneret) (:d* :datastar-cl) (:ht :hunchentoot)))
(in-package #:clock)

(ht:define-easy-handler (index :uri "/") ()
  (setf (ht:content-type*) "text/html")
  (sp:with-html-string
    (:doctype)
    (:html
     (:head (:script :type "module" :src (d*:datastar-url)))
      (:body :data-signals (d*:init-signals :time "--:--:--" :ticks 0
                                          :name "" :greeting "Hello" :message "")
             :data-init (d*:sse-get "/sse")
             (:h1 "Datastar-CL SSE Demo")
             (:div :id "clock" :data-text "$time")
             (:div "Ticks: " (:strong :data-text "$ticks"))
             (:p (:select :data-bind "greeting"
                         (:option :value "Hello" "Hello")
                         (:option :value "Hi" "Hi")
                         (:option :value "Hey" "Hey")
                         (:option :value "Greetings" "Greetings"))
                " "
                (:input :placeholder "Your name" :data-bind "name")
                " "
                (:button :|data-on:click| (d*:sse-get "/hi") "Say hi")
                (:p :data-text "$message"))))))

(ht:define-easy-handler (sse-handler :uri "/sse") ()
  (let ((tick 0))
    (d*:with-sse (gen ht:*request*)
      (loop
        (multiple-value-bind (s m h) (decode-universal-time (get-universal-time))
          (d*:patch-signals gen
                            (list "time"  (format nil "~2,'0d:~2,'0d:~2,'0d" h m s)
                                  "ticks" (incf tick))))
        (sleep 1)))))

;;; Signal reading progression (simplest ---> most ergonomic):
;;;
;;;   1) READ-SIGNAL: read one signal by key string
;;;        (d*:read-signal ht:*request* "name")
;;;
;;;   2) READ-SIGNALS: parse all signals into a hash-table (once)
;;;        (let ((signals (d*:read-signals ht:*request*)))
;;;          (gethash "name" signals))
;;;
;;;   3) WITH-SIGNALS: bind N variables in one call (below)
;;;        (d*:with-signals ((name "name" "default") ...) req body)
;;;
;;; Below we use WITH-SIGNALS to read greeting + name in one parse, with default
;;; values when either signal is absent.
(ht:define-easy-handler (hi :uri "/hi") ()
  (d*:with-signals ((name     "name"     "stranger")
                    (greeting "greeting" "Hello"))
      ht:*request*
    (d*:with-sse (gen ht:*request*)
      (d*:patch-signals gen
                        (list "message"
                              (format nil "~A, ~A!" greeting name))))))

(ht:start (make-instance 'ht:easy-acceptor :port 8989))
(format t "~&Server started on http://localhost:8989~%")

Clack Version

The same example on Clack, showing the identical signal-reading API with a different backend:

;;;; -*- Mode: LISP; fill-column: 80; coding: utf-8 -*-

;;;; DEMO-CLACK.LISP --- Minimal Clack example
;;;;
;;;; Copyright (C) 2025, 2026 Frederico Muñoz / ΛↃ lambda combine
;;;;
;;;; This file is part of datastar-cl, the Common Lisp SDK for Datastar
;;;;
;;;; License: MIT

;;; NOTE: A blocking ~with-sse~ body (e.g. loop ... sleep) blocks the Clack
;;; worker thread for the duration of the connection _if using Woo_ -- by
;;; design. Clack+Woo is suitable for the one-shot ~/hi~ endpoint out-of-the box
;;; but for long-lived streaming check the documentation: blocking the event
;;; loop will block Woo. This example uses Hunchentoo as the backend server for
;;; Clack, but Woo is also tested (changeable in :backend below)

(ql:quickload '(:clack :spinneret :datastar-cl/clack))

(defpackage #:clock-clack
  (:use #:cl)
  (:local-nicknames (:sp :spinneret) (:d* :datastar-cl)))
(in-package #:clock-clack)

(defun render-index ()
  (sp:with-html-string
    (:doctype)
    (:html
     (:head (:script :type "module" :src (d*:datastar-url)))
     (:body :data-signals "{time: '--:--:--', ticks: 0, name: '', message: ''}"
            :data-init (d*:sse-get "/sse")
            (:h1 "Datastar-CL SSE Demo (Clack)")
            (:div :id "clock" :data-text "$time")
            (:div "Ticks: " (:strong :data-text "$ticks"))
            (:p (:input :placeholder "Your name" :data-bind "name")
                (:button :|data-on:click| (d*:sse-get "/hi") "Say hi")
                (:p :data-text "$message"))))))

(defun sse-handler (env)
  (lambda (responder)
    (let ((tick 0))
      (d*:with-sse (gen (env responder))
        (loop
          (multiple-value-bind (s m h) (decode-universal-time (get-universal-time))
            (d*:patch-signals gen
                              (list "time"  (format nil "~2,'0d:~2,'0d:~2,'0d" h m s)
                                    "ticks" (incf tick))))
          (sleep 1))))))

(defun hi-handler (env)
  (lambda (responder)
    (let ((name (d*:read-signal env "name")))
      (d*:with-sse (gen (env responder))
        (d*:patch-signals gen
                          (list "message"
                                (format nil "Hello, ~A!"
                                        (if (zerop (length name))
                                            "stranger"
                                          name))))))))

(defun app (env)
  (let ((path (getf env :path-info)))
    (cond
      ((string= path "/")    `(200 (:content-type "text/html; charset=utf-8")
                                   (,(render-index))))
      ((string= path "/sse") (sse-handler env))
      ((string= path "/hi")  (hi-handler env))
      (t                     '(404 (:content-type "text/plain") ("Not Found"))))))

(clack:clackup #'app :server :hunchentoot :port 8989)
(format t "~&Server started on http://localhost:8989~%")

(defroute guide-interaction (:get :text/html))