A page is one Ring handler, ring/handler, given the app: a map of what the sessions render and how the page around
them looks.
(def app
{;; The session's root island, from the request that created it.
:root (fn [{:keys [uid request]}] (project-page uid (-> request :path-params :id)))
;; The page, rendered from the session's first frame.
:page (fn [{:keys [request]}]
(let [nonce (csp-nonce request)]
(h/html
[h/doctype-html5
[:html {:data-nonce nonce}
[:head
[:script {:type "module" :nonce nonce :src datastar-url}]
(ring/scripts nonce)]
(ring/mount :body {:class [:min-h-screen]})]])))
;; Your authentication: the user a request is made on behalf of, or nil.
:uid current-user
:error-view (fn [id e] [:div.error "This part of the page failed to load."])
:first-frame-ms 150
:compression [:zstd :br :gzip]})
(def routes
[["/projects/:id" {:handler (ring/handler app)}]])
;; Innermost first: the router, then what may refuse a page, then tropical's.
(def handler
(-> (router routes)
wrap-login
wrap-session
ring/wrap-refused-streams
ring/wrap-scripts))
The keys, with the details in the docstrings of co.multiply.tropical.ring and co.multiply.tropical.session:
| Key | What it is |
|---|---|
:root | (fn [{:keys [tab uid request]}] call): the session's root island |
:page | (fn [{:keys [tab frame scripts request]}] html): the page, from the session's first frame |
:open-when-hidden | true keeps each tab's stream open while its page is hidden, for browser automation; optional |
:uid | (fn [request] uid): the user, or nil |
:error-view | (fn [id e] hiccup): what renders in place of an island that throws; optional |
:first-frame-ms | how long a session's first frame may wait for reads still pending; 0 by default |
:compression | the encodings to compress the stream with, in order of preference; none by default |
:max-signals-bytes | the largest action body read; 1 MiB by default |
ring/handler serves the page (a GET), the stream the page opens (a
GET with a Tropical-Tab header) and the actions its islands render (POSTs with a Tropical-Action header), all at
the page's own URL. Mount it for GET and POST. The app's :root gets the :request that created the session, so a
root can come from the route: (fn [{:keys [request]}] (project (-> request :path-params :id))).(action/request) returns that request.:uid returns the user a request is made on behalf of, and a page or action without
one gets a 403, a stream a reload, as a refused one does. Sign-in happens before the handler, or :uid returns an
anonymous id the app issues.ring/wrap-refused-streams, outside everything that may refuse a page, and
a stream answered with a redirect or a 4xx (other than 408 and 429) gets a stream that reloads the page instead, so
the page request gets the app's own answer: the sign-in redirect, the not-found page. A 5xx, as during a deploy, is
retried. If the page answers but its stream is refused again, the tab is left as it is rather than reloaded in a
loop. An open stream isn't asked again, so a page's subject going away while it is open is for its islands to show.(= :action (ring/request-kind req)) holds for exactly the requests the handler takes as actions, POSTs with
a Tropical-Action header and a JSON body, which Ring's form and multipart parsers leave alone. A body read before
the handler gets a 500. The handler reads at most :max-signals-bytes (1 MiB by default), and answers 413 to more.ring/wrap-scripts serves the scripts islands declare, under
/tropical/: wrap the app in it outside the routes and the session middleware (Scripts the page needs).An app of many pages shares one map, and gives each route its own root:
(def ^:private app
"What every page shares; each route adds its root."
{:uid current-user
:error-view error-view
:first-frame-ms 150
:compression [:zstd :br :gzip]
:page (fn [{:keys [request]}] (document request))})
(defn page
"The handler for a page whose root island is `(root request)`."
[root]
(ring/handler (assoc app :root (fn [{:keys [request]}] (root request)))))
(def routes
[["/projects/:id" {:handler (page #(project-page (-> % :path-params :id)))}]
["/settings" {:handler (page (fn [_] (settings-page)))}]])
:page renders what every page shares: the head, with Datastar, the CSP nonce
and the scripts, and the element the app mounts in (ring/mount). What differs from page to page is the root.(use-session) is {:tab :uid :request}, the request
being the one that created the session, so an island deep in the page reads the page's path, (-> (use-session) :request :uri), without a binding of the app's own. For a tab rebuilt after a restart, the request is its stream's,
to the same URL: the path and query are the page's either way, and the headers are the request's.ring/handler checks the app as it is called, such as
whether it can load its encodings.(ring/mount :body attrs & children) renders the element, such as
the body, with the app's attributes, holding the session's first frame. Its attributes open the tab's stream, kept
open while the page is hidden if the app says :open-when-hidden, keep the state the browser owns
(State the browser owns), and refuse files dropped outside a drop area. children go after the
first frame, outside the islands, such as a container for dialogs. The element's data-init opens the stream, so
attrs can't have one, and a page mounts once.(ring/scripts nonce) gives the script tags of the scripts the first
frame's islands declared, with the page's CSP nonce (Scripts the page needs). With mount and
scripts, :page needs nothing from its argument but the request, for the nonce and the app's own head.ui/page-attrs works, but doesn't keep the state the
browser owns, and lets a file dropped outside a drop area open in the tab: it is logged, once per handler.mount does: (ring/stream-init tab), the expression opening the
stream, in the element's data-init, with {:open-when-hidden true} if wanted; ui/page-attrs on it, given its
other attributes; and the page's frame, or without one, an element with the root's id, (island/element-id "app")
for a root named app. Its head takes (script/tags scripts nonce).Function(), which needs 'unsafe-eval', unless
the page opts into its CSP mode (Datastar 1.0.3 and later): put the page's nonce on the root element,
[:html {:data-nonce nonce}], and Datastar compiles each expression into a script carrying it. The nonce holds for
the page's life, so expressions patched in over the stream compile the same way. Without a nonce, leave the
attribute off: an empty one stops Datastar.:first-frame-ms, it
waits until no read of its islands is pending, or that many ms pass, and renders as reads land meanwhile, so a page
whose reads land in 20 ms arrives with them, and one whose read takes seconds arrives pending at the budget. Later
frames never wait. 100 to 200 ms covers the reads worth waiting for; the default, 0, sends the first frame at once.An island whose render throws renders the app's :error-view, (fn [id e] hiccup), in its place. The default says
only that the island failed: what it renders reaches the client, and an exception's message can carry what the user
shouldn't see. Failures and lifecycle events are logged through clojure.tools.logging, with the exception. A
failure in a session's own loop, outside the islands' renders, closes the session and its stream, so the client
reconnects into a new one.
An island's element id, the signal of a cookie or storage state, and a script's URL are opaque digests of the app's
names for them (co.multiply.tropical.names), 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 its errors; a failed
island's log line gives its element id too. A digest is deterministic, so every server gives a name the same one, and
what the browser keeps under a state's name is found after a deploy.
For debugging, the JVM property co.multiply.tropical.readable-names=true has the page show the names as written, and
(island/element-id path) gives an island's digest from the REPL. Never name them by hand in CSS, a script or an
expression: that holds only while they are readable. The helpers name them (ui/signal, ui/path), and an element
the app needs to find gets an id of its own.
With :compression [:zstd :br :gzip] in the app, each tab's stream is compressed with the first of those its request
accepts, or not at all. One encoder lasts the stream's life, so a morph repeating what an earlier frame sent goes as a
reference to it, and each frame is flushed through it at once, so the client decodes the frame as it arrives. A
server's own compression can't do that for a response that never ends: Jetty's zstd and brotli hold what they are given
until the response ends. The page and the actions are left to the server's compression.
co.multiply.tropical.zstd.library and co.multiply.tropical.brotli.library name a libzstd, 1.4 or
later, and a libbrotlienc, 1.0 or later. An encoding the handler can't load fails its creation.--enable-native-access=ALL-UNNAMED keeps the JDK from warning as they call
native code.session/heartbeat-ms): an SSE comment, which the client ignores, and which keeps the idle timeouts of proxies
along the way from closing it.session/grace-ms), then lets go of them; a tab shown after that gets a
fresh session. :open-when-hidden true in the app keeps the stream open while hidden, for browser automation in
development.An open session goes on rendering with the code it started with, since a reload such as clj-reload's or
tools.namespace's makes new vars. Make session/close-all! the last step of the app's reload, once the new code serves
requests. Each tab reconnects about a second later into a fresh session, whose first frame updates the page in place,
leaving its signals, scroll and form input as they are. A tab that reconnects before the new code serves gets a session
of the old code. What only a page load renders, the page's head and edited scripts, shows once the page is reloaded.
Can you improve this documentation?Edit on GitHub
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 |