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.(handler app)A Ring handler, sync or async, for a page's route, serving the page, its
stream and its actions for app (see the namespace docstring). Mount it for
GET and POST. It reads an action's body, its signals and any value, itself, and
refuses a body read before it, with a 500: middleware that parses or refuses
bodies, on the route or before routing, lets actions past
((= :action (request-kind req))).
Called sync, it holds the stream request's thread for as long as the stream is open: run sync handlers on virtual threads.
Throws for an encoding in :compression it can't load.
A Ring handler, sync or async, for a page's route, serving the page, its stream and its actions for `app` (see the namespace docstring). Mount it for GET and POST. It reads an action's body, its signals and any value, itself, and refuses a body read before it, with a 500: middleware that parses or refuses bodies, on the route or before routing, lets actions past (`(= :action (request-kind req))`). Called sync, it holds the stream request's thread for as long as the stream is open: run sync handlers on virtual threads. Throws for an encoding in `:compression` it can't load.
The default cap on an action's body, its signals and any value: 1 MiB.
The default cap on an action's body, its signals and any value: 1 MiB.
(mount tag)(mount tag attrs & children)The element the app mounts in, such as the page's body: tag with attrs,
holding the session's first frame, which the islands are patched into from
then on. Its attributes open the tab's stream (stream-init, kept open
while hidden if the app says :open-when-hidden) and keep the state the
browser owns (ui/page-attrs), so the page needs nothing else of tropical's
but its scripts (scripts):
[:html
[:head ... (ring/scripts nonce)]
(ring/mount :body {:class [:min-h-screen]})]
children go after the first frame, outside the islands, such as a
container for dialogs. Without a first frame, because the session closed or
took too long, it holds a placeholder with the root island's id, which the
stream fills in.
The stream needs the element's data-init, so attrs can't have one, nor
the attributes page-attrs adds, and a page mounts once: a second element
would open a second stream for the tab. Call it in the app's :page, which
handler renders; anywhere else it throws.
The element the app mounts in, such as the page's body: `tag` with `attrs`,
holding the session's first frame, which the islands are patched into from
then on. Its attributes open the tab's stream (`stream-init`, kept open
while hidden if the app says `:open-when-hidden`) and keep the state the
browser owns (`ui/page-attrs`), so the page needs nothing else of tropical's
but its scripts (`scripts`):
[:html
[:head ... (ring/scripts nonce)]
(ring/mount :body {:class [:min-h-screen]})]
`children` go after the first frame, outside the islands, such as a
container for dialogs. Without a first frame, because the session closed or
took too long, it holds a placeholder with the root island's id, which the
stream fills in.
The stream needs the element's `data-init`, so `attrs` can't have one, nor
the attributes `page-attrs` adds, and a page mounts once: a second element
would open a second stream for the tab. Call it in the app's `:page`, which
`handler` renders; anywhere else it throws.(request-kind req)What req is to tropical's handler: :page, :stream, :action, or nil
for a request it refuses. The handler dispatches on it, so middleware that
must tell tropical's requests apart before routing agrees with the handler.
An action is a POST with a Tropical-Action header and a JSON body, which
only the handler reads: a body parser that runs first leaves it nothing to
read (see handler). Ring's form and multipart parsers ignore JSON.
What `req` is to tropical's handler: `:page`, `:stream`, `:action`, or nil for a request it refuses. The handler dispatches on it, so middleware that must tell tropical's requests apart before routing agrees with the handler. An action is a POST with a `Tropical-Action` header and a JSON body, which only the handler reads: a body parser that runs first leaves it nothing to read (see `handler`). Ring's form and multipart parsers ignore JSON.
(scripts nonce)The script tags for the page's head: the scripts its first frame's islands
declared (co.multiply.tropical.script), each carrying nonce, the page's
CSP nonce, or none without one. Scripts islands declare later go over the
stream. Call it in the app's :page, which handler renders; anywhere else
it throws.
The script tags for the page's head: the scripts its first frame's islands declared (`co.multiply.tropical.script`), each carrying `nonce`, the page's CSP nonce, or none without one. Scripts islands declare later go over the stream. Call it in the app's `:page`, which `handler` renders; anywhere else it throws.
(stream-init tab)(stream-init tab {:keys [open-when-hidden] :as opts})The Datastar expression that opens tab's stream, for the page's data-init.
The stream is requested from the page's own URL. It carries the tab, and an
id for this page load, made in the browser, so the session can tell its own
page from a copy with the same tab id, such as a duplicated tab.
retry: 'always' reconnects a stream the server ended too, not just one that
failed: a closed session ends its connection so the client retries into a
new one.
Datastar closes the stream while the page is hidden, and reopens it when the
page is shown, which resyncs it. An action taken meanwhile runs, and its
result shows once the page is shown again, within the session's grace
(session/grace-ms).
mount renders it into the element the app mounts in, with the app's own
:open-when-hidden. For a page of its own making, opts:
:open-when-hidden true keeps the stream open while the page is hidden,
for development: browser automation can drive a page
its browser keeps hidden, and see each change as it
happens. Every hidden tab then holds its connection,
its session and, compressed, its encoder. A page that
is left still lets go of its stream, as it does
without the option, and one the browser restores
from its back/forward cache opens it again.The Datastar expression that opens `tab`'s stream, for the page's `data-init`.
The stream is requested from the page's own URL. It carries the tab, and an
id for this page load, made in the browser, so the session can tell its own
page from a copy with the same tab id, such as a duplicated tab.
`retry: 'always'` reconnects a stream the server ended too, not just one that
failed: a closed session ends its connection so the client retries into a
new one.
Datastar closes the stream while the page is hidden, and reopens it when the
page is shown, which resyncs it. An action taken meanwhile runs, and its
result shows once the page is shown again, within the session's grace
(`session/grace-ms`).
`mount` renders it into the element the app mounts in, with the app's own
`:open-when-hidden`. For a page of its own making, `opts`:
- `:open-when-hidden` true keeps the stream open while the page is hidden,
for development: browser automation can drive a page
its browser keeps hidden, and see each change as it
happens. Every hidden tab then holds its connection,
its session and, compressed, its encoder. A page that
is left still lets go of its stream, as it does
without the option, and one the browser restores
from its back/forward cache opens it again.(wrap-refused-streams handler)Middleware that answers a refused stream with a reload of its page.
A tab's stream passes the page route's middleware each time it connects, and that middleware may refuse it: the login expired, or what the page shows is gone. Datastar retries a refused stream with backoff, then gives up, and follows a redirect to a page it can't use as a stream; either way the tab is left stale. So a stream request answered with a redirect, or a 4xx other than 408 and 429, gets a stream that reloads the page instead, and the page request gets the app's own answer. A 5xx, as during a deploy, stays as it is, and the client retries it.
Put it outside everything that may refuse a page's requests, before routing
if the router's own not-found should count. A page whose stream is refused
again within reload-guard-s of reloading is left as it is.
Middleware that answers a refused stream with a reload of its page. A tab's stream passes the page route's middleware each time it connects, and that middleware may refuse it: the login expired, or what the page shows is gone. Datastar retries a refused stream with backoff, then gives up, and follows a redirect to a page it can't use as a stream; either way the tab is left stale. So a stream request answered with a redirect, or a 4xx other than 408 and 429, gets a stream that reloads the page instead, and the page request gets the app's own answer. A 5xx, as during a deploy, stays as it is, and the client retries it. Put it outside everything that may refuse a page's requests, before routing if the router's own not-found should count. A page whose stream is refused again within `reload-guard-s` of reloading is left as it is.
(wrap-scripts handler)Middleware that serves the scripts islands declare
(co.multiply.tropical.script), at their URLs under script/path. Other
requests pass to handler.
Put it outside the routes and the session's middleware: a script is the same for everyone, so the browser and shared caches keep it.
Middleware that serves the scripts islands declare (`co.multiply.tropical.script`), at their URLs under `script/path`. Other requests pass to `handler`. Put it outside the routes and the session's middleware: a script is the same for everyone, so the browser and shared caches keep it.
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 |