Ring/Reitit adapter for embeddable stube kernels.
Ring/Reitit adapter for embeddable stube kernels.
The conversation data model — pure helpers, no I/O.
A conversation is the entire server-side state of a single user's session against one mounted flow. It is a plain map; persistence, history, and concurrency are all handled by working with these values.
{:conv/id "cv-019"
:conv/instances {"ix-7e2" {…instance map…} …}
:conv/stack ["ix-7c1" "ix-7e2"] ; bottom → top
:conv/history [previous-conv …]
:conv/created #inst "…"
:conv/touched #inst "…"}
An instance is the merged shape of an instantiated component:
{:instance/id "ix-7e2"
:instance/type :auth/login
:instance/parent "ix-7c1" | nil
:instance/resume :on-login | nil
:instance/rendered? false ; toggled on first emitted patch
…user state from (:component/init cdef)…}
The user-defined state lives at the top level of the instance map (not
under a :state key). Handler functions therefore see one merged map
and can both read instance metadata (:instance/id) and their own
domain fields by simple keyword lookup. Handlers must not clobber the
:instance/* keys.
The conversation data model — pure helpers, no I/O.
A *conversation* is the entire server-side state of a single user's
session against one mounted flow. It is a plain map; persistence,
history, and concurrency are all handled by working with these values.
----------------------------------------------------------------------
Shape
----------------------------------------------------------------------
{:conv/id "cv-019"
:conv/instances {"ix-7e2" {…instance map…} …}
:conv/stack ["ix-7c1" "ix-7e2"] ; bottom → top
:conv/history [previous-conv …]
:conv/created #inst "…"
:conv/touched #inst "…"}
An *instance* is the merged shape of an instantiated component:
{:instance/id "ix-7e2"
:instance/type :auth/login
:instance/parent "ix-7c1" | nil
:instance/resume :on-login | nil
:instance/rendered? false ; toggled on first emitted patch
…user state from (:component/init cdef)…}
The user-defined state lives at the top level of the instance map (not
under a `:state` key). Handler functions therefore see one merged map
and can both read instance metadata (`:instance/id`) and their own
domain fields by simple keyword lookup. Handlers must not clobber the
`:instance/*` keys.Public API of the stube framework.
Most users only need this namespace. It re-exports the small set of
functions that are intended to be called from application code; the
internals live in dev.zeko.stube.kernel, dev.zeko.stube.conversation,
dev.zeko.stube.registry, dev.zeko.stube.render, dev.zeko.stube.runtime,
dev.zeko.stube.http and dev.zeko.stube.server. Hosts embedding stube
in their own Ring app reach for dev.zeko.stube.embed and
dev.zeko.stube.adapter.ring.
(require '[dev.zeko.stube.core :as s])
(s/defcomponent :demo/prompt-number
:init (fn [{:keys [text]}] {:text text :answer ""})
:keep #{:answer}
:render (fn [self]
[:form (s/root-attrs self (s/on self :submit))
[:label (:text self)]
[:input (merge {:name "answer"} (s/bind :answer))]
[:button "OK"]])
:handle (fn [self _]
[(s/answer (parse-long (:answer self)))]))
(s/defcomponent :demo/guess
:init (fn [_] {:target (rand-int 100)})
:handle (fn [_ _]
[(s/call :demo/prompt-number {:text "Guess 1–100"} :on-guess)])
:on-guess (fn [self n]
(cond
(< n (:target self))
[(s/call :demo/prompt-number {:text "Too low"} :on-guess)]
:else
[(s/end {:winner true})])))
(s/mount! "/guess" :demo/guess)
(s/start! {:port 8080})
Everything in this namespace is intended to remain stable across
framework versions. Host-framework integration also has a stable
surface in dev.zeko.stube.embed / dev.zeko.stube.adapter.ring.
Other namespaces are internal until the framework reaches 1.0.
Public API of the stube framework.
Most users only need this namespace. It re-exports the small set of
functions that are intended to be called from application code; the
internals live in [[dev.zeko.stube.kernel]], [[dev.zeko.stube.conversation]],
[[dev.zeko.stube.registry]], [[dev.zeko.stube.render]], [[dev.zeko.stube.runtime]],
[[dev.zeko.stube.http]] and [[dev.zeko.stube.server]]. Hosts embedding stube
in their own Ring app reach for [[dev.zeko.stube.embed]] and
[[dev.zeko.stube.adapter.ring]].
----------------------------------------------------------------------
At a glance
----------------------------------------------------------------------
(require '[dev.zeko.stube.core :as s])
(s/defcomponent :demo/prompt-number
:init (fn [{:keys [text]}] {:text text :answer ""})
:keep #{:answer}
:render (fn [self]
[:form (s/root-attrs self (s/on self :submit))
[:label (:text self)]
[:input (merge {:name "answer"} (s/bind :answer))]
[:button "OK"]])
:handle (fn [self _]
[(s/answer (parse-long (:answer self)))]))
(s/defcomponent :demo/guess
:init (fn [_] {:target (rand-int 100)})
:handle (fn [_ _]
[(s/call :demo/prompt-number {:text "Guess 1–100"} :on-guess)])
:on-guess (fn [self n]
(cond
(< n (:target self))
[(s/call :demo/prompt-number {:text "Too low"} :on-guess)]
:else
[(s/end {:winner true})])))
(s/mount! "/guess" :demo/guess)
(s/start! {:port 8080})
----------------------------------------------------------------------
Stability of this surface
----------------------------------------------------------------------
Everything in this namespace is intended to remain stable across
framework versions. Host-framework integration also has a stable
surface in [[dev.zeko.stube.embed]] / [[dev.zeko.stube.adapter.ring]].
Other namespaces are internal until the framework reaches 1.0.Dev-time conveniences gated by the stube.dev system property.
Currently exposes one thing: post-handler state-shape validation via
optional Malli schemas declared on :component/state (see S-9).
Production runs incur the cost of a single (when @dev-mode? …)
check, so the validation pipeline can sit unconditionally inside
kernel/dispatch without bloating the hot path.
Enable validation by either
-Dstube.dev=true ; system property
STUBE_DEV=true ; environment variable
or by binding *enabled?* true at the REPL.
Malli stays an optional dependency: the namespace is loaded via
requiring-resolve only when a schema is actually present and
dev mode is on. Apps that never declare a schema, or never enable
dev mode, do not need Malli on the classpath.
Dev-time conveniences gated by the `stube.dev` system property.
Currently exposes one thing: post-handler state-shape validation via
optional Malli schemas declared on `:component/state` (see S-9).
Production runs incur the cost of a single `(when @dev-mode? …)`
check, so the validation pipeline can sit unconditionally inside
`kernel/dispatch` without bloating the hot path.
Enable validation by either
-Dstube.dev=true ; system property
STUBE_DEV=true ; environment variable
or by binding [[*enabled?*]] true at the REPL.
Malli stays an optional dependency: the namespace is loaded via
`requiring-resolve` only when a schema is actually present *and*
dev mode is on. Apps that never declare a schema, or never enable
dev mode, do not need Malli on the classpath.Constructors and accessors for the effect vocabulary the kernel folds.
Effects on the wire are plain vectors keyed by an op tag:
[:call <embed> :resume <k>]
[:call-in-slot <slot> <embed> :resume <k>]
[:answer <value>]
[:replace <embed>]
[:patch <hiccup>]
[:patch-signals <map>]
[:execute-script <js>]
[:history :replace|:push <url>]
[:io <fn>]
[:after <ms> <event>]
[:subscribe <topic> <event>]
[:unsubscribe] | [:unsubscribe <topic>]
[:back]
[:end <value>]
This namespace deliberately does NOT change that wire shape. It only
gives callers — handlers, the kernel's step methods, tests — named
helpers so the data contract lives in one file instead of being spread
across pattern-matching destructure forms.
Constructors are named after the op; accessors are named
<op>-<role> (e.g. call-embed, after-delay). Effects that
the kernel materially treats as continuations (call/answer/etc.) and
those that are pure side-effects (io/after/subscribe/etc.) live side
by side here because they share one folder.
Constructors and accessors for the effect vocabulary the kernel folds.
Effects on the wire are plain vectors keyed by an op tag:
[:call <embed> :resume <k>]
[:call-in-slot <slot> <embed> :resume <k>]
[:answer <value>]
[:replace <embed>]
[:patch <hiccup>]
[:patch-signals <map>]
[:execute-script <js>]
[:history :replace|:push <url>]
[:io <fn>]
[:after <ms> <event>]
[:subscribe <topic> <event>]
[:unsubscribe] | [:unsubscribe <topic>]
[:back]
[:end <value>]
This namespace deliberately does NOT change that wire shape. It only
gives callers — handlers, the kernel's `step` methods, tests — named
helpers so the data contract lives in one file instead of being spread
across pattern-matching destructure forms.
Constructors are named after the op; accessors are named
`<op>-<role>` (e.g. [[call-embed]], [[after-delay]]). Effects that
the kernel materially treats as continuations (call/answer/etc.) and
those that are pure side-effects (io/after/subscribe/etc.) live side
by side here because they share one folder.Embeddable runtime API for stube.
This is the namespace host applications reach for when they want to
drop stube into an existing Ring app, Integrant system, or test
harness. Every fn here is a thin facade over dev.zeko.stube.runtime;
the indirection used to go through requiring-resolve for a load-order
concern that no longer applies, see ADR 0006.
A host typically uses three or four functions:
(def k (embed/make-kernel {:store … :base-path "/app"}))
(def cid (embed/mint-conversation! k :flow/root init-args request))
(embed/shell-for k cid) ; → Hiccup nodes for `<body>`
(embed/head-tags k) ; → Hiccup nodes for `<head>`
(embed/dispatch! k cid event) ; programmatic event injection
(embed/halt! k) ; graceful shutdown
Component code never reaches into this namespace. Component authors
stay inside dev.zeko.stube.core (s/...), where state is implicit
and the active kernel is bound by the runtime around each dispatch.
Adapters (http.clj, halos/http.clj, server.clj) drive the
runtime through dev.zeko.stube.runtime directly — embed is the
host surface, not the adapter surface.
Embeddable runtime API for stube.
This is the namespace host applications reach for when they want to
drop stube into an existing Ring app, Integrant system, or test
harness. Every fn here is a thin facade over [[dev.zeko.stube.runtime]];
the indirection used to go through `requiring-resolve` for a load-order
concern that no longer applies, see ADR 0006.
Reading guide
-------------
A host typically uses three or four functions:
(def k (embed/make-kernel {:store … :base-path "/app"}))
(def cid (embed/mint-conversation! k :flow/root init-args request))
(embed/shell-for k cid) ; → Hiccup nodes for `<body>`
(embed/head-tags k) ; → Hiccup nodes for `<head>`
(embed/dispatch! k cid event) ; programmatic event injection
(embed/halt! k) ; graceful shutdown
Component code never reaches into this namespace. Component authors
stay inside [[dev.zeko.stube.core]] (`s/...`), where state is implicit
and the active kernel is bound by the runtime around each dispatch.
Adapters (`http.clj`, `halos/http.clj`, `server.clj`) drive the
runtime through [[dev.zeko.stube.runtime]] directly — `embed` is the
*host* surface, not the adapter surface.In-page error reporting for component throws.
When a :render or :handle fn throws, the kernel and frame layers
catch here instead of letting the exception bubble into the SSE
handler. The conversation is left untouched; an :error fragment
patches a banner in place of the failing instance's DOM node so the
user sees what went wrong and can keep interacting with the page.
The optional :on-error hook configured on
dev.zeko.stube.embed/make-kernel is invoked with (conv throwable) and may return its own fragment to display instead of
the default banner. The throwable handed to the hook is wrapped in
an ex-info carrying :stube.error/iid and :stube.error/phase
(:render or :handle).
In-page error reporting for component throws. When a `:render` or `:handle` fn throws, the kernel and frame layers catch here instead of letting the exception bubble into the SSE handler. The conversation is left untouched; an `:error` fragment patches a banner in place of the failing instance's DOM node so the user sees what went wrong and can keep interacting with the page. The optional `:on-error` hook configured on [[dev.zeko.stube.embed/make-kernel]] is invoked with `(conv throwable)` and may return its own fragment to display instead of the default banner. The throwable handed to the hook is wrapped in an `ex-info` carrying `:stube.error/iid` and `:stube.error/phase` (`:render` or `:handle`).
Slice-1: linear flow components via the defflow macro.
We deliberately shadow clojure.core/await (the agent-blocking
primitive nobody reaches for in 2026) with our own coroutine-suspending
await. The :refer-clojure :exclude keeps the compiler quiet.
A flow is a component whose role is to sequence a series of child
components and return a final value. Written by hand, a flow looks
like a state machine: a :start hook plus a chain of :on-step-N
resume keys that thread the partial result forward. That is correct
but tedious; the same logic reads naturally as straight-line code:
(s/defflow :booking/wizard []
(let [dates (s/await (s/embed :booking/dates))
room (s/await (s/embed :booking/room {:dates dates}))
receipt (s/await (s/embed :booking/pay {:price (price-for room)}))]
{:dates dates :room room :receipt receipt}))
The defflow macro compiles that body into a regular component
registration. Under the hood we use cloroutine:
the body becomes a stackless coroutine whose suspend point is
await, and the coroutine continuation lives on the instance map as
::coro. Each user event delivered by the kernel is funnelled into
one fixed resume key (:on-flow-resume) that injects the child's
answer back into the coroutine and steps it forward.
The result is a component whose externally observable behaviour is
identical to a hand-rolled chain of :on-step-N callbacks (modulo the
resume key's name; see ADR
0001-resume-key-naming),
but whose source reads as ordinary Clojure.
await cannot appear inside a nested fn, lazy seq, custom type
method, or anywhere else the surrounding form might escape the
coroutine's synchronous context. let/do/if/cond/when/
loop+recur are all fine.try/catch across an await is not supported: a catch in
the surrounding body cannot intercept the exception thrown into
the coroutine on resume. Use dev.zeko.stube.core/answer-error
in the child to route failures explicitly.defflow is therefore not durable — the dev.zeko.stube.store
file store logs a warning and skips its on-disk save. This is a
deliberate property: defflow is the ergonomic for transient
flows. When you need a long-running flow that survives restarts,
write a hand-rolled task component with :start plus named resume
keys. The tutorial section Durable flows: defflow vs. task
components shows the same wizard in both shapes.Slice-1: linear flow components via the [[defflow]] macro.
We deliberately shadow `clojure.core/await` (the agent-blocking
primitive nobody reaches for in 2026) with our own coroutine-suspending
`await`. The `:refer-clojure :exclude` keeps the compiler quiet.
A *flow* is a component whose role is to sequence a series of child
components and return a final value. Written by hand, a flow looks
like a state machine: a `:start` hook plus a chain of `:on-step-N`
resume keys that thread the partial result forward. That is correct
but tedious; the same logic reads naturally as straight-line code:
(s/defflow :booking/wizard []
(let [dates (s/await (s/embed :booking/dates))
room (s/await (s/embed :booking/room {:dates dates}))
receipt (s/await (s/embed :booking/pay {:price (price-for room)}))]
{:dates dates :room room :receipt receipt}))
The `defflow` macro compiles that body into a regular component
registration. Under the hood we use [cloroutine](https://github.com/leonoel/cloroutine):
the body becomes a stackless coroutine whose suspend point is
[[await]], and the coroutine continuation lives on the instance map as
`::coro`. Each user event delivered by the kernel is funnelled into
one fixed resume key (`:on-flow-resume`) that injects the child's
answer back into the coroutine and steps it forward.
The result is a component whose externally observable behaviour is
identical to a hand-rolled chain of `:on-step-N` callbacks (modulo the
resume key's name; see ADR
[0001-resume-key-naming](../../../../docs/decisions/0001-resume-key-naming.md)),
but whose source reads as ordinary Clojure.
----------------------------------------------------------------------
Restrictions inherited from cloroutine
----------------------------------------------------------------------
* `await` cannot appear inside a nested `fn`, lazy seq, custom type
method, or anywhere else the surrounding form might escape the
coroutine's synchronous context. `let`/`do`/`if`/`cond`/`when`/
`loop`+`recur` are all fine.
* `try`/`catch` *across* an `await` is not supported: a `catch` in
the surrounding body cannot intercept the exception thrown into
the coroutine on resume. Use [[dev.zeko.stube.core/answer-error]]
in the child to route failures explicitly.
* Storing the coroutine on the instance map gives up EDN
serialisability for flow instances. A conversation containing a
`defflow` is therefore not durable — the [[dev.zeko.stube.store]]
file store logs a warning and skips its on-disk save. This is a
deliberate property: `defflow` is the ergonomic for transient
flows. When you need a long-running flow that survives restarts,
write a hand-rolled task component with `:start` plus named resume
keys. The tutorial section *Durable flows: defflow vs. task
components* shows the same wizard in both shapes.Fragment data shapes and the one Datastar SSE translator.
A fragment is the kernel's wire-format-neutral way of saying "push this to the browser":
{:fragment/kind :elements
:fragment/html "<form id=ix-001>…</form>"
:fragment/opts {…patch-elements! options…}}
{:fragment/kind :signals
:fragment/data {:foo 1}
:fragment/opts {}}
{:fragment/kind :script
:fragment/script "alert('hi')"
:fragment/opts {}}
{:fragment/kind :error
:fragment/html "<div id=ix-7e2 class=stube-error …>…</div>"
:fragment/opts {:selector "#ix-7e2" :patch-mode :outer}}
{:fragment/kind :close}
The kernel never touches Datastar; it just produces these maps. This
namespace is the single Datastar SDK boundary — it owns the patch-
mode keyword translation and turns fragments into SSE events. Before
it existed, the translator was duplicated between dev.zeko.stube.http and
dev.zeko.stube.server.
Fragment data shapes and the one Datastar SSE translator.
A *fragment* is the kernel's wire-format-neutral way of saying "push
this to the browser":
{:fragment/kind :elements
:fragment/html "<form id=ix-001>…</form>"
:fragment/opts {…patch-elements! options…}}
{:fragment/kind :signals
:fragment/data {:foo 1}
:fragment/opts {}}
{:fragment/kind :script
:fragment/script "alert('hi')"
:fragment/opts {}}
{:fragment/kind :error
:fragment/html "<div id=ix-7e2 class=stube-error …>…</div>"
:fragment/opts {:selector "#ix-7e2" :patch-mode :outer}}
{:fragment/kind :close}
The kernel never touches Datastar; it just produces these maps. This
namespace is the **single Datastar SDK boundary** — it owns the patch-
mode keyword translation and turns fragments into SSE events. Before
it existed, the translator was duplicated between [[dev.zeko.stube.http]] and
[[dev.zeko.stube.server]].Rendering an instance into a fragment.
Three small jobs:
:render against the merged instance map (with
render/*conv* bound so s/render-slot can resolve children).selector=#root, mode=inner (first render of a
frame) and Datastar's default morph-by-id (subsequent renders),
so input focus and scroll state survive across re-renders.Rendering an instance into a fragment. Three small jobs: * Invoke the user's `:render` against the merged instance map (with `render/*conv*` bound so `s/render-slot` can resolve children). * Decorate the outer hiccup with halo data-attrs when halos are active, and cache the resulting HTML on the instance for the dev panel's HTML tab. * Decide between `selector=#root, mode=inner` (first render of a frame) and Datastar's default morph-by-id (subsequent renders), so input focus and scroll state survive across re-renders.
Development overlay — Seaside-style halos.
Off by default; activated when (a) the server was started with
:halos? true and (b) the conversation carries :conv/halos? true
(set by the shell handler when the URL has ?halos=1).
Three layers live here:
decorate-root merges
data-stube-iid / data-stube-type into every instance's outer
hiccup attrs. The kernel calls this once per render when halos are
active.panel-hiccup builds the inspector
content fetched by halos.js from <base>/halos/<cid>/panel.
The panel is plain server-rendered HTML; no SSE / no extra conv.tree, instance, history,
where. Re-exported by dev.zeko.stube.core.No I/O lives here. The http layer turns these values into responses.
Development overlay — Seaside-style halos. Off by default; activated when (a) the server was started with `:halos? true` and (b) the conversation carries `:conv/halos? true` (set by the shell handler when the URL has `?halos=1`). Three layers live here: 1. **Hiccup decoration** — [[decorate-root]] merges `data-stube-iid` / `data-stube-type` into every instance's outer hiccup attrs. The kernel calls this once per render when halos are active. 2. **Side-panel hiccup** — [[panel-hiccup]] builds the inspector content fetched by `halos.js` from `<base>/halos/<cid>/panel`. The panel is plain server-rendered HTML; no SSE / no extra conv. 3. **REPL helpers** — [[tree]], [[instance]], [[history]], [[where]]. Re-exported by `dev.zeko.stube.core`. No I/O lives here. The http layer turns these values into responses.
Ring handlers for the dev halos overlay. All endpoints 404 when the
server was not started with :halos? true, so production builds
never expose them.
Three endpoints:
| route | method | purpose |
|---|---|---|
<base>/halos.js | GET | the overlay script |
<base>/halos/:cid/enable | POST | flip a conv into halos mode + redraw |
<base>/halos/:cid/panel | GET | render the inspector side-panel HTML |
Ring handlers for the dev halos overlay. All endpoints 404 when the server was not started with `:halos? true`, so production builds never expose them. Three endpoints: | route | method | purpose | |--------------------------------|--------|----------------------------------------| | `<base>/halos.js` | GET | the overlay script | | `<base>/halos/:cid/enable` | POST | flip a conv into halos mode + redraw | | `<base>/halos/:cid/panel` | GET | render the inspector side-panel HTML |
Ring handlers that bridge HTTP to the kernel.
Five endpoints implement the client/server contract:
| route | method | purpose |
|---|---|---|
/<mount-path> | GET | mint a conversation, serve the shell HTML |
/sse/:cid | GET | open the long-lived SSE stream for the conversation |
/back/:cid | POST | restore the previous conversation snapshot |
/upload/:cid/:iid | POST | parse multipart data and dispatch :upload-received |
/event/:cid/:iid/:event | POST | dispatch one event; the iid and event live in the path, signals in the body |
The shell page is a trivial HTML document. All real UI is delivered
via SSE patches once the browser connects to /sse/:cid.
Ring handlers that bridge HTTP to the kernel. Five endpoints implement the client/server contract: | route | method | purpose | |-------------------------------|--------|-------------------------------------------------------------------------------| | `/<mount-path>` | GET | mint a conversation, serve the shell HTML | | `/sse/:cid` | GET | open the long-lived SSE stream for the conversation | | `/back/:cid` | POST | restore the previous conversation snapshot | | `/upload/:cid/:iid` | POST | parse multipart data and dispatch `:upload-received` | | `/event/:cid/:iid/:event` | POST | dispatch one event; the iid and event live in the path, signals in the body | The shell page is a trivial HTML document. All real UI is delivered via SSE patches once the browser connects to `/sse/:cid`.
The pure runtime: step, run-effects, dispatch.
Hosts embedding stube do not call into this namespace directly; the
documented embedder surface lives in dev.zeko.stube.embed. This
file is the value-language only: every function takes a conversation
value (plus the event for dispatch) and returns the next conversation
value plus the fragments to emit.
Everything in this namespace operates on plain values. A handler
returns [self' effects]; run-effects folds those effects into the
conversation, producing the next conversation value and the list of
fragments that must be pushed to the browser. A fragment is just a
small map describing one Datastar event — see [[fragment-shape]] for
the schema.
No I/O, no atoms, no SSE — that all lives one layer up in
dev.zeko.stube.runtime / dev.zeko.stube.http / dev.zeko.stube.server.
This means the entire interaction loop is testable from a REPL with
dispatch and a hand-built conversation value, with no server running.
[:call <embed> :resume <k>] push a child frame; on `:answer` the
parent's `:k` function is invoked
[:call-in-slot <slot> <embed> :resume <k>]
temporarily swap one embedded slot;
the child answers back to the parent
without taking over the page
[:set-keyed-children <slot> <[[key embed]…]>]
reconcile an ordered, key-addressed
set of children; emits per-child
append/remove/outer fragments
instead of re-rendering the parent
[:answer <value>] pop this frame; deliver `value` to
the parent under its resume key
[:replace <embed>] pop this frame and push another in
its place (Seaside `become:`)
[:patch <hiccup>] extra DOM patch (no stack change)
[:patch-signals <map>] push a Datastar signal patch
[:execute-script <js>] run literal JS in the browser
[:history :replace|:push url] sync browser URL (replaceState/pushState)
[:io <fn>] ask the bound runtime to run `(fn)`
off-thread; pure folds leave it inert
[:after ms event] schedule a future event for this instance
[:subscribe topic event] subscribe this instance to published messages
[:unsubscribe topic?] remove this instance's topic subscription(s)
[:back] restore the previous conversation
from `:conv/history` (slice 3)
[:end <value>] terminate the conversation
All effects produce zero or more fragments and an updated conversation.
Beyond the keys read directly by :render / :handle, a component
may include:
:start (fn [self] [self' effects])
:stop (fn [self] [self' effects])
:wakeup (fn [self] [self' effects])
:start runs once immediately after instantiation for both stack
frames and embedded children, which lets "task" components launch
their first :call without a synthetic user event and lets widgets
subscribe or schedule timers. :stop runs just before a frame/subtree
is removed; :wakeup runs when a persisted or history-restored frame
becomes live again.
The pure runtime: `step`, `run-effects`, `dispatch`.
Hosts embedding stube do not call into this namespace directly; the
documented embedder surface lives in [[dev.zeko.stube.embed]]. This
file is the value-language only: every function takes a conversation
value (plus the event for `dispatch`) and returns the next conversation
value plus the fragments to emit.
Reading guide
-------------
Everything in this namespace operates on plain values. A handler
returns `[self' effects]`; `run-effects` folds those effects into the
conversation, producing the next conversation value and the list of
*fragments* that must be pushed to the browser. A fragment is just a
small map describing one Datastar event — see [[fragment-shape]] for
the schema.
No I/O, no atoms, no SSE — that all lives one layer up in
[[dev.zeko.stube.runtime]] / [[dev.zeko.stube.http]] / [[dev.zeko.stube.server]].
This means the entire interaction loop is testable from a REPL with
`dispatch` and a hand-built conversation value, with no server running.
Effect vocabulary
-----------------
[:call <embed> :resume <k>] push a child frame; on `:answer` the
parent's `:k` function is invoked
[:call-in-slot <slot> <embed> :resume <k>]
temporarily swap one embedded slot;
the child answers back to the parent
without taking over the page
[:set-keyed-children <slot> <[[key embed]…]>]
reconcile an ordered, key-addressed
set of children; emits per-child
append/remove/outer fragments
instead of re-rendering the parent
[:answer <value>] pop this frame; deliver `value` to
the parent under its resume key
[:replace <embed>] pop this frame and push another in
its place (Seaside `become:`)
[:patch <hiccup>] extra DOM patch (no stack change)
[:patch-signals <map>] push a Datastar signal patch
[:execute-script <js>] run literal JS in the browser
[:history :replace|:push url] sync browser URL (replaceState/pushState)
[:io <fn>] ask the bound runtime to run `(fn)`
off-thread; pure folds leave it inert
[:after ms event] schedule a future event for this instance
[:subscribe topic event] subscribe this instance to published messages
[:unsubscribe topic?] remove this instance's topic subscription(s)
[:back] restore the previous conversation
from `:conv/history` (slice 3)
[:end <value>] terminate the conversation
All effects produce zero or more fragments and an updated conversation.
Component lifecycle keys
------------------------
Beyond the keys read directly by `:render` / `:handle`, a component
may include:
:start (fn [self] [self' effects])
:stop (fn [self] [self' effects])
:wakeup (fn [self] [self' effects])
`:start` runs once immediately after instantiation for both stack
frames and embedded children, which lets "task" components launch
their first `:call` without a synthetic user event and lets widgets
subscribe or schedule timers. `:stop` runs just before a frame/subtree
is removed; `:wakeup` runs when a persisted or history-restored frame
becomes live again.Keyed-children primitive.
A parent can declare an ordered set of child component instances
identified by stable user-facing keys instead of fixed slot names.
When the set changes, the kernel emits per-child element fragments
(:append / :remove / :outer against the container) rather than
re-rendering the whole parent.
(s/set-keyed-children slot pairs) where pairs is
[[stable-key embed-spec] ...] in display order. Triggers the
diff fold in reconcile!.(s/keyed-children self slot) returns hiccup
for the container <div id=PARENTIID--SLOTNAME> with each child's
current HTML inlined in order.State shape on the parent instance:
:instance/keyed-slots
{:slot/cols {:order [:c1 :c2]
:children {:c1 {:iid "ix-000005"
:embed {:embed/type :demo/counter
:embed/args {:start 0}}}
:c2 {:iid "ix-000006"
:embed {…}}}}}
:start, emit :append
at the right position
(:append/:prepend/:after).:stop, remove, emit :remove
targeting the child iid.:init the child in place
(iid preserved, descendants rebuilt),
emit :outer
at the child iid.:outer against the
whole container (cheaper than a
parent re-render; still
distinguishable from individual
diffs in replay traces).Before the parent has been rendered the first time, reconcile!
performs all the bookkeeping silently and emits no fragments — the
parent's normal render-frame will pick up the populated state and
emit one container.
Keyed-children primitive.
A parent can declare an ordered set of child component instances
identified by stable user-facing keys instead of fixed slot names.
When the set changes, the kernel emits per-child element fragments
(`:append` / `:remove` / `:outer` against the container) rather than
re-rendering the whole parent.
Surface
-------
- **Effect** `(s/set-keyed-children slot pairs)` where `pairs` is
`[[stable-key embed-spec] ...]` in display order. Triggers the
diff fold in [[reconcile!]].
- **Render helper** `(s/keyed-children self slot)` returns hiccup
for the container `<div id=PARENTIID--SLOTNAME>` with each child's
current HTML inlined in order.
State shape on the parent instance:
:instance/keyed-slots
{:slot/cols {:order [:c1 :c2]
:children {:c1 {:iid "ix-000005"
:embed {:embed/type :demo/counter
:embed/args {:start 0}}}
:c2 {:iid "ix-000006"
:embed {…}}}}}
Diff rules
----------
- Key in new but not old → mint, `:start`, emit `:append`
at the right position
(`:append`/`:prepend`/`:after`).
- Key in old but not new → `:stop`, remove, emit `:remove`
targeting the child iid.
- Same key, different embed args → re-`:init` the child in place
(iid preserved, descendants rebuilt),
emit `:outer`
at the child iid.
- Same key, same embed args → no fragment.
- Different order, same key-set → emit one `:outer` against the
whole container (cheaper than a
parent re-render; still
distinguishable from individual
diffs in replay traces).
Before the parent has been rendered the first time, `reconcile!`
performs all the bookkeeping silently and emits no fragments — the
parent's normal `render-frame` will pick up the populated state and
emit one container.Component lifecycle hooks: :start, :stop, :wakeup.
Each hook returns [self' effects] (or just effects, for terse
cleanup hooks). The functions in this namespace fold a hook's
emitted effects through run-effects, returning [conv' fragments]
for the caller.
To keep the dependency direction one-way (kernel requires lifecycle,
not the other way), the kernel's run-effects is passed in as
run-effects-fn. No back-reference from lifecycle to kernel.
Component lifecycle hooks: `:start`, `:stop`, `:wakeup`. Each hook returns `[self' effects]` (or just `effects`, for terse cleanup hooks). The functions in this namespace fold a hook's emitted effects through `run-effects`, returning `[conv' fragments]` for the caller. To keep the dependency direction one-way (kernel requires lifecycle, not the other way), the kernel's `run-effects` is passed in as `run-effects-fn`. No back-reference from lifecycle to kernel.
The component registry.
In stube, a component is a plain map of the form
{:component/id :auth/login
:component/doc "Prompt for credentials."
:component/init (fn [args] state-map)
:component/render (fn [self] hiccup)
:component/handle (fn [self event] [self' effects])
:component/keep #{:signal-keys}
:component/start (fn [self] [self' effects])
:component/stop (fn [self] [self' effects])
:component/wakeup (fn [self] [self' effects])
:component/children {:slot/x embed-spec}
:on-foo (fn [self answer-value] [self' effects])
…}
Behaviour lives in the values; the key under which the kernel finds them
is by namespaced convention. Resume keys (:on-foo, :on-step-3, …)
are looked up dynamically when an [:answer …] effect pops a child
frame: the parent's :instance/resume value names the function to call.
Author keys (:init, :render, :handle, :keep, :doc, :state,
:start, :stop, :wakeup, :children, :url) are lifted to their
:component/<name> homes by register! so every cdef the kernel
reads has a single uniform namespace.
Two kinds of keys, two rules. Lifecycle keys (:init, :render,
:handle, etc.) are a closed set: the framework owns them, and the
kernel only consults their :component/<name> form, so the
bare-form/lifted-form pair must agree. Resume keys (:on-foo,
:on-error-foo) are open: component authors invent new ones per
call site, and the kernel looks them up by exact name, so they pass
through verbatim with no namespacing. Adding a new colocated key
is a framework-author change — extend colocated-keys and the
AGENTS/docs entries together; user-author code never reaches for
this.
The registry maps :component/id to the component map. It is held in a
single atom so component definitions can be evaluated at namespace load
time the same way defmulti defmethods are.
The component registry.
In stube, a *component* is a plain map of the form
{:component/id :auth/login
:component/doc "Prompt for credentials."
:component/init (fn [args] state-map)
:component/render (fn [self] hiccup)
:component/handle (fn [self event] [self' effects])
:component/keep #{:signal-keys}
:component/start (fn [self] [self' effects])
:component/stop (fn [self] [self' effects])
:component/wakeup (fn [self] [self' effects])
:component/children {:slot/x embed-spec}
:on-foo (fn [self answer-value] [self' effects])
…}
Behaviour lives in the values; the key under which the kernel finds them
is by namespaced convention. Resume keys (`:on-foo`, `:on-step-3`, …)
are looked up dynamically when an `[:answer …]` effect pops a child
frame: the parent's `:instance/resume` value names the function to call.
Author keys (`:init`, `:render`, `:handle`, `:keep`, `:doc`, `:state`,
`:start`, `:stop`, `:wakeup`, `:children`, `:url`) are lifted to their
`:component/<name>` homes by [[register!]] so every cdef the kernel
reads has a single uniform namespace.
Two kinds of keys, two rules. *Lifecycle* keys (`:init`, `:render`,
`:handle`, etc.) are a closed set: the framework owns them, and the
kernel only consults their `:component/<name>` form, so the
bare-form/lifted-form pair must agree. *Resume* keys (`:on-foo`,
`:on-error-foo`) are open: component authors invent new ones per
call site, and the kernel looks them up by exact name, so they pass
through verbatim with no namespacing. Adding a new colocated key
is a framework-author change — extend `colocated-keys` and the
AGENTS/docs entries together; user-author code never reaches for
this.
The registry maps `:component/id` to the component map. It is held in a
single atom so component definitions can be evaluated at namespace load
time the same way `defmulti` defmethods are.Hiccup → HTML rendering and the small DSL for Datastar attributes.
Two responsibilities live here, deliberately kept apart from the kernel:
Serialise hiccup to HTML with Chassis.
The kernel works with hiccup data structures all the way through;
they are only stringified at the very edge, just before
patch-elements! writes to the wire. This keeps everything before
the wire pure, diff-able, and REPL-inspectable.
Generate Datastar attribute fragments — on, bind — that
tag a piece of UI with the wiring that lets the client post events
back to the right conversation and instance.
The cid only exists at request time, so the helpers consult a dynamic var bound by the http layer for the duration of a render.
Hiccup → HTML rendering and the small DSL for Datastar attributes. Two responsibilities live here, deliberately kept apart from the kernel: 1. **Serialise hiccup to HTML** with [Chassis](https://github.com/onionpancakes/chassis). The kernel works with hiccup data structures all the way through; they are only stringified at the very edge, just before `patch-elements!` writes to the wire. This keeps everything before the wire pure, diff-able, and REPL-inspectable. 2. **Generate Datastar attribute fragments** — `on`, `bind` — that tag a piece of UI with the wiring that lets the client post events back to the right conversation and instance. The cid only exists at request time, so the helpers consult a dynamic var bound by the http layer for the duration of a render.
Embeddable stube runtime instances.
dev.zeko.stube.kernel remains the pure effect fold. This namespace
holds the small amount of mutable runtime state needed to embed that
fold in a host Ring app: live conversations, SSE channels, timers,
subscriptions, and the pending-root baton between shell render and
first SSE attach.
Embeddable stube runtime instances. `dev.zeko.stube.kernel` remains the pure effect fold. This namespace holds the small amount of mutable runtime state needed to embed that fold in a host Ring app: live conversations, SSE channels, timers, subscriptions, and the pending-root baton between shell render and first SSE attach.
Standalone http-kit lifecycle around a default stube kernel.
Two concerns live here:
start!, stop!, mount!, unmount!, plus the
per-conversation reaper that runs alongside the listener.default-kernel,
conversation, active-conversations, end!, publish!,
inspect. These all delegate to dev.zeko.stube.runtime with
the process-global kernel value.Adapters and tests that already have a kernel in hand should call
dev.zeko.stube.runtime directly — this namespace is for the
greenfield (s/start!) + (s/mount!) workflow.
The mutable conversation/SSE/timer state lives on the kernel value
returned by dev.zeko.stube.embed/make-kernel (a thin public
facade over dev.zeko.stube.runtime/make-kernel).
Standalone http-kit lifecycle around a default stube kernel. Two concerns live here: * The lifecycle: `start!`, `stop!`, `mount!`, `unmount!`, plus the per-conversation reaper that runs alongside the listener. * The default-kernel convenience surface: `default-kernel`, `conversation`, `active-conversations`, `end!`, `publish!`, `inspect`. These all delegate to `dev.zeko.stube.runtime` with the process-global kernel value. Adapters and tests that already have a kernel in hand should call [[dev.zeko.stube.runtime]] directly — this namespace is for the greenfield `(s/start!)` + `(s/mount!)` workflow. The mutable conversation/SSE/timer state lives on the kernel value returned by [[dev.zeko.stube.embed/make-kernel]] (a thin public facade over [[dev.zeko.stube.runtime/make-kernel]]).
Session cookies + conversation ownership checks.
Each browser gets a stube_sid cookie minted on first visit; that
value is recorded on the conversation as :conv/owner-token when the
cid is created. Subsequent requests for that cid are accepted only
when the cookie matches the stored token. This is the single
primitive authorized? both http and halos handlers use.
Session cookies + conversation ownership checks. Each browser gets a `stube_sid` cookie minted on first visit; that value is recorded on the conversation as `:conv/owner-token` when the cid is created. Subsequent requests for that cid are accepted only when the cookie matches the stored token. This is the single primitive [[authorized?]] both http and halos handlers use.
The HTML shell page served on the first GET of a mounted flow.
The shell is effectively empty: a <div id="root"> placeholder
plus a data-init that opens the long-lived SSE stream. Once the
browser connects, every UI patch arrives through that stream — so
this namespace knows nothing about components or rendering.
The HTML shell page served on the first GET of a mounted flow. The shell is effectively empty: a `<div id="root">` placeholder plus a `data-init` that opens the long-lived SSE stream. Once the browser connects, every UI patch arrives through that stream — so this namespace knows nothing about components or rendering.
Pluggable persistence for conversation values.
A conversation is just a map of plain Clojure data (see
dev.zeko.stube.conversation). Slice 3 adds a small protocol for swapping
out the storage backend without touching the kernel:
(s/start! {:port 8080
:store (dev.zeko.stube.store/file-store "/var/lib/stube/convs")})
Three operations are enough:
| op | when |
|---|---|
load-all | once at startup, to repopulate memory |
save! | after every successful swap-conv! |
delete! | when a conversation ends (:end, reaper) |
The default is in-memory-store, which keeps the slice-0 behaviour
unchanged: the in-process conversation atom on the active
dev.zeko.stube.runtime kernel is the only copy of the truth and
save! is a no-op.
dev.zeko.stube.flow continuations are live cloroutine objects, not
EDN values. A conversation that contains a defflow instance is
therefore not durable: on a clean restart its on-disk copy is gone
(the file-store logs a warning and skips the save).
This is a deliberate property of the framework, not a gap. defflow
is the ergonomic for transient flows — wizards a user completes in
one sitting, multi-step UIs whose value comes from the linear-code
shape. If the flow needs to survive a deploy or a process crash,
write it as a hand-rolled task component instead: a :start hook
plus named resume keys threads the same state through an EDN-clean
map, and persists transparently through this store. See the
Durable flows: defflow vs. task components section of the tutorial
for a side-by-side example.
The kernel does not refuse to register or run a defflow-containing
conversation against a file-store; the live behaviour is normal.
Only the durable copy is skipped, and the warning fires so you can
feel the boundary.
Pluggable persistence for conversation values.
A conversation is just a map of plain Clojure data (see
[[dev.zeko.stube.conversation]]). Slice 3 adds a small protocol for swapping
out the storage backend without touching the kernel:
(s/start! {:port 8080
:store (dev.zeko.stube.store/file-store "/var/lib/stube/convs")})
Three operations are enough:
| op | when |
|--------------|---------------------------------------------|
| `load-all` | once at startup, to repopulate memory |
| `save!` | after every successful `swap-conv!` |
| `delete!` | when a conversation ends (`:end`, reaper) |
The default is [[in-memory-store]], which keeps the slice-0 behaviour
unchanged: the in-process conversation atom on the active
`dev.zeko.stube.runtime` kernel is the only copy of the truth and
`save!` is a no-op.
----------------------------------------------------------------------
defflow is in-memory only — by design
----------------------------------------------------------------------
`dev.zeko.stube.flow` continuations are live cloroutine objects, not
EDN values. A conversation that contains a `defflow` instance is
therefore not durable: on a clean restart its on-disk copy is gone
(the [[file-store]] logs a warning and skips the save).
This is a deliberate property of the framework, not a gap. `defflow`
is the ergonomic for transient flows — wizards a user completes in
one sitting, multi-step UIs whose value comes from the linear-code
shape. If the flow needs to survive a deploy or a process crash,
write it as a hand-rolled task component instead: a `:start` hook
plus named resume keys threads the same state through an EDN-clean
map, and persists transparently through this store. See the
*Durable flows: defflow vs. task components* section of the tutorial
for a side-by-side example.
The kernel does not refuse to register or run a `defflow`-containing
conversation against a [[file-store]]; the live behaviour is normal.
Only the durable copy is skipped, and the warning fires so you can
feel the boundary.Small canonical UI components used by the convenience helpers in
dev.zeko.stube.core.
This namespace deliberately does not require dev.zeko.stube.core: it registers
component maps directly so dev.zeko.stube.core can require us without a cycle.
Small canonical UI components used by the convenience helpers in [[dev.zeko.stube.core]]. This namespace deliberately does not require `dev.zeko.stube.core`: it registers component maps directly so `dev.zeko.stube.core` can require us without a cycle.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |