Live Collections & Streams
Push server-driven UI updates to every connected browser over SSE, with no hand-written hub, event route, snapshot, or heartbeat.
(import (sigil web live))Two building blocks:
- A
live-streamis a push channel clients subscribe to. Youlive-push!ui-*updates and every subscriber applies them. No store, no render — the general case (a chat feed, a room log, notifications). - A
live-collectionis alive-streamplus an id-keyed store and a per-item render function. Mutating it broadcasts the right DOM patch automatically: a new row appends, an updated row morphs in place by id, a removed row is deleted by id.
A mutation/push is what everyone sees; a handler's return value is what the acting user sees (usually an empty ui-response, or nothing under ui-routes).
The client JavaScript is served by with-sigil-ui middleware and injected by http-response/page; each stream's endpoint is registered with live-routes.
Collections
live-collection
Create a collection: a stream whose subscribers see a list of rendered rows kept in sync as you mutate it. The render function's top-level element MUST carry an id — the collection targets that id when it morphs or removes the row.
(define todos
(live-collection render: item-row
container: "#todo-list"
path: "/events"))render:—item -> SXMLfor one row (its top element has anid).container:— CSS id-selector of the list element rows live in.path:— the SSE path the page subscribes to (default"/events").on-change:— optional, see below.
live-add! / live-update! / live-remove!
Mutate the store and broadcast the matching patch to every client. They return the affected item (or #f), not an HTTP response.
(live-add! todos (dict text: "Buy milk" done?: #f)) ; broadcast: append row
(live-update! todos id (lambda (i) (dict-set i done?: #t))) ; broadcast: morph row by id
(live-remove! todos id) ; broadcast: remove row by idThe collection assigns the id: field on live-add!. live-update! takes a function item -> item (dicts are immutable; return a new one with dict-set).
live-get / live-all
Read accessors (for a handler that needs current state, e.g. an inline edit form). Read-only.
(live-get todos id) ; => item or #f
(live-all todos) ; => items in insertion orderlive-list-view
Render the collection's list: the <ul> (or container) with the current rows, plus the hidden element that subscribes the page to the stream. Drop it in a body.
(http-response/page "To-Do"
`(div (h1 "To-Do") ,(add-form) ,(live-list-view todos)))on-change:
An optional (items -> ui-update or list) on live-collection, broadcast after every mutation. For UI derived from the whole list — a count, a summary. It receives only the item list; per-action events go through live-push!.
(define todos
(live-collection render: item-row container: "#todo-list" path: "/events"
on-change: (lambda (items)
(ui-update target: "#count" mode: "inner"
content: (count-label (length items))))))Streams
live-stream
A bare push channel — hub + SSE route + lazy heartbeat + subscription element, with no store or render. on-connect: is an optional thunk returning an initial ui-update string sent to each new subscriber (a snapshot); a plain feed omits it, so late joiners see only future pushes.
(define log (live-stream path: "/log"))live-push!
Send a ui-update (or a list of them) to every subscriber of a stream or a collection.
(live-push! log (ui-update target: "#log" mode: "append"
content: `(li "Item added")))live-subscribe
The hidden data-sg-sse element that subscribes a page to a stream (or a collection). live-list-view includes one for its collection; use live-subscribe directly for a bare stream.
`(div (h2 "Activity") (ul (@ (id "log"))) ,(live-subscribe log))Routing
live-routes
Return a handler that serves the SSE endpoint for a stream or a collection. Combine it with your app's routes.
(define app
(-> (ui-routes
(router …item routes…)
(live-routes todos) ; registers /events
(live-routes log)) ; registers /log
(with-sigil-ui)
(with-logging)
(with-not-found)))Serve it as usual: main is just (with-async (http-serve app port: 8080)).
Common Patterns
CRUD handlers over a live collection
Under ui-routes, each handler is the mutation; the acting user gets an empty acknowledgement while everyone gets the broadcast.
(define (create-handler req)
(live-add! todos (dict text: (form-text req) done?: #f))
(ui-response (ui-update target: "#add-form" content: (add-form)))) ; reset the form
(define (toggle-handler req)
(live-update! todos (item-id req)
(lambda (i) (dict-set i done?: (not (dict-ref i done?:))))))
(define (delete-handler req)
(live-remove! todos (item-id req)))A collection-free feed (chat / room log / notifications)
The general case: a bare live-stream and live-push!. No store, no render — just push a ui-update on each event.
(define chat (live-stream path: "/chat"))
;; page body: (ul (@ (id "messages"))) + (live-subscribe chat)
(define (say-handler req)
(live-push! chat
(ui-update target: "#messages" mode: "append"
content: `(li ,(form-text req))))
(ui-response))Derived summary UI
Use on-change: for anything computed from the whole list (a count, totals, an "all done" flag); it re-broadcasts after every mutation, so the summary stays in sync without touching the handlers.