Commit1dbbf0eaRecorded5 Jul 2026Repositorysigil-web

Add (sigil web live): live collections + with-sigil-ui middleware

Message

live-collection binds a per-item render fn, a container selector, and an SSE endpoint into one object that OWNS its store, broadcast hub, and heartbeat. Mutations broadcast the right DOM patch to every client and return the item (not a response), so a handler stays: mutate (everyone sees) + return the acting user's response.

  - live-add!/live-update!/live-remove! : mutate + broadcast append/morph/remove.
    The morph/remove target is derived from the rendered row's own id attribute,
    so the render fn is the single source of truth for a row's DOM id.
  - live-get/live-all : read accessors (e.g. an inline-edit handler).
  - live-view : renders the list container + the hidden data-sg-sse subscription.
  - live-routes : registers the /events route (on-connect snapshot + subscription).
  - Heartbeat is lazy: started on the first /events connection (already inside the
    server's with-async), so main stays (with-async (http-serve app ...)).
  - Internal store seam (all/get/add/update!/remove!) with one in-memory ordered
    id-keyed impl; a DB-backed store can drop in later without touching callers.
    No store: param exposed yet.

with-sigil-ui: middleware that serves /js/sigil-web-ui.js, so an app never registers that route by hand (pairs with http-response/page injecting the tag).

Out of scope (separate follow-up): SSE on-disconnect / client identity.

Changed
 src/sigil/web/live.sgl | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/sigil/web/ui.sgl   |  28 ++++++++++++++++++-
 2 files changed, 275 insertions(+), 1 deletion(-)
Diff
src/sigil/web/live.sgladded
@@ -0,0 +1,248 @@
+1
;;; (sigil web live) - Live collections
+2
;;;
+3
;;; A live collection binds a data source, a per-item render function, a
+4
;;; container selector, and a broadcast hub into one object. Mutating it
+5
;;; (`live-add!` / `live-update!` / `live-remove!`) automatically pushes the
+6
;;; right DOM patch to every connected browser: a new row is appended, an
+7
;;; updated row is morphed in place by id, a removed row is deleted by id.
+8
;;;
+9
;;; The collection owns its store, its hub, and its heartbeat, so an app
+10
;;; author never hand-writes the store / broadcast / snapshot / events-route
+11
;;; plumbing. `live-view` renders the current rows plus the live subscription;
+12
;;; `live-routes` registers the SSE endpoint.
+13
;;;
+14
;;; Mutations do NOT return the HTTP response — they return the affected item
+15
;;; (or #f) for convenience. The handler returns the acting user's own
+16
;;; response explicitly. Mental model: the mutation is what *everyone* sees;
+17
;;; the return value is what *you* see.
+18
;;;
+19
;;; Example:
+20
;;; ```scheme
+21
;;; (define todos
+22
;;; (live-collection render: item-row container: "#todo-list" endpoint: "/events"))
+23
;;;
+24
;;; (define (create-handler req)
+25
;;; (live-add! todos (dict text: (form-text req) done?: #f)) ; everyone: append row
+26
;;; (sigil-ui-response target: "#add-form" content: (add-form))) ; you: reset the form
+27
;;; ```
+28
+29
(define-library (sigil web live)
+30
(import (sigil core)
+31
(sigil struct)
+32
(sigil string)
+33
(sigil sxml)
+34
(sigil channels)
+35
(sigil http response)
+36
(sigil web routes)
+37
(sigil web ui))
+38
+39
(export
+40
live-collection
+41
live-collection?
+42
live-get
+43
live-all
+44
live-add!
+45
live-update!
+46
live-remove!
+47
live-view
+48
live-routes
+49
live-snapshot)
+50
+51
(begin
+52
+53
;; ============================================================
+54
;; Internal store seam
+55
;; ============================================================
+56
;;
+57
;; A minimal id-keyed, insertion-ordered store. Items are dicts; the
+58
;; store owns the `id:` field (assigned on add). This is deliberately an
+59
;; internal interface (`store-all`/`store-get`/`store-add!`/`store-update!`/
+60
;; `store-remove!`) with one in-memory implementation, so a database-backed
+61
;; store can drop in later without touching the collection or the app.
+62
;; Not exposed as a `store:` parameter yet.
+63
+64
(define-struct mem-store
+65
(items default: '() mutable: #t) ; list of item dicts (insertion order)
+66
(next-id default: 0 mutable: #t))
+67
+68
(define (store-all s) (mem-store-items s))
+69
+70
(define (store-get s id)
+71
(let loop ((items (mem-store-items s)))
+72
(cond ((null? items) #f)
+73
((= (dict-ref (car items) id:) id) (car items))
+74
(else (loop (cdr items))))))
+75
+76
;; Assign the next id, store the item, return it (with its id set).
+77
(define (store-add! s item)
+78
(let* ((n (+ (mem-store-next-id s) 1))
+79
(it (dict-set item id: n)))
+80
(set-mem-store-next-id! s n)
+81
(set-mem-store-items! s (append (mem-store-items s) (list it)))
+82
it))
+83
+84
;; Replace item `id` with `(f item)`; return the new item or #f.
+85
(define (store-update! s id f)
+86
(let ((item (store-get s id)))
+87
(and item
+88
(let ((next (f item)))
+89
(set-mem-store-items! s
+90
(map (lambda (i) (if (= (dict-ref i id:) id) next i))
+91
(mem-store-items s)))
+92
next))))
+93
+94
;; Remove item `id`; return the removed item or #f.
+95
(define (store-remove! s id)
+96
(let ((existed (store-get s id)))
+97
(when existed
+98
(set-mem-store-items! s
+99
(filter (lambda (i) (not (= (dict-ref i id:) id)))
+100
(mem-store-items s))))
+101
existed))
+102
+103
+104
;; ============================================================
+105
;; Live collection
+106
;; ============================================================
+107
+108
(define-struct live-collection-rec
+109
(render) ; item -> SXML row (with an id attribute)
+110
(container) ; CSS selector for the list, e.g. "#todo-list"
+111
(endpoint) ; SSE path, e.g. "/events"
+112
(store) ; internal store
+113
(hub) ; broadcast channel
+114
(heartbeat-started? default: #f mutable: #t))
+115
+116
;;; Create a live collection.
+117
;;;
+118
;;; Parameters:
+119
;;; render: (item -> SXML) rendering one row. The rendered element
+120
;;; MUST carry an `id` attribute; the collection targets that
+121
;;; id when it morphs or removes the row.
+122
;;; container: CSS id-selector of the list element new rows append into
+123
;;; (e.g. "#todo-list").
+124
;;; endpoint: SSE path the page subscribes to (default "/events").
+125
;;;
+126
;;; The store, broadcast hub, and heartbeat are created and managed
+127
;;; internally.
+128
(define (live-collection (keys: (render #f)
+129
(container #f)
+130
(endpoint "/events")))
+131
(live-collection-rec
+132
render: render
+133
container: container
+134
endpoint: endpoint
+135
store: (mem-store)
+136
hub: (make-broadcast)))
+137
+138
;;; #t if `x` is a live collection.
+139
(define (live-collection? x) (live-collection-rec? x))
+140
+141
;;; Read one item by id (or #f). For read-only handlers such as an inline
+142
;;; edit form that needs the item's current state; mutations go through
+143
;;; `live-update!` / `live-remove!`.
+144
(define (live-get coll id)
+145
(store-get (live-collection-rec-store coll) id))
+146
+147
;;; All items in insertion order. Read-only.
+148
(define (live-all coll)
+149
(store-all (live-collection-rec-store coll)))
+150
+151
;; Render an item and return (selector . row), where selector targets the
+152
;; row's own id attribute (e.g. "#item-3"). The render function is the
+153
;; single source of truth for a row's DOM id.
+154
(define (render-row+selector coll item)
+155
(let* ((row ((live-collection-rec-render coll) item))
+156
(id (sxml-attr-ref row 'id)))
+157
(if id
+158
(cons (string-append "#" id) row)
+159
(error "live-collection: rendered row is missing an id attribute"))))
+160
+161
;;; Add `item` to the collection: assign an id, store it, and broadcast an
+162
;;; append of its rendered row to every connected client. Returns the
+163
;;; stored item (with its id). Does NOT return an HTTP response.
+164
(define (live-add! coll item)
+165
(let* ((it (store-add! (live-collection-rec-store coll) item))
+166
(row ((live-collection-rec-render coll) it)))
+167
(broadcast-send (live-collection-rec-hub coll)
+168
(ui-morph target: (live-collection-rec-container coll)
+169
mode: "append"
+170
content: row))
+171
it))
+172
+173
;;; Update item `id` in place with `(f item)`: store it and broadcast a
+174
;;; morph of its rendered row (targeted by the row's id) to every client.
+175
;;; Returns the new item, or #f if no such id. Does NOT return a response.
+176
(define (live-update! coll id f)
+177
(let ((it (store-update! (live-collection-rec-store coll) id f)))
+178
(when it
+179
(let ((sel+row (render-row+selector coll it)))
+180
(broadcast-send (live-collection-rec-hub coll)
+181
(ui-morph target: (car sel+row) content: (cdr sel+row)))))
+182
it))
+183
+184
;;; Remove item `id`: delete it from the store and broadcast a remove of
+185
;;; its row (targeted by the row's id) to every client. Returns the removed
+186
;;; item, or #f if no such id. Does NOT return a response.
+187
(define (live-remove! coll id)
+188
(let ((it (store-get (live-collection-rec-store coll) id)))
+189
(when it
+190
(let ((sel (car (render-row+selector coll it))))
+191
(store-remove! (live-collection-rec-store coll) id)
+192
(broadcast-send (live-collection-rec-hub coll)
+193
(ui-remove target: sel))))
+194
it))
+195
+196
;; Strip a leading "#" from an id-selector to get the bare element id.
+197
(define (selector->id selector)
+198
(if (and (> (string-length selector) 0)
+199
(char=? (string-ref selector 0) #\#))
+200
(substring selector 1 (string-length selector))
+201
selector))
+202
+203
;;; The current-state snapshot: a UI update that replaces the container's
+204
;;; contents with every current row. Sent to each browser on connect.
+205
(define (live-snapshot coll)
+206
(ui-morph target: (live-collection-rec-container coll)
+207
mode: "inner"
+208
content: (map (live-collection-rec-render coll)
+209
(store-all (live-collection-rec-store coll)))))
+210
+211
;;; Render the live view: the list container holding the current rows, plus
+212
;;; the hidden element that subscribes the page to the live stream. Drop
+213
;;; this into a page body; the collection owns the container id and the
+214
;;; subscription endpoint.
+215
(define (live-view coll)
+216
`(div
+217
(ul (@ (id ,(selector->id (live-collection-rec-container coll))))
+218
,@(map (live-collection-rec-render coll)
+219
(store-all (live-collection-rec-store coll))))
+220
(div (@ (data-sg-sse ,(live-collection-rec-endpoint coll))
+221
(style "display:none")))))
+222
+223
;; Start the heartbeat once, on the first SSE connection. The connection
+224
;; handler runs in its own goroutine under the server's `with-async`, so
+225
;; the scheduler is available here without the app calling anything at
+226
;; startup. (The flag check/set isn't atomic; a burst of simultaneous
+227
;; first-connects could start more than one heartbeat. Harmless for now;
+228
;; revisit if it matters.)
+229
(define (ensure-heartbeat! coll)
+230
(unless (live-collection-rec-heartbeat-started? coll)
+231
(set-live-collection-rec-heartbeat-started?! coll #t)
+232
(start-sse-heartbeat! (live-collection-rec-hub coll))))
+233
+234
(define (live-events-handler coll)
+235
(lambda (req)
+236
(ensure-heartbeat! coll)
+237
(http-response/sse-broadcast (live-collection-rec-hub coll)
+238
on-connect: (lambda () (live-snapshot coll)))))
+239
+240
;;; Return a handler that serves the collection's SSE endpoint. Combine it
+241
;;; with your app's routes: `(routes (router ...) (live-routes todos))`.
+242
(define (live-routes coll)
+243
(router
+244
(route method: GET
+245
pattern: (live-collection-rec-endpoint coll)
+246
handler: (live-events-handler coll))))
+247
+248
))
src/sigil/web/ui.sglmodified
@@ -28,6 +28,7 @@
28
(sigil string)
29
(sigil math)
30
(sigil sxml)
+31
(sigil http request)
32
(sigil http response)
33
(sigil resources))
34
@@ -110,7 +111,8 @@
111
;; JS helpers
112
sigil-web-ui-script
113
sigil-web-ui-handler
113
sigil-web-ui-head)
+114
sigil-web-ui-head
+115
with-sigil-ui)
116
117
(begin
118
@@ -1296,4 +1298,28 @@
1298
`(script (@ (src ,idiomorph-url)))
1299
`(script (@ (src ,script-url)))))
1300
+1301
;;; Middleware that serves the Sigil Web UI client script.
+1302
;;;
+1303
;;; A chain-style wrapper: it intercepts `GET <path>` (default
+1304
;;; `/js/sigil-web-ui.js`, the URL `sigil-web-ui-head` points at) and serves
+1305
;;; the bundled client, passing every other request through. This is the
+1306
;;; middleware counterpart to `http-response/page` auto-injecting the script
+1307
;;; tag: add `(with-sigil-ui)` to your chain and you never register the JS
+1308
;;; route by hand.
+1309
;;;
+1310
;;; Example:
+1311
;;; ```scheme
+1312
;;; (-> (routes (router ...))
+1313
;;; (with-sigil-ui)
+1314
;;; (with-logging)
+1315
;;; (with-not-found))
+1316
;;; ```
+1317
(define (with-sigil-ui handler (keys: (path "/js/sigil-web-ui.js")))
+1318
(let ((serve (sigil-web-ui-handler)))
+1319
(lambda (request)
+1320
(if (and (eq? (http-request-method request) 'GET)
+1321
(string=? (http-request-path request) path))
+1322
(serve request)
+1323
(handler request)))))
+1324
1325
))