Side-effecting registries that live in parallel to the conversation atom: timers, pub/sub, and the pending-flow baton.
Three independent state shapes:
| atom | shape | role |
|---|---|---|
!pending-flows | {cid → flow-id} | one-shot baton between mount |
| and first SSE connect | ||
!timers | {cid → #{future}} | scheduled future-fired events |
!subscriptions | {topic → {[cid iid] event}} | live pub/sub routing |
Delivery — both timer fires and publish! — calls back into a
conversation via a dispatch function the server installs at startup
with install-dispatch!. Keeping the dependency one-way (server
requires async, async never requires server) avoids the circular
require that would otherwise emerge from
schedule-event! → dispatch! → kernel hook → schedule-event!.
Side-effecting registries that live in parallel to the conversation
atom: timers, pub/sub, and the pending-flow baton.
Three independent state shapes:
| atom | shape | role |
|-----------------|--------------------------------|--------------------------------|
| `!pending-flows`| `{cid → flow-id}` | one-shot baton between mount |
| | | and first SSE connect |
| `!timers` | `{cid → #{future}}` | scheduled future-fired events |
| `!subscriptions`| `{topic → {[cid iid] event}}` | live pub/sub routing |
Delivery — both timer fires and `publish!` — calls back into a
conversation via a dispatch function the server installs at startup
with [[install-dispatch!]]. Keeping the dependency one-way (server
requires async, async never requires server) avoids the circular
require that would otherwise emerge from
`schedule-event! → dispatch! → kernel hook → schedule-event!`.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.
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.http and
dev.zeko.stube.server.
────────────────────────────────────────────────────────────────────── At a glance ──────────────────────────────────────────────────────────────────────
(require '[dev.zeko.stube.core :as s])
(s/defcomponent :ui/prompt
: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 :ui/prompt {:text "Guess 1–100"} :on-guess)])
:on-guess (fn [self n]
(cond
(< n (:target self))
[(s/call :ui/prompt {: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. Names and arities outside this namespace are considered 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.http]] and
[[dev.zeko.stube.server]].
──────────────────────────────────────────────────────────────────────
At a glance
──────────────────────────────────────────────────────────────────────
(require '[dev.zeko.stube.core :as s])
(s/defcomponent :ui/prompt
: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 :ui/prompt {:text "Guess 1–100"} :on-guess)])
:on-guess (fn [self n]
(cond
(< n (:target self))
[(s/call :ui/prompt {: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. Names and arities outside this namespace are
considered internal until the framework reaches 1.0.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>]
[: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>]
[: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.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 §13 of v2_1.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 in slice 1 (open
question, see v2_1.md §13).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 §13 of `v2_1.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 in slice 1 (open
question, see `v2_1.md` §13).
* Storing the coroutine on the instance map gives up strict EDN
serialisability for flow instances; persistence (slice 3) will
treat them as a separate concern.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 :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 :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 /stube/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 `/stube/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 |
|---|---|---|
/stube/halos.js | GET | the overlay script |
/stube/halos/:cid/enable | POST | flip a conv into halos mode + redraw |
/stube/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 | |--------------------------------|--------|----------------------------------------| | `/stube/halos.js` | GET | the overlay script | | `/stube/halos/:cid/enable` | POST | flip a conv into halos mode + redraw | | `/stube/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 |
/conv/:cid/sse | GET | open the long-lived SSE stream for the conversation |
/conv/:cid/back | POST | restore the previous conversation snapshot |
/stube/upload/:cid/:iid | POST | parse multipart data and dispatch :upload-received |
/conv/: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 /conv/:cid/sse.
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 | | `/conv/:cid/sse` | GET | open the long-lived SSE stream for the conversation | | `/conv/:cid/back` | POST | restore the previous conversation snapshot | | `/stube/upload/:cid/:iid` | POST | parse multipart data and dispatch `:upload-received` | | `/conv/: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 `/conv/:cid/sse`.
The pure runtime: step, run-effects, dispatch.
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.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
[: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
[:io <fn>] call `(fn)` off-thread (fire-and-forget)
[: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.
The pure runtime: `step`, `run-effects`, `dispatch`.
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.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
[: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
[:io <fn>] call `(fn)` off-thread (fire-and-forget)
[: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.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}
: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.
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}
: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.
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.
The reitit router.
Conversation-scoped routes are always present; user mounts are appended. The router resolves the user-mount table lazily on each request so adding mounts after start works.
Before this namespace existed, dev.zeko.stube.server built the
router using requiring-resolve to dodge a circular require with
dev.zeko.stube.http. Pulling the build into its own ns lets us
require both layers directly.
The reitit router. Conversation-scoped routes are always present; user mounts are appended. The router resolves the user-mount table lazily on each request so adding mounts after start works. Before this namespace existed, `dev.zeko.stube.server` built the router using `requiring-resolve` to dodge a circular require with `dev.zeko.stube.http`. Pulling the build into its own ns lets us require both layers directly.
Live conversation atom + http-kit lifecycle.
Three atoms live here:
| atom | shape | purpose |
|---|---|---|
!conversations | {cid → conv} | the live conversation values |
!sse-sessions | {cid → sse-gen} | the open browser connections |
!mounts | {path → flow-id} | the routes registered with mount! |
Timers, pub/sub, and the pending-flow baton moved to
dev.zeko.stube.async — this namespace owns conversation storage and
the http-kit server. All the http handlers live in
dev.zeko.stube.http; this namespace only exposes the functions
they need, so the http layer never reaches into raw atoms.
Live conversation atom + http-kit lifecycle.
Three atoms live here:
| atom | shape | purpose |
|--------------------|----------------------|--------------------------------------------|
| `!conversations` | `{cid → conv}` | the live conversation values |
| `!sse-sessions` | `{cid → sse-gen}` | the open browser connections |
| `!mounts` | `{path → flow-id}` | the routes registered with [[mount!]] |
Timers, pub/sub, and the pending-flow baton moved to
[[dev.zeko.stube.async]] — this namespace owns conversation storage and
the http-kit server. All the http handlers live in
[[dev.zeko.stube.http]]; this namespace only exposes the functions
they need, so the http layer never reaches into raw atoms.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 atom in dev.zeko.stube.server is the only copy of
the truth and save! is a no-op.
────────────────────────────────────────────────────────────────────── Cloroutine and persistence ──────────────────────────────────────────────────────────────────────
dev.zeko.stube.flow continuations are stateful objects, not EDN values. A
conversation that contains a live defflow instance therefore can't
go through the EDN file store as-is; the store will log a warning
and skip that conversation. Hand-rolled task components from slice
0 (the :start + resume-key pattern) ARE EDN-clean and persist
perfectly. Closing this gap is open work for a later slice.
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 atom in `dev.zeko.stube.server` is the only copy of
the truth and `save!` is a no-op.
──────────────────────────────────────────────────────────────────────
Cloroutine and persistence
──────────────────────────────────────────────────────────────────────
`dev.zeko.stube.flow` continuations are stateful objects, not EDN values. A
conversation that contains a live `defflow` instance therefore can't
go through the EDN file store as-is; the store will log a warning
and skip that conversation. Hand-rolled task components from slice
0 (the `:start` + resume-key pattern) ARE EDN-clean and persist
perfectly. Closing this gap is open work for a later slice.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 |