Liking cljdoc? Tell your friends :D

co.multiply.tropical.action

Actions as capabilities: server-side closures reachable from the client only while the island rendering them is mounted.

use-action mints an unguessable token bound to the session's user the first time an island asks for it, and revokes it once the island stops asking or unmounts. So a POST can only reach closures currently rendered for that user, which is the property Electric gets from (e/server (when allowed (e/client ...))). The authority part of the check happened when the island decided to render the action. The arguments (Datastar signals, and a value if the action takes one) are untrusted input, as e/client -> e/server values always were.

Actions as capabilities: server-side closures reachable from the client only
while the island rendering them is mounted.

`use-action` mints an unguessable token bound to the session's user the first
time an island asks for it, and revokes it once the island stops asking or
unmounts. So a POST can only reach closures currently rendered for that user,
which is the property Electric gets from `(e/server (when allowed (e/client ...)))`.
The authority part of the check happened when the island decided to render
the action. The arguments (Datastar signals, and a value if the action takes
one) are untrusted input, as `e/client` -> `e/server` values always were.
raw docstring

co.multiply.tropical.compression

Compressing a tab's stream, which an app opts into with :compression (co.multiply.tropical.ring): the encodings, how a request's is chosen, and the encoder each opens over the response.

  • :zstd Zstandard, through the libzstd tropical ships (co.multiply.tropical.sse.ZstdEncoder).
  • :br Brotli, through the libbrotlienc tropical ships (co.multiply.tropical.sse.BrotliEncoder).
  • :gzip gzip, from the JDK.

A stream's encoder lives as long as the stream, and keeps what it sent before as its window: a frame repeating what an earlier one sent, as a morph of an island mostly does, is sent as a reference to it. The frame's events are flushed through the encoder at once, so the client decodes the frame as it arrives.

The settings are tropical's, measured on streams of frames (see the logbook), not the app's to tune. Each is a trade of memory for how far back the window reaches, held for every open stream:

  • :zstd level 3, a 256 KiB window: about 0.7 MB a stream.
  • :br quality 4, a 128 KiB window: about 0.9 MB a stream.
  • :gzip the JDK's default level, and gzip's 32 KiB window: about 0.25 MB a stream. A morph of an island larger than the window is compressed on its own, without reference to its last.

The native libraries are tropical's: an encoding is loaded when the app names it, and ring/handler throws for one it can't load.

Compressing a tab's stream, which an app opts into with `:compression`
(`co.multiply.tropical.ring`): the encodings, how a request's is chosen, and
the encoder each opens over the response.

- `:zstd`  Zstandard, through the libzstd tropical ships
           (`co.multiply.tropical.sse.ZstdEncoder`).
- `:br`    Brotli, through the libbrotlienc tropical ships
           (`co.multiply.tropical.sse.BrotliEncoder`).
- `:gzip`  gzip, from the JDK.

A stream's encoder lives as long as the stream, and keeps what it sent
before as its window: a frame repeating what an earlier one sent, as a
morph of an island mostly does, is sent as a reference to it. The frame's
events are flushed through the encoder at once, so the client decodes the
frame as it arrives.

The settings are tropical's, measured on streams of frames (see the
logbook), not the app's to tune. Each is a trade of memory for how far back
the window reaches, held for every open stream:

- `:zstd`  level 3, a 256 KiB window: about 0.7 MB a stream.
- `:br`    quality 4, a 128 KiB window: about 0.9 MB a stream.
- `:gzip`  the JDK's default level, and gzip's 32 KiB window: about 0.25 MB
           a stream. A morph of an island larger than the window is
           compressed on its own, without reference to its last.

The native libraries are tropical's: an encoding is loaded when the app
names it, and `ring/handler` throws for one it can't load.
raw docstring

No vars found in this namespace.

co.multiply.tropical.cookie

State the browser owns and the server renders, kept in a cookie: whether a section is open, a sidebar folded, a pane's width. It changes in the browser without a round trip, survives page loads, is right in the first frame, and neither a later patch nor an island mounting again undoes what the user did.

defcookie declares a state, with its default and what a value must be. The var is its identity, so it lives wherever the code that uses it does: next to one island, or in a namespace several share. Any island anywhere in the page takes it up with use-cookie, which returns a handle for value and for the helpers in co.multiply.tropical.ui:

(defcookie group-open
  "Whether a sidebar group is open, per group."
  {:default true :valid? boolean?})

(defisland sidebar-group
  {:key :id}
  [group]
  (let [open (use-cookie group-open (:id group))]
    [:section
     [:button (ui/aria-expanded {:data-on:click (ui/toggle open)} open) (:name group)]
     [:ul (ui/shown open) ...]]))

How it holds:

  • The browser keeps each state in a Datastar signal under _cookie. An island that uses a state declares its signal on its root, only if missing (island/declare-signal), so the first island in the page to use it declares it, and any later declaration, a remount or a patch carrying the server's stale copy changes nothing. Islands using one state follow one signal.
  • The element the app mounts in carries ui/page-attrs (ring/mount adds it): a handler that writes each change of a _cookie signal into the cookie, merged with what the cookie holds, so tabs don't undo one another's changes. It keeps only values other than their state's default, drops states no longer declared, and drops the oldest values past max-cookie-chars.
  • The server reads the cookie of the request that created the session (co.multiply.tropical.ring binds it), so its value is the state as the session began. The cookie is the client's to write: a value that isn't valid for its state reads as the default.

The server sets a state from an action, as it would any signal: its handler returns patch's signals, which the islands using the state follow and the body's handler keeps. For the page an action opens, such as a new item's with a choice made before the item existed, it passes them to action/navigate's :signals: the cookie has them before the page loads.

A state's values are booleans, numbers or strings, since every request carries the cookie. Text, or a value of any size, that the server needn't render in the first frame is co.multiply.tropical.storage's. A state is used either with an item, as one per item, or without, never both.

State the browser owns and the server renders, kept in a cookie: whether a
section is open, a sidebar folded, a pane's width. It changes in the browser
without a round trip, survives page loads, is right in the first frame, and
neither a later patch nor an island mounting again undoes what the user did.

`defcookie` declares a state, with its default and what a value must be. The
var is its identity, so it lives wherever the code that uses it does: next to
one island, or in a namespace several share. Any island anywhere in the page
takes it up with `use-cookie`, which returns a handle for `value` and for the
helpers in `co.multiply.tropical.ui`:

    (defcookie group-open
      "Whether a sidebar group is open, per group."
      {:default true :valid? boolean?})

    (defisland sidebar-group
      {:key :id}
      [group]
      (let [open (use-cookie group-open (:id group))]
        [:section
         [:button (ui/aria-expanded {:data-on:click (ui/toggle open)} open) (:name group)]
         [:ul (ui/shown open) ...]]))

How it holds:

- The browser keeps each state in a Datastar signal under `_cookie`. An
  island that uses a state declares its signal on its root, only if missing
  (`island/declare-signal`), so the first island in the page to use it
  declares it, and any later declaration, a remount or a patch carrying the
  server's stale copy changes nothing. Islands using one state follow one
  signal.
- The element the app mounts in carries `ui/page-attrs` (`ring/mount` adds
  it): a handler that writes each change of a `_cookie` signal into the
  cookie, merged with what the cookie holds, so tabs don't undo one
  another's changes. It keeps only values other than
  their state's default, drops states no longer declared, and drops the
  oldest values past `max-cookie-chars`.
- The server reads the cookie of the request that created the session
  (`co.multiply.tropical.ring` binds it), so its value is the state as the
  session began. The cookie is the client's to write: a value that isn't
  valid for its state reads as the default.

The server sets a state from an action, as it would any signal: its handler
returns `patch`'s signals, which the islands using the state follow and the
body's handler keeps. For the page an action opens, such as a new item's
with a choice made before the item existed, it passes them to
`action/navigate`'s `:signals`: the cookie has them before the page loads.

A state's values are booleans, numbers or strings, since every request
carries the cookie. Text, or a value of any size, that the server needn't
render in the first frame is `co.multiply.tropical.storage`'s. A state is
used either with an item, as one per item, or without, never both.
raw docstring

co.multiply.tropical.hooks

observe, the hook for reading from outside the page, such as a database subscription, a poll loop or a query.

It returns pending until the first value arrives, and throws in the render when what it reads fails: the island renders the error view unless it catches it. A read is a hold keyed by what it reads, so hooks of an app's own that read the same thing in one render share it: one resource, one value.

`observe`, the hook for reading from outside the page, such as a database
subscription, a poll loop or a query.

It returns `pending` until the first value arrives, and throws in the render
when what it reads fails: the island renders the error view unless it catches
it. A read is a hold keyed by what it reads, so hooks of an app's own that
read the same thing in one render share it: one resource, one value.
raw docstring

co.multiply.tropical.island

Islands: the unit of rendering, caching and patching, written as functions.

defisland defines a function returning hiccup, as a React component does. Calling an island inside another island's hiccup does not render it: it places the island there, with its arguments, for the runtime (co.multiply.tropical.runtime) to reconcile. An island renders again only when its arguments change (=), or when something it read through a hook changed. Otherwise its last output is reused, and so is everything it holds.

Hooks run only during a render. Each takes a key, unique within the island, instead of relying on call order:

  • use-watch read a ref (atom, etc.) or a signal; re-render when the selected value changes.
  • use-state island-local state, kept while the island is mounted.
  • use-hold hold something (a subscription, a token) while renders keep asking for it.
  • use-session the session's {:tab :uid :request}.
  • use-patch-signals a fn patching the page's signals from any thread.

co.multiply.tropical.hooks/observe and co.multiply.tropical.action/use-action build on these.

Ids are scoped by parent, as React keys are. An island's slot is its name, plus .key for a keyed island, and is unique among its siblings. Its id is the path of slots from the root, app/vault-card/vault-panel, so it is unique on the page wherever the island is placed. An island placed by a different parent is a different instance, and so is one under a new key: it starts over, which is how to reset an island, such as a dialog opened again.

The page sees an opaque digest of the id on the island's element (element-id), unless names are readable (co.multiply.tropical.names).

Islands: the unit of rendering, caching and patching, written as functions.

`defisland` defines a function returning hiccup, as a React component does.
Calling an island inside another island's hiccup does not render it: it places
the island there, with its arguments, for the runtime
(`co.multiply.tropical.runtime`) to reconcile. An island renders again only
when its arguments change (`=`), or when something it read through a hook
changed. Otherwise its last output is reused, and so is everything it holds.

Hooks run only during a render. Each takes a key, unique within the island,
instead of relying on call order:

- `use-watch`   read a ref (atom, etc.) or a signal; re-render when the selected value changes.
- `use-state`   island-local state, kept while the island is mounted.
- `use-hold`    hold something (a subscription, a token) while renders keep asking for it.
- `use-session` the session's `{:tab :uid :request}`.
- `use-patch-signals` a fn patching the page's signals from any thread.

`co.multiply.tropical.hooks/observe` and
`co.multiply.tropical.action/use-action` build on these.

Ids are scoped by parent, as React keys are. An island's slot is its name,
plus `.key` for a keyed island, and is unique among its siblings. Its id is
the path of slots from the root, `app/vault-card/vault-panel`, so it is
unique on the page wherever the island is placed. An island placed by a
different parent is a different instance, and so is one under a new key: it
starts over, which is how to reset an island, such as a dialog opened again.

The page sees an opaque digest of the id on the island's element
(`element-id`), unless names are readable (`co.multiply.tropical.names`).
raw docstring

co.multiply.tropical.names

What the browser sees of the app's own names: an island's element id, the signal of a cookie or storage state, a script's URL.

By default each is an opaque digest of the app's name for it, so the page tells nothing of how the app is laid out: its namespaces, its functions, its states. The server keeps the names as written, in its logs, its REPL and the errors it throws. A digest is deterministic, so every server gives a name the same one: a tab reconnecting into another server is patched in place, and what the browser keeps under a state's name, in its cookie or storage, is found again after a deploy.

With the JVM property co.multiply.tropical.readable-names set to true, read once as this namespace loads, the browser sees the names as written, for reading them while debugging. A state's names change with it, so what the browser kept under the other ones reads as the default. Every server of an app runs one way.

A name is the app's own only in its code: what reaches the page goes through co.multiply.tropical.ui (signal, path) and the like. CSS, a script or an expression that names an island's element or a state's signal by hand would hold only while names are readable.

What the browser sees of the app's own names: an island's element id, the
signal of a cookie or storage state, a script's URL.

By default each is an opaque digest of the app's name for it, so the page
tells nothing of how the app is laid out: its namespaces, its functions, its
states. The server keeps the names as written, in its logs, its REPL and the
errors it throws. A digest is deterministic, so every server gives a name
the same one: a tab reconnecting into another server is patched in place,
and what the browser keeps under a state's name, in its cookie or storage,
is found again after a deploy.

With the JVM property `co.multiply.tropical.readable-names` set to `true`,
read once as this namespace loads, the browser sees the names as written,
for reading them while debugging. A state's names change with it, so what
the browser kept under the other ones reads as the default. Every server of
an app runs one way.

A name is the app's own only in its code: what reaches the page goes
through `co.multiply.tropical.ui` (`signal`, `path`) and the like. CSS, a
script or an expression that names an island's element or a state's signal
by hand would hold only while names are readable.
raw docstring

No vars found in this namespace.

co.multiply.tropical.resource

Shared, reference-counted stateful resources, such as subscriptions to a database or poll loops.

There is one physical resource per key, however many sessions watch it. It opens on the first subscriber. After the last subscriber leaves, it lingers for linger-ms and then closes, unless someone subscribes again first. The linger absorbs remount churn: switching back and forth, navigation, and reconnects.

A resource is opened by a subject, (fn [emit!] undo), as in Missionary's observe. Keys are a call site and its args. Islands subscribe with co.multiply.tropical.hooks/observe, which holds a subscription for as long as the island's renders keep asking for it: a render that stops, or the island unmounting, releases it. Nobody calls release! by hand.

The instances of shared states (co.multiply.tropical.shared) are resources too, under keys of their own: they open holding a value, with a linger of their state's, and change by its swap!, not by a subject.

A resource whose task fails is closed: its value becomes Failed, so its readers throw, and the next subscriber to its key opens a new one.

Shared, reference-counted stateful resources, such as subscriptions to a
database or poll loops.

There is one physical resource per key, however many sessions watch it. It
opens on the first subscriber. After the last subscriber leaves, it lingers for
`linger-ms` and then closes, unless someone subscribes again first. The linger
absorbs remount churn: switching back and forth, navigation, and reconnects.

A resource is opened by a subject, `(fn [emit!] undo)`, as in Missionary's
`observe`. Keys are a call site and its args. Islands subscribe with
`co.multiply.tropical.hooks/observe`, which
holds a subscription for as long as the island's renders keep asking for it: a
render that stops, or the island unmounting, releases it. Nobody calls
`release!` by hand.

The instances of shared states (`co.multiply.tropical.shared`) are resources
too, under keys of their own: they open holding a value, with a linger of
their state's, and change by its `swap!`, not by a subject.

A resource whose task fails is closed: its value becomes `Failed`, so its
readers throw, and the next subscriber to its key opens a new one.
raw docstring

co.multiply.tropical.ring

A Ring handler for a page: the page itself, the stream it opens and the actions its islands render, all at the page's URL.

All three are requests to the page's route, so they pass through the same middleware: what it binds from the request, such as the account from the login or a workspace from the path, renders and actions see alike, and a tab the server no longer knows is rebuilt from the route it was on. The handler tells them apart by method, and by the header the page's expressions send:

  • GET the page: a new tab's session, server-side rendered from its first frame.
  • GET with Tropical-Tab the tab's stream, which the page opens with mount, or stream-init.
  • POST with Tropical-Action an action use-action rendered, its signals, and a value if it takes one, a JSON body; or a script's request to a token of its island (action/use-token), as an upload's.

request-kind tells them apart, for middleware that runs before routing and must let them through, such as one that refuses or parses request bodies.

A stream passes the route's middleware each time it connects, and a refusal, because the login expired or what the page shows is gone, would leave the tab stale: Datastar retries it, then gives up. wrap-refused-streams answers such a refusal with a reload of the page, so the page request gets the app's own answer, and the handler answers its own refusals of a stream that way. An open stream isn't asked again: what a page shows going away while it is open is for its islands to render.

The app the sessions run (co.multiply.tropical.session) takes more keys here, and its :root also gets the :request that created the session:

  • :uid (fn [request] uid): the user the request is made on behalf of, or nil. Authentication is the app's; the session and its actions belong to that user. A page or action without one gets a 403, a stream a reload.
  • :page (fn [{:keys [tab frame scripts request]}] html): the page, server-side rendered from the session's first frame. It mounts the app with mount, which holds the frame, opens the stream and keeps the state the browser owns, and puts the scripts the frame's islands declared in its head with scripts. A page that opens no stream for its tab, which could never update, throws; one without ui/page-attrs is logged, once.
  • :open-when-hidden optional: true keeps each tab's stream open while its page is hidden, for browser automation in development (see stream-init).
  • :max-signals-bytes optional: the largest action body read, in bytes; larger gets a 413. Defaults to max-signals-bytes.
  • :compression optional: the encodings to compress the stream with, in order of preference, such as [:zstd :br :gzip] (co.multiply.tropical.compression). A stream is compressed with the one its request accepts, or not at all. The page and the actions are left to the server's own compression.

The scripts islands declare (co.multiply.tropical.script) are served by wrap-scripts, outside the routes.

A session is created in the scope of the browser's state its request carries (co.multiply.tropical.cookie), so its islands render that state in the first frame. The page is rendered for its user, whose entries in the browser's storage ui/page-attrs keeps (co.multiply.tropical.storage).

A session renders in the scope of the request that created it, fixed for its life. An action's handler runs in the scope of its own request, and action/request returns it: what may differ from the session's, such as a cookie the page's own response set.

The handler is sync or async. Called sync, it holds the stream request's thread for as long as the stream is open.

What goes over the stream, and how the signals are read, follow Datastar's SDK specification, as co.multiply.tropical.sse describes.

A Ring handler for a page: the page itself, the stream it opens and the
actions its islands render, all at the page's URL.

All three are requests to the page's route, so they pass through the same
middleware: what it binds from the request, such as the account from the
login or a workspace from the path, renders and actions see alike, and a tab
the server no longer knows is rebuilt from the route it was on. The handler
tells them apart by method, and by the header the page's expressions send:

- `GET`                          the page: a new tab's session, server-side
                                 rendered from its first frame.
- `GET`  with `Tropical-Tab`     the tab's stream, which the page opens with
                                 `mount`, or `stream-init`.
- `POST` with `Tropical-Action`  an action `use-action` rendered, its signals,
                                 and a value if it takes one, a JSON body; or
                                 a script's request to a token of its island
                                 (`action/use-token`), as an upload's.

`request-kind` tells them apart, for middleware that runs before routing and
must let them through, such as one that refuses or parses request bodies.

A stream passes the route's middleware each time it connects, and a refusal,
because the login expired or what the page shows is gone, would leave the
tab stale: Datastar retries it, then gives up. `wrap-refused-streams` answers
such a refusal with a reload of the page, so the page request gets the app's
own answer, and the handler answers its own refusals of a stream that way.
An open stream isn't asked again: what a page shows going away while it is
open is for its islands to render.

The app the sessions run (`co.multiply.tropical.session`) takes more keys
here, and its `:root` also gets the `:request` that created the session:

- `:uid`               `(fn [request] uid)`: the user the request is made on
                       behalf of, or nil. Authentication is the app's; the
                       session and its actions belong to that user. A page or
                       action without one gets a 403, a stream a reload.
- `:page`              `(fn [{:keys [tab frame scripts request]}] html)`: the
                       page, server-side rendered from the session's first
                       `frame`. It mounts the app with `mount`, which holds
                       the frame, opens the stream and keeps the state the
                       browser owns, and puts the scripts the frame's islands
                       declared in its head with `scripts`. A page that opens
                       no stream for its tab, which could never update,
                       throws; one without `ui/page-attrs` is logged, once.
- `:open-when-hidden`  optional: true keeps each tab's stream open while its
                       page is hidden, for browser automation in
                       development (see `stream-init`).
- `:max-signals-bytes` optional: the largest action body read, in bytes;
                       larger gets a 413. Defaults to `max-signals-bytes`.
- `:compression`       optional: the encodings to compress the stream with,
                       in order of preference, such as `[:zstd :br :gzip]`
                       (`co.multiply.tropical.compression`). A stream is
                       compressed with the one its request accepts, or not
                       at all. The page and the actions are left to the
                       server's own compression.

The scripts islands declare (`co.multiply.tropical.script`) are served by
`wrap-scripts`, outside the routes.

A session is created in the scope of the browser's state its request carries
(`co.multiply.tropical.cookie`), so its islands render that state in the
first frame. The page is rendered for its user, whose entries in the
browser's storage `ui/page-attrs` keeps (`co.multiply.tropical.storage`).

A session renders in the scope of the request that created it, fixed for its
life. An action's handler runs in the scope of its own request, and
`action/request` returns it: what may differ from the session's, such as a
cookie the page's own response set.

The handler is sync or async. Called sync, it holds the stream request's
thread for as long as the stream is open.

What goes over the stream, and how the signals are read, follow Datastar's
SDK specification, as `co.multiply.tropical.sse` describes.
raw docstring

co.multiply.tropical.runtime

The island runtime of one session: which islands are mounted, what each one holds and reads, and how a frame is produced.

An island renders when it is new, when its arguments changed, or when a value it read changed. Its children are then reconciled against what it placed: children with unchanged arguments keep their last output, and children it no longer places are unmounted, releasing what they held and unsubscribing from what they read. A frame visits only the islands that need rendering and their ancestors; every other subtree is reused as it is.

Single-threaded: only the session's pump calls in. The session subscribes once to each source its islands read (co.multiply.tropical.signal). A change marks the subscription in the session's inbox, from whatever thread made it, and the pump passes the marked subscriptions to frame!.

The island runtime of one session: which islands are mounted, what each one
holds and reads, and how a frame is produced.

An island renders when it is new, when its arguments changed, or when a value
it read changed. Its children are then reconciled against what it placed:
children with unchanged arguments keep their last output, and children it no
longer places are unmounted, releasing what they held and unsubscribing from
what they read. A frame visits only the islands that need rendering and their
ancestors; every other subtree is reused as it is.

Single-threaded: only the session's pump calls in. The session subscribes
once to each source its islands read (`co.multiply.tropical.signal`). A change marks the
subscription in the session's inbox, from whatever thread made it, and the
pump passes the marked subscriptions to `frame!`.
raw docstring

co.multiply.tropical.script

Scripts the page loads for its islands: ES modules, such as the web component an island's elements are, or functions its expressions call.

defscript declares one, from a resource on the classpath. An island whose elements need it takes it up with use, during its render:

(defscript outline
  "The page's outline, which follows the reader as they scroll."
  {:resource "app/outline.js"})

(defisland article-outline
  []
  (script/use outline)
  [:nav [:page-outline]])

How it loads:

  • A script is served at a URL of its own, under path, named by its var, as co.multiply.tropical.names shows it, and the hash of its content, by ring/wrap-scripts. The URL changes with the content, so the browser keeps a script as long as it likes, and loads a changed one anew.
  • The page's own scripts are those the islands of its first frame declared: the page puts them in its head, with the CSP nonce, through tags, so they load with the page (ring/handler's :scripts).
  • Over the stream, the session sends each script once, appended to the page's head, from the first frame in which an island declares it. Datastar gives the tag the page's nonce. So a script loads whether its island is in the first frame or patched in later, and in the first frame of a page that left it out of its head, a little later.
  • A module runs once per page, however many tags load it: the browser keeps a module per URL for the life of the page.

What a script must know:

  • It is an ES module: loaded as <script type="module">, deferred, and in strict mode. It may import other modules by absolute URL.
  • The island's morph matches an element's children to the server's: what a script puts into an island's elements is undone by the next render. A web component draws into a shadow root, which the morph doesn't reach.
  • A module that fails to load, such as one blocked for want of the nonce, stays failed for the page, under any later tag.
  • A page keeps the version of a script it loaded first. A changed script loads under its new URL, but customElements.define won't redefine a name, so a component changes on the next page load.

In development, a script whose resource is a file is read again when the file changes, at most once a second, so a reload of the page picks up an edit.

Scripts the page loads for its islands: ES modules, such as the web component
an island's elements are, or functions its expressions call.

`defscript` declares one, from a resource on the classpath. An island whose
elements need it takes it up with `use`, during its render:

    (defscript outline
      "The page's outline, which follows the reader as they scroll."
      {:resource "app/outline.js"})

    (defisland article-outline
      []
      (script/use outline)
      [:nav [:page-outline]])

How it loads:

- A script is served at a URL of its own, under `path`, named by its var, as
  `co.multiply.tropical.names` shows it, and the hash of its content, by
  `ring/wrap-scripts`. The URL changes with the
  content, so the browser keeps a script as long as it likes, and loads a
  changed one anew.
- The page's own scripts are those the islands of its first frame declared:
  the page puts them in its head, with the CSP nonce, through `tags`, so they
  load with the page (`ring/handler`'s `:scripts`).
- Over the stream, the session sends each script once, appended to the
  page's head, from the first frame in which an island declares it. Datastar
  gives the tag the page's nonce. So a script loads whether its island is in
  the first frame or patched in later, and in the first frame of a page that
  left it out of its head, a little later.
- A module runs once per page, however many tags load it: the browser keeps a
  module per URL for the life of the page.

What a script must know:

- It is an ES module: loaded as `<script type="module">`, deferred, and in
  strict mode. It may import other modules by absolute URL.
- The island's morph matches an element's children to the server's: what a
  script puts into an island's elements is undone by the next render. A web
  component draws into a shadow root, which the morph doesn't reach.
- A module that fails to load, such as one blocked for want of the nonce,
  stays failed for the page, under any later tag.
- A page keeps the version of a script it loaded first. A changed script
  loads under its new URL, but `customElements.define` won't redefine a
  name, so a component changes on the next page load.

In development, a script whose resource is a file is read again when the file
changes, at most once a second, so a reload of the page picks up an edit.
raw docstring

co.multiply.tropical.session

Tab sessions: one island tree per browser tab, outliving any single SSE connection.

A session is created when the page is served, and the page is rendered from its first frame. The tab's stream (ring/stream-init) then attaches to it, and the session belongs to that page load from then on. Datastar drops that stream routinely: a hidden tab closes it, network blips retry it. A detached session keeps its islands, and so their resources, for grace-ms. A reconnect within that window resyncs from the cached frame without reopening anything. After the window, every island is unmounted and everything they held is released.

Each session is run by one pump: a virtual thread that owns all of the session's mutable state, its island runtime included, and consumes the session's inbox (co.multiply.tropical.signal.Inbox). The inbox holds lifecycle events (attach, detach, close) and the subscriptions marked since the pump last looked. A change to something the islands read only marks its subscription, once until the pump takes it. Rendering happens on the pump, never on the thread that made the change, so a shared resource's loop never renders on anyone's behalf. Changes that arrive while a frame is being sent coalesce into the next one, so a slow client gets fewer, later frames, never a backlog, and the inbox never holds more than one mark per subscription.

A session runs an app, a map:

  • :root (fn [{:keys [tab uid request]}] call): the session's root island, given the session's context, which every island reads with use-session. :request is the request that created the session, where co.multiply.tropical.ring created it.
  • :patch-signals optional, (fn [info] signals): a map of signals to send with each patch, or nil. See patch-signals.
  • :error-view optional, (fn [id e] hiccup): what renders in place of an island whose render throws (see runtime/runtime).
  • :first-frame-ms optional: how long, in ms, the session's first frame may wait for reads still pending (below). Defaults to 0.

The first frame a session delivers, to the page or over the stream that rebuilt the session, is the first a client sees from it. Its reads are often cold: a new tab, a first visit, every open tab after a restart. Sent at once, it shows their pending states, which the data replaces a moment later. So it waits until no read of its islands is pending, or :first-frame-ms passes, and goes out as it is then. It renders as reads land meanwhile, so islands that appear only once an earlier read lands get to read too. Later frames don't wait. A page waits at most 5 s for its frame (render-page!), so keep the wait well under that: 100 to 200 ms covers the reads worth waiting for.

Scripts the islands declare (co.multiply.tropical.script) go over the stream once each, appended to the page's head, ahead of the first frame that needs them: those of the first frame too, unless the page put them in its head (scripts-in-page!), in case it left them out. A session belongs to one page load, which keeps the modules it loaded, so a reconnect doesn't send them again.

A session whose pump fails, because a step outside the islands' renders threw, is closed, and so is its stream: the client reconnects into a new session instead of waiting on one that sends nothing.

Tab sessions: one island tree per browser tab, outliving any single SSE
connection.

A session is created when the page is served, and the page is rendered from
its first frame. The tab's stream (`ring/stream-init`) then attaches to it,
and the session belongs to that page load from then on. Datastar drops that
stream routinely: a hidden tab closes it, network blips retry it.
A detached session keeps its islands, and so their resources, for `grace-ms`.
A reconnect within that window resyncs from the cached frame without
reopening anything. After the window, every island is unmounted and
everything they held is released.

Each session is run by one pump: a virtual thread that owns all of the
session's mutable state, its island runtime included, and consumes the
session's inbox (`co.multiply.tropical.signal.Inbox`). The inbox holds
lifecycle events (attach, detach, close) and the subscriptions marked since
the pump last looked. A change to something the islands read only marks its subscription,
once until the pump takes it. Rendering happens on the pump, never on the
thread that made the change, so a shared resource's loop never renders on
anyone's behalf. Changes that arrive while a frame is being sent coalesce into
the next one, so a slow client gets fewer, later frames, never a backlog, and
the inbox never holds more than one mark per subscription.

A session runs an app, a map:

- `:root`           `(fn [{:keys [tab uid request]}] call)`: the session's root
                    island, given the session's context, which every island
                    reads with `use-session`. `:request` is the request that
                    created the session, where `co.multiply.tropical.ring`
                    created it.
- `:patch-signals`  optional, `(fn [info] signals)`: a map of signals to send
                    with each patch, or nil. See `patch-signals`.
- `:error-view`     optional, `(fn [id e] hiccup)`: what renders in place of
                    an island whose render throws (see `runtime/runtime`).
- `:first-frame-ms` optional: how long, in ms, the session's first frame may
                    wait for reads still pending (below). Defaults to 0.

The first frame a session delivers, to the page or over the stream that
rebuilt the session, is the first a client sees from it. Its reads are often
cold: a new tab, a first visit, every open tab after a restart. Sent at once,
it shows their pending states, which the data replaces a moment later. So it
waits until no read of its islands is pending, or `:first-frame-ms` passes,
and goes out as it is then. It renders as reads land meanwhile, so islands
that appear only once an earlier read lands get to read too. Later frames
don't wait. A page waits at most 5 s for its frame (`render-page!`), so keep
the wait well under that: 100 to 200 ms covers the reads worth waiting for.

Scripts the islands declare (`co.multiply.tropical.script`) go over the
stream once each, appended to the page's head, ahead of the first frame that
needs them: those of the first frame too, unless the page put them in its
head (`scripts-in-page!`), in case it left them out.
A session belongs to one page load, which keeps the modules it loaded, so a
reconnect doesn't send them again.

A session whose pump fails, because a step outside the islands' renders
threw, is closed, and so is its stream: the client reconnects into a new
session instead of waiting on one that sends nothing.
raw docstring

co.multiply.tropical.shared

State on the server that islands share across sessions: every island that reads a state with the same key reads one value, and renders again when it changes. It sits between what an island keeps, which a page load ends, and a def, which nothing ends.

(defshared toasts
  "The toasts a user's tabs show."
  {:init []})

(defisland toast-list
  []
  (let [{:keys [uid]} (island/use-session)
        [messages swap-messages!] (use-shared toasts uid)]
    [:ul (for [m messages] [:li (:text m)])]))

;; Anywhere: an action, a resource, a task of the app's own.
(shared/swap! toasts uid conj {:text "Saved."})

The key decides how shared a state is: a user's id makes it the user's, in every tab and across page loads; a document's id makes it everyone's who has the document open; nil, everyone's.

An instance, a state with a key, is created with the state's :init by the first island to read it, ready in that render. It is held while any island reads it, in any session, and lingers after the last stops, as a resource does (co.multiply.tropical.resource, whose registry holds it), so a page load finds it as the last page left it. Then it is gone, and a swap! finds nothing to change.

It lives in this JVM's memory: a restart loses it, and each server behind a balancer holds its own. What must last, or reach a user wherever they are connected, belongs in a database or a stream the app reads with observe.

The key is the access: whoever reads a state with a key reads its value. Build it from what the session vouches for, such as use-session's :uid, never from signals or an action's value.

State on the server that islands share across sessions: every island that
reads a state with the same key reads one value, and renders again when it
changes. It sits between what an island keeps, which a page load ends, and a
`def`, which nothing ends.

    (defshared toasts
      "The toasts a user's tabs show."
      {:init []})

    (defisland toast-list
      []
      (let [{:keys [uid]} (island/use-session)
            [messages swap-messages!] (use-shared toasts uid)]
        [:ul (for [m messages] [:li (:text m)])]))

    ;; Anywhere: an action, a resource, a task of the app's own.
    (shared/swap! toasts uid conj {:text "Saved."})

The key decides how shared a state is: a user's id makes it the user's, in
every tab and across page loads; a document's id makes it everyone's who has
the document open; nil, everyone's.

An instance, a state with a key, is created with the state's `:init` by the
first island to read it, ready in that render. It is held while any island
reads it, in any session, and lingers after the last stops, as a resource
does (`co.multiply.tropical.resource`, whose registry holds it), so a page
load finds it as the last page left it. Then it is gone, and a `swap!` finds
nothing to change.

It lives in this JVM's memory: a restart loses it, and each server behind a
balancer holds its own. What must last, or reach a user wherever they are
connected, belongs in a database or a stream the app reads with `observe`.

The key is the access: whoever reads a state with a key reads its value. Build
it from what the session vouches for, such as `use-session`'s `:uid`, never
from signals or an action's value.
raw docstring

co.multiply.tropical.storage

State the browser owns and keeps in localStorage, such as a draft the user is writing: a value of any size, which outlasts closing what shows it, a page load, and the server restarting or being deployed again, in the one browser, while the server stores none of it.

defstorage declares a state, with its default and how its values become JSON and come back. Any island takes it up with use-storage, with an item for one per item, such as a suggestion's id, and a default of its own if the item has one:

(defstorage edits
  "Edits to a suggestion, before the user acts on it."
  {:default {} :valid? map?})

(defisland rework
  [{:keys [id text]}]
  (let [draft (use-storage edits id {:default {"text" text}})
        start (use-action :start
                (fn [_ value]
                  (when-some [{t "text"} (decode edits value)]
                    (start! id t))
                  (forget [[edits id]]))
                {:value (ui/signal draft)})]
    [:form
     [:textarea {:data-bind (ui/path draft "text")}]
     [:button {:type "button" :data-on:click start} "Start"]]))

How it holds:

  • The browser keeps each value in a Datastar signal under _storage, local to the page, so no action posts it unless one passes it as its value. An island that uses a state declares the signal on its root, only if missing: as the browser's entry has it, or as the default. A later render, or the island mounting again, leaves what the user did.
  • The element the app mounts in carries ui/page-attrs (ring/mount adds it), whose handler writes each change into the browser's entry, while the value differs from the default the island declared it with: only an edit is kept, and a default the server revised shows through where there is none.
  • The server sees a value only when an action passes it, or through observe. The first frame renders the default, and the browser fills in its own as its script starts, through the elements' bindings (data-bind, data-text, ui/attr).
  • An entry is the user's: its key holds a digest of the session's user, and a page loaded for another drops it. A page also drops entries older than their state's :max-age-ms, stored under another :version, or of a state no longer declared. When the browser's storage is full, the oldest entries go first.

Values are JSON, since the browser changes them itself, in expressions and bindings. :encode turns a Clojure value into one, and :decode turns one back; a state checks, as it is declared, that its default comes back as it went. A value only ever round-tripped, which the browser never reads, can be a string, such as Transit, at the cost of binding to it.

An action sets entries with patch, and drops them with forget, in the browser that made it, whether or not anything on the page uses them.

State the browser owns and keeps in `localStorage`, such as a draft the user
is writing: a value of any size, which outlasts closing what shows it, a page
load, and the server restarting or being deployed again, in the one browser,
while the server stores none of it.

`defstorage` declares a state, with its default and how its values become
JSON and come back. Any island takes it up with `use-storage`, with an item
for one per item, such as a suggestion's id, and a default of its own if the
item has one:

    (defstorage edits
      "Edits to a suggestion, before the user acts on it."
      {:default {} :valid? map?})

    (defisland rework
      [{:keys [id text]}]
      (let [draft (use-storage edits id {:default {"text" text}})
            start (use-action :start
                    (fn [_ value]
                      (when-some [{t "text"} (decode edits value)]
                        (start! id t))
                      (forget [[edits id]]))
                    {:value (ui/signal draft)})]
        [:form
         [:textarea {:data-bind (ui/path draft "text")}]
         [:button {:type "button" :data-on:click start} "Start"]]))

How it holds:

- The browser keeps each value in a Datastar signal under `_storage`, local
  to the page, so no action posts it unless one passes it as its value. An
  island that uses a state declares the signal on its root, only if missing:
  as the browser's entry has it, or as the default. A later render, or the
  island mounting again, leaves what the user did.
- The element the app mounts in carries `ui/page-attrs` (`ring/mount` adds
  it), whose handler writes each change into the browser's entry, while the
  value differs from the default the island declared it with: only an edit
  is kept, and a default the server revised shows through where there is
  none.
- The server sees a value only when an action passes it, or through
  `observe`. The first frame renders the default, and the browser fills
  in its own as its script starts, through the elements' bindings
  (`data-bind`, `data-text`, `ui/attr`).
- An entry is the user's: its key holds a digest of the session's user, and
  a page loaded for another drops it. A page also drops entries older than
  their state's `:max-age-ms`, stored under another `:version`, or of a
  state no longer declared. When the browser's storage is full, the oldest
  entries go first.

Values are JSON, since the browser changes them itself, in expressions and
bindings. `:encode` turns a Clojure value into one, and `:decode` turns one
back; a state checks, as it is declared, that its default comes back as it
went. A value only ever round-tripped, which the browser never reads, can
be a string, such as Transit, at the cost of binding to it.

An action sets entries with `patch`, and drops them with `forget`, in the
browser that made it, whether or not anything on the page uses them.
raw docstring

co.multiply.tropical.test

Islands in a test: rendered as a session renders them, on the test's own thread, without a server, a stream or a browser.

(with-open [h (test/harness #(app uid) {:uid uid})]
  (is (str/includes? (test/render! h) "Clicked 0 times"))
  ((test/action h "app/counter" :click) {})
  (is (str/includes? (test/render! h) "Clicked 1 times")))

A harness holds what its islands hold, as a session does: the resources they read, their state, their action tokens. Closing it unmounts them, so their resources start to linger, and their tokens are revoked.

Renders run in the scope of the thread calling render!, as a session's run in the scope of the request that created it: bind what the route's middleware would with scoping around them. An island using a cookie state (co.multiply.tropical.cookie) renders its default.

Islands in a test: rendered as a session renders them, on the test's own
thread, without a server, a stream or a browser.

```clojure
(with-open [h (test/harness #(app uid) {:uid uid})]
  (is (str/includes? (test/render! h) "Clicked 0 times"))
  ((test/action h "app/counter" :click) {})
  (is (str/includes? (test/render! h) "Clicked 1 times")))
```

A harness holds what its islands hold, as a session does: the resources they
read, their state, their action tokens. Closing it unmounts them, so their
resources start to linger, and their tokens are revoked.

Renders run in the scope of the thread calling `render!`, as a session's run
in the scope of the request that created it: bind what the route's
middleware would with `scoping` around them. An island using a cookie state
(`co.multiply.tropical.cookie`) renders its default.
raw docstring

co.multiply.tropical.ui

Attributes that follow the browser, and the page's part in keeping the state it owns.

The browser owns some state, and changes it without a round trip. Where it keeps it decides what the server knows of it:

  • co.multiply.tropical.cookie: small values, which every request carries, so the server renders them in the first frame. Whether a section is open, a sidebar folded, a pane's width.
  • co.multiply.tropical.storage: values of any size, in localStorage, which the server sees only when an action passes them, or it reads them (storage/observe). A draft the user is writing.

Either gives a handle, for the helpers here: signal and path name its signal, for expressions and bindings of the app's own, toggle flips a boolean, and shown and aria-expanded render an element and its control from it. They are built on attr, which has any attribute follow any expression, from the first paint. json writes a value of the server's into an expression.

The element the app mounts in (co.multiply.tropical.ring/mount) carries page-attrs, which keep both stores, and refuse files dropped outside a drop area (co.multiply.tropical.upload).

Attributes that follow the browser, and the page's part in keeping the
state it owns.

The browser owns some state, and changes it without a round trip. Where it
keeps it decides what the server knows of it:

- `co.multiply.tropical.cookie`: small values, which every request carries,
  so the server renders them in the first frame. Whether a section is open,
  a sidebar folded, a pane's width.
- `co.multiply.tropical.storage`: values of any size, in `localStorage`,
  which the server sees only when an action passes them, or it reads them
  (`storage/observe`). A draft the user is writing.

Either gives a handle, for the helpers here: `signal` and `path` name its
signal, for expressions and bindings of the app's own, `toggle` flips a
boolean, and `shown` and `aria-expanded` render an element and its control
from it. They are built on `attr`, which has any attribute follow any
expression, from the first paint. `json` writes a value of the server's
into an expression.

The element the app mounts in (`co.multiply.tropical.ring/mount`) carries
`page-attrs`, which keep both stores, and refuse files dropped outside a
drop area (`co.multiply.tropical.upload`).
raw docstring

co.multiply.tropical.upload

Files the browser sends straight to where the app keeps them, such as a bucket taking presigned PUTs: their bytes never pass through the app.

An island takes files with use-uploads, which keeps their list and renders the island again as it changes:

(let [{:keys [uploads start]}
      (use-uploads :attachments
        {:target (fn [{:keys [id name type size]} uploads]
                   (if (< (count (remove (comp #{:refused :failed} :state) uploads)) 10)
                     (let [key (str "uploads/" id)]
                       {:ref key :url (presign-put key type) :headers {"Content-Type" type}})
                     {:refused "At most ten files."}))})]
  [:div
   [:input {:type "file" :multiple true :data-on:change (start "evt.target.files")}]
   (for [{:keys [id name state reason]} uploads]
     ...)])

How a file goes:

  1. start's expression is given files, from a file input or a drop. The browser posts their names, types and sizes, not their bytes.
  2. :target is called for each file, in order, with the list as it stands, the pick's earlier files included: one pick of the island at a time, so a limit holds. It returns where the bytes go, {:ref :url :method :headers} (:method PUT unless given), or {:refused reason}. The file joins the list either way, so the island shows it at once. :ref is the app's own reference, such as the storage key, and stays on the server: the browser knows the upload by its id.
  3. The browser sends the bytes to :url, at most three files at a time, its progress in a signal (progress).
  4. It reports how it went. An upload that arrived is passed to :arrived, if given, which can check it, as with a HEAD, and refuse it with {:refused reason}. One that failed gets the browser's reason.
  5. Those that arrived leave the list when the island takes them with take!, as a message being sent takes its files, or, once none is on its way, together to :settled, as a project takes in a pick as one batch.

An upload is a map: :id, what the browser declared (:name, :type, :size), :ref, and its :state:

  • :uploading its bytes are on their way.
  • :arrived the browser reported them sent, and :arrived took them.
  • :refused :target or :arrived refused it, with its :reason.
  • :failed sending failed, or :arrived or :settled threw, with a :reason in English, for the log or the island.

The list lives as long as the island. An island that unmounts, or a session that closes, forgets it, and reports for it are refused, as is a report naming an id the island didn't give, such as one of another session: its token reaches its own list only.

What is left to the app:

  • What the browser declares, and its report that a file arrived, are untrusted. Sign the size and type into the target, or check the object in :arrived.
  • An object whose arrival is never reported, because the tab closed or the island unmounted, or that is removed or refused once it arrived, stays where it went. Upload under a prefix the store expires, and keep what the app takes.
  • The target must accept the browser's request: a store on another origin with CORS allowing the page's origin, the method and the headers, and a page whose CSP restricts connect-src allowing the store's origin.
Files the browser sends straight to where the app keeps them, such as a
bucket taking presigned PUTs: their bytes never pass through the app.

An island takes files with `use-uploads`, which keeps their list and renders
the island again as it changes:

    (let [{:keys [uploads start]}
          (use-uploads :attachments
            {:target (fn [{:keys [id name type size]} uploads]
                       (if (< (count (remove (comp #{:refused :failed} :state) uploads)) 10)
                         (let [key (str "uploads/" id)]
                           {:ref key :url (presign-put key type) :headers {"Content-Type" type}})
                         {:refused "At most ten files."}))})]
      [:div
       [:input {:type "file" :multiple true :data-on:change (start "evt.target.files")}]
       (for [{:keys [id name state reason]} uploads]
         ...)])

How a file goes:

1. `start`'s expression is given files, from a file input or a drop. The
   browser posts their names, types and sizes, not their bytes.
2. `:target` is called for each file, in order, with the list as it stands,
   the pick's earlier files included: one pick of the island at a time, so a
   limit holds. It returns where the bytes go, `{:ref :url :method :headers}`
   (`:method` PUT unless given), or `{:refused reason}`. The file joins the
   list either way, so the island shows it at once. `:ref` is the app's own
   reference, such as the storage key, and stays on the server: the browser
   knows the upload by its id.
3. The browser sends the bytes to `:url`, at most three files at a time, its
   progress in a signal (`progress`).
4. It reports how it went. An upload that arrived is passed to `:arrived`, if
   given, which can check it, as with a HEAD, and refuse it with
   `{:refused reason}`. One that failed gets the browser's reason.
5. Those that arrived leave the list when the island takes them with
   `take!`, as a message being sent takes its files, or, once none is on
   its way, together to `:settled`, as a project takes in a pick as one
   batch.

An upload is a map: `:id`, what the browser declared (`:name`, `:type`,
`:size`), `:ref`, and its `:state`:

- `:uploading` its bytes are on their way.
- `:arrived`   the browser reported them sent, and `:arrived` took them.
- `:refused`   `:target` or `:arrived` refused it, with its `:reason`.
- `:failed`    sending failed, or `:arrived` or `:settled` threw, with a
               `:reason` in English, for the log or the island.

The list lives as long as the island. An island that unmounts, or a session
that closes, forgets it, and reports for it are refused, as is a report
naming an id the island didn't give, such as one of another session: its
token reaches its own list only.

What is left to the app:

- What the browser declares, and its report that a file arrived, are
  untrusted. Sign the size and type into the target, or check the object in
  `:arrived`.
- An object whose arrival is never reported, because the tab closed or the
  island unmounted, or that is removed or refused once it arrived, stays
  where it went. Upload under a prefix the store expires, and keep what the
  app takes.
- The target must accept the browser's request: a store on another origin
  with CORS allowing the page's origin, the method and the headers, and a
  page whose CSP restricts `connect-src` allowing the store's origin.
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