Liking cljdoc? Tell your friends :D

dev.zeko.stube.adapter.ring

Ring/Reitit adapter for embeddable stube kernels.

Ring/Reitit adapter for embeddable stube kernels.
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.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.

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

dev.zeko.stube.dev

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

dev.zeko.stube.embed

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.

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

dev.zeko.stube.errors

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`).
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 ADR 0001-resume-key-naming), 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.
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.
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 :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]].
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 <base>/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 `<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.
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
<base>/halos.jsGETthe overlay script
<base>/halos/:cid/enablePOSTflip a conv into halos mode + redraw
<base>/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                                |
|--------------------------------|--------|----------------------------------------|
| `<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   |
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
/sse/:cidGETopen the long-lived SSE stream for the conversation
/back/:cidPOSTrestore the previous conversation snapshot
/upload/:cid/:iidPOSTparse multipart data and dispatch :upload-received
/event/: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 /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`.
raw docstring

dev.zeko.stube.kernel

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.

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

dev.zeko.stube.keyed

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.

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.
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}
 :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.
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.runtime

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

dev.zeko.stube.server

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).

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]]).
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 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.

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