re-frame.ssr.ring¶
The Ring/Pedestal host adapter for re-frame2 server-side rendering. It materialises the structured response produced by the SSR runtime in re-frame.ssr into the wire format a Ring-compatible server expects. It never writes to a socket directly. The per-request lifecycle, the rendering and head primitives, the SSR events / subs / cofx, and the :rf.server/* fx all live in re-frame.ssr (artefact day8/re-frame2-ssr), not here.
Ships in the day8/re-frame2-ssr-ring artefact. See Server-side rendering — the tutorial for the conceptual walkthrough.
Handler constructors¶
ssr-handler¶
- Kind: function
- Signature:
- Description: Returns a synchronous Ring handler that renders one re-frame2 SSR request per call.
The returned handler owns the whole per-request lifecycle:
- Populate the per-frame request slot.
- Register the per-request frame, draining
:initial-eventssynchronously so the:rf.server/requestcofx can resolve. - Read the resolved response accumulator, and branch on
:redirect. - Otherwise, render
:root-view, build the hydration payload, wrap it in the HTML shell, and materialise structured cookies toSet-Cookieheaders. - Destroy the frame in a
finally.
Required opts:
:initial-events— an ordered vector of events, dispatched synchronously and in order into the per-request frame at creation. Alternatively, a(fn [request] → initial-events-vector)that derives the vector from the Ring request. The fn form is the replay-safe seam for folding a request-derived fact into a boot event's payload, e.g.(fn [req] [[:auth/server-init {:user (extract-user req)}]]). For non-durable request reads inside a handler, declare:rf.cofx/requires [:rf.server/request]on the registration instead.- Omission throws
:rf.error/ssr-ring-missing-initial-eventsat construction. - A value that is neither a vector nor a fn, or a fn returning a non-vector, throws
:rf.error/invalid-initial-eventsper request.
- Omission throws
:root-view— a hiccup vector (e.g.[:app/root]) or a 0-arity fn returning hiccup, rendered against the per-request frame after the drain settles.- Omission throws
:rf.error/ssr-ring-missing-root-viewat construction. - Any other shape throws
:rf.error/invalid-root-viewat render time.
- Omission throws
:payload— REQUIRED, fail-closed. The hydration-payload policy, one opt with two shapes:- A non-empty vector of top-level app-db keys (keywords) is an allowlist (recommended). Only the listed keys ship in
:rf/app-db. Every other key is dropped, including keys added later. - The keyword
:rf.ssr.payload/whole-app-dbships the wholeapp-db. Use it only when the app-db is structurally safe to expose. - Absence or an empty allowlist throws
:rf.error/ssr-missing-payload-policy. An unrecognised keyword throws:rf.error/ssr-unknown-payload-policy. An allowlist with non-keyword entries throws:rf.error/ssr-malformed-payload-allowlist. All three throw at construction.
- A non-empty vector of top-level app-db keys (keywords) is an allowlist (recommended). Only the listed keys ship in
Optional opts:
:fx-overrides— per-frame:fx-overridesmap, passed through verbatim (e.g. to stub:rf.http/managedin tests).:ssr— per-frame:ssrconfig map, e.g.{:dev-error-detail? true :public-error-id :myapp/projector}.:emit-hash?— embeddata-rf-render-hashon the root element; defaulttrue.:version— hydration payload's:rf/version; default1.:schema-digest— hydration payload's:rf/schema-digest.:html-shell—(body-html payload-edn opts) → string; defaults todefault-html-shell.:content-type— default"text/html; charset=utf-8".
Error handling — :error-view vs :on-error. These two opts handle two different failures, and a robust deployment wires both. Classification is by the projected status: a projected 4xx (routing miss / bad client input) keeps the app's own not-found / bad-request body + hydration payload and does not call :error-view; a projected 5xx (server fault) ships the error page. A buggy :error-view — whether it throws OR depends on a reactive sub that recovers to nil — falls back once to the default template; a buggy :on-error falls back to default-on-error. Neither bug bypasses the error boundary.
| Aspect | :error-view |
:on-error |
|---|---|---|
| Which failure? | A projected 5xx the error projector catches: a drain-time handler/fx/sub exception, a render-time view throw, or an unrenderable root/shell throw. A projected 4xx does NOT reach it — the app keeps its own body. | A transport / Ring-layer failure the projector cannot see: a per-request frame setup throw, a render-time CLJ exception, a header/cookie materialise throw, or a thrown initial-event. |
| What does it produce? | The projected error-page body (hiccup): a registered-view keyword (resolved as [error-view public-error]) or a (public-error) → hiccup fn. It renders through the standard SSR emitter, with no app body / hydration payload alongside it. |
A raw Ring response map {:status … :headers … :body …} returned verbatim to the server. |
| What is its input? | ONLY the public-error map, sanitised by the projector and safe to render (never the request, throwable, or frame). | The raw (request throwable), carrying the unsanitised throwable. The locked default never reads it. |
| Default when omitted? | Minimal default error template (absence does NOT keep the root body). | Minimal locked 500 (default-on-error, topology-leak-safe). |
Trusted shell-hook string opts. Four optional strings cross the trust boundary into the rendered HTML envelope, split by injection position:
:headand:body-end— raw content hooks, injected verbatim with no escaping.:script-src(default"/main.js") and:app-element-id(default"app") — escaped attribute hooks, escape-attr'd into a quoted attribute value.
Non-string non-nil values throw :rf.error/ssr-trusted-shell-opt-invalid at construction. Wiring the raw content hooks from untrusted input (a CMS field, a tenant-admin form, a query-string parameter) is an arbitrary-script-injection XSS vector. When content originates upstream of the trust boundary, use the structured alternatives: reg-head for head fragments, and registered views plus the :rf.server/* fx for body content.
- Example:
(require '[ring.adapter.jetty :as jetty]
'[re-frame.core :as rf]
'[re-frame.ssr.ring :as ssr.ring])
(rf/init! (requiring-resolve 'my-app/ssr-adapter))
(def handler
(ssr.ring/ssr-handler
{:initial-events [[:rf/server-init]]
:root-view [:app/root]
;; :payload is REQUIRED, fail-closed. A vector is an allowlist of
;; top-level app-db keys to ship; :rf.ssr.payload/whole-app-db opts
;; into the whole db. Omit it and construction throws
;; :rf.error/ssr-missing-payload-policy.
:payload [:articles :session-user]
:html-shell my-app/shell}))
(jetty/run-jetty handler {:port 3000 :join? false})
stream-handler¶
- Kind: function
- Signature:
- Description: The streaming counterpart of
ssr-handler. Returns a synchronous Ring handler that streams SSR responses viaTransfer-Encoding: chunked.
Streaming behaviour:
- Flushes a shell on the first byte, then streams
:rf/suspense-boundarysubtrees as their data resolves. - A boundary whose drain changed app-db also carries a speculative per-subtree hydration delta, projected through the same
:payloadpolicy as the final payload. An off-allowlist or empty delta emits no delta script. - The final chunk carries the canonical full hydration payload. If a speculative delta and the canonical payload ever disagree, the payload wins.
- Failure isolation is per-boundary: a boundary whose render throws keeps its fallback (with a
:rf.ssr/suspense-boundary-failedtrace) while the rest of the page streams on. - Non-streaming responses (no
:rf/suspense-boundaryin the tree) still ride the chunked path, with zero continuations. The wire shape collapses to shell-prefix + shell-html + final-payload + shell-suffix. - A
:redirectset during the drain short-circuits to a bodiless Location response before any chunk is written. - The shell renders on the request thread, before the chunked head commits. A root-view or shell-walk throw fails closed to a non-200 projected error page (
:rf.error/ssr-render-failedvia the projector), with no writer thread spawned. - Any
Content-Lengthheader accumulated during the drain is stripped (case-insensitively) so the host server owns chunked transfer framing.
Opts mirror ssr-handler: :initial-events (both vector and (fn [request] → …) forms), :root-view, :payload, :fx-overrides, :ssr, :on-error, :error-view, :emit-hash?, :version, :schema-digest, :content-type, plus the four trusted shell-hook opts (:head / :body-end / :script-src / :app-element-id, honoured by default-streaming-prefix / default-streaming-suffix).
One exception: :html-shell is not supported and is rejected at construction (:rf.error/ssr-streaming-unsupported-opt). The streaming path flushes a split prefix/suffix straddling the continuation chunks, so a one-piece shell callback can never run. Customise the streaming envelope through the trusted shell-hook opts, or use ssr-handler when a bespoke one-piece shell is required.
Concurrency model: one raw daemon java.lang.Thread per in-flight streamed request. There is no framework pool and no framework in-flight cap. Every writer's catch/finally closes the pipe and tears the frame down on every exit path (no-leak). The in-flight ceiling is the host server's accept-queue / worker-thread limit (Jetty / http-kit / Aleph). Operators size that limit as the one knob for high streaming concurrency or slow-client hardening.
- Example:
(require '[ring.adapter.jetty :as jetty]
'[re-frame.ssr.ring :as ssr.ring])
;; Same opts as ssr-handler, minus :html-shell (rejected at construction).
;; Customise the streaming envelope via the trusted shell-hook opts.
(def handler
(ssr.ring/stream-handler
{:initial-events [[:rf/server-init]]
:root-view [:app/root]
:payload [:articles :session-user]
:script-src "/js/main.js"}))
(jetty/run-jetty handler {:port 3000 :join? false})
ssr-middleware¶
- Kind: function
- Signature:
- Description: Returns Ring middleware that delegates to
ssr-handlerfor requests its:match?predicate accepts, and to the wrapped handler otherwise. Curried:(ssr-middleware opts)returns a(handler) → wrapped-handlermiddleware.
Opts are ssr-handler's opts (including the required, fail-closed :payload) plus :match?, a (request) → boolean predicate. When it returns truthy, SSR renders. When it returns falsy, the call falls through to the wrapped handler. :match? defaults to matching every GET request.
- Example:
;; ssr-middleware is CURRIED: (ssr-middleware opts) returns a Ring
;; middleware (handler) → wrapped-handler. Apply it to the fallback
;; handler, then compose normally.
(def app
(-> default-handler
((ssr.ring/ssr-middleware
{:initial-events [[:rf/server-init]]
:root-view [:app/root]
:payload [:articles :session-user]
:match? (fn [req] (= :get (:request-method req)))}))
wrap-static-assets))
Defaults and overrides¶
handler-defaults¶
- Kind: var
- Signature:
- Description: The default
ssr-handleropts, merged under caller-supplied opts at construction (caller values win). This is a data var, not a fn. It is exposed so callers can read or extend the baseline.:on-erroris deliberately absent: it is resolved separately, so the defaults stay orthogonal to the on-error precedence. - Example:
default-html-shell¶
- Kind: function
- Signature:
- Description: The default HTML envelope: wraps the rendered body in a minimal, runnable document. Override via the
:html-shellopt onssr-handlerfor a custom<head>/ scripts / styles.
Arguments:
body-html— the string returned byre-frame.ssr/render-to-string.payload-edn— the hydration payload pre-serialised withpr-str.opts— the adapter opts; the keys:head/:html-attrs/:body-attrs/:body-end/:script-src/:app-element-id/:langinfluence the envelope.
Behaviour:
- The hydration-payload
<script>is stamped with the framework-pinned id__rf_payload; the client bootstrap reads it viadocument.getElementById("__rf_payload"). Its EDN body is escaped EDN-aware, so a payload containing</script>cannot close the envelope. A custom shell must emit the payload<script>under this id, with equivalent escaping, or substitute its own bootstrap that reads a custom id. - The shell does not emit
<title>. The head fragment threaded in as:headis the canonical source. - The two attribute-value hooks (
:script-src,:app-element-id) are escape-attr'd. The two content hooks (:head,:body-end) are injected raw. - Example:
;; The default envelope, invoked directly (the handler does this for you). (ssr.ring/default-html-shell "<div>rendered body</div>" "{:rf/version 1 :rf/app-db {}}" ;; pr-str'd hydration payload {:head "<title>MyApp</title>" :script-src "/js/main.js"}) ;; => "<!DOCTYPE html><html lang=\"en\"><head>…</head><body>…</body></html>"
default-on-error¶
- Kind: function
- Signature:
- Description: The minimal 500 response used when a handler caller omits
:on-error. Shared byssr-handlerandstream-handler, so the topology-leak contract lives in one place. It covers exceptions the SSR error projector can't see: Ring-layer throws, render-time CLJ exceptions, and writer-thread-pre-spawn throws. Trace-emitted drain errors are handled by the projector instead.
The body must not leak the throwable's message: .getMessage carries internal topology, such as JDBC URLs, deploy-root file paths, partial SQL, and server-internal class names. So the fn ignores the throwable and emits a fixed generic plaintext body. Apps wanting a branded transport-failure body supply an explicit leak-safe :on-error fn that returns a fixed response and ignores the throwable. Exposed as a value: a 2-arity fn, not a defn, so it carries no :arglists.
- Example:
;; The host-locked transport-failure net (used when :on-error is omitted).
(ssr.ring/default-on-error request some-throwable)
;; => {:status 500
;; :headers {"Content-Type" "text/plain; charset=utf-8"}
;; :body "Internal error"}
Streaming envelope¶
default-streaming-prefix¶
- Kind: function
- Signature:
-
Description: The shell prefix flushed as the first streamed chunk. It mirrors
default-html-shell's open +<head>+ body-open + app-div-open. It also shares the:html-attrs/:langfallback with the non-streaming shell, so the two envelopes can't diverge. -
head-html— the resolved head fragment. opts— honours:html-attrs/:body-attrs/:lang(default"en") /:app-element-id(default"app") /:render-hash.
When :render-hash is supplied (the handler passes it iff :emit-hash? is true), data-rf-render-hash is stamped on the #app div, the first DOM root of the streamed document. This mirrors the non-streaming handler's root-element marker.
- Example:
;; The first streamed chunk — open + <head> + <body> + app-div-open.
(ssr.ring/default-streaming-prefix
"<title>MyApp</title>" ;; resolved head HTML
{:lang "en" :app-element-id "app"})
;; => "<!DOCTYPE html><html lang=\"en\"><head>…</head><body><div id=\"app\">"
default-streaming-suffix¶
- Kind: function
- Signature:
- Description: The shell suffix flushed after the final-payload chunk. It emits the bootstrap
<script>(if:script-srcis set; default"/main.js", escape-attr'd), the raw:body-endHTML, and the document close (</body></html>).
The app root (</div>) is not closed here. It is closed at the end of the shell chunk, so the resolved templates, hydration-delta scripts, and the final __rf_payload script all stream outside #app. The suffix is therefore purely bootstrap script + raw :body-end + document close. All of it is already outside #app, mirroring the non-streaming default-html-shell.
- Example:
;; The trailing chunk — bootstrap <script>, raw :body-end, document close.
(ssr.ring/default-streaming-suffix {:script-src "/js/main.js"})
;; => "<script src=\"/js/main.js\"></script></body></html>"
Cookie serialisation¶
cookie->set-cookie-header¶
- Kind: function
- Signature:
- Description: Serialises one structured
re-frame.ssrcookie map to aSet-Cookieheader value, per RFC 6265 §4.1. It is re-exposed from the façade so that tests, alternate host adapters (Pedestal, http-kit), and user code needing a one-off serialisation can reach it without an internal namespace.
Fields:
:name— required.:value— URL-encoded; serialises as the empty string when absent.- Everything else (
:max-age,:domain,:path,:expires(epoch-millis long),:secure,:http-only,:same-site) is an attribute appended after semicolons.
Validation is fail-loud:
:namemust be a string or a keyword/symbol, and it must match the RFC 6265 §4.1.1 token grammar. Either violation throws:rf.error/cookie-invalid-name.- A missing
:namethrows:rf.error/cookie-missing-name. :domain/:path/:max-age/:same-siteare checked for CR / LF / NUL before concatenation; a violation throws:rf.error/cookie-invalid-attribute. This closes header-splitting injection.- A non-integer
:expiresthrows:rf.error/cookie-invalid-expires. - Example:
See also¶
re-frame.ssr— the SSR runtime:render-to-string, the streaming triple, the head model (reg-head/active-head/render-head), error projection (reg-error-projector/project-error), the SSR events / subs / cofx, and the per-request:rf.server/*fx.re-frame.core—init!,make-frame,reg-event, and the render / head primitives re-exported on the facade.re-frame.routing— routes opt into per-route head models via:headmetadata.- Server-side rendering — the tutorial — the conceptual walkthrough.