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.
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.No vars found in this namespace.
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:
_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.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.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.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.
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`).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.
No vars found in this namespace.
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.
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.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!`.
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:
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.tags, so they
load with the page (ring/handler's :scripts).What a script must know:
<script type="module">, deferred, and in
strict mode. It may import other modules by absolute URL.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.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.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.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:
_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.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.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).: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.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.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`).
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:
start's expression is given files, from a file input or a drop. The
browser posts their names, types and sizes, not their bytes.: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.:url, at most three files at a time, its
progress in a signal (progress).: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.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:
:arrived.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.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |