Liking cljdoc? Tell your friends :D

dev.zeko.stube.async

Side-effecting registries that live in parallel to the conversation atom: timers, pub/sub, and the pending-flow baton.

Three independent state shapes:

atomshaperole
!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!`.
raw docstring

dev.zeko.stube.conversation

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.
raw docstring

dev.zeko.stube.core

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.
raw docstring

dev.zeko.stube.effects

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.
raw docstring

dev.zeko.stube.flow

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).
  • 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.
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.
raw docstring

dev.zeko.stube.fragments

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]].
raw docstring

dev.zeko.stube.frame

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.
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.
raw docstring

dev.zeko.stube.halos

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 decorationdecorate-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 hiccuppanel-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 helperstree, 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.
raw docstring

dev.zeko.stube.halos.http

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:

routemethodpurpose
/stube/halos.jsGETthe overlay script
/stube/halos/:cid/enablePOSTflip a conv into halos mode + redraw
/stube/halos/:cid/panelGETrender 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   |
raw docstring

dev.zeko.stube.http

Ring handlers that bridge HTTP to the kernel.

Five endpoints implement the client/server contract:

routemethodpurpose
/<mount-path>GETmint a conversation, serve the shell HTML
/conv/:cid/sseGETopen the long-lived SSE stream for the conversation
/conv/:cid/backPOSTrestore the previous conversation snapshot
/stube/upload/:cid/:iidPOSTparse multipart data and dispatch :upload-received
/conv/:cid/:iid/:eventPOSTdispatch 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`.
raw docstring

dev.zeko.stube.kernel

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.
raw docstring

dev.zeko.stube.lifecycle

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.
raw docstring

dev.zeko.stube.registry

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.
raw docstring

dev.zeko.stube.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. 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 fragmentson, 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.
raw docstring

dev.zeko.stube.routes

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.
raw docstring

dev.zeko.stube.server

Live conversation atom + http-kit lifecycle.

Three atoms live here:

atomshapepurpose
!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.
raw docstring

dev.zeko.stube.session

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.
raw docstring

dev.zeko.stube.shell

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.
raw docstring

dev.zeko.stube.store

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:

opwhen
load-allonce 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.
raw docstring

dev.zeko.stube.ui

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.
raw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close