Skip to content

Spec 011 — Server-Side Rendering & Hydration

SSR is a core goal; see 000-Vision.md.

The :rf.server/* per-request fxs are managed external effects — per Managed-Effects, the surface MUST satisfy the nine properties. The :rf.server/* fxs shape the HTTP response synchronously inside the per-request frame and never report a completion across an event boundary, so property 9 (the uniform async-reply envelope) is exempt — the eight synchronous properties are the ones that bite: effect-as-data, framework-owned per-request lifecycle, structured failure taxonomy under :rf.ssr/*, trace-bus observability, :sensitive? / :large? composition, built-in retry / abort / teardown semantics, in-flight per-request registry, per-frame interceptor scoping.

Abstract

Server-side rendering (SSR) is part of the target architecture. The design supports:

  • rendering views on the server from explicit state and inputs
  • serialising the initial state needed for hydration
  • separating render-time computation from browser-only side-effects
  • evaluating derived state without a browser runtime
  • making the client/server handoff explicit rather than magical

This Spec captures the contract; the per-host implementation realises it.

Artefact (CLJS reference). Per the per-feature artefact-split strategy, the CLJS reference's SSR & hydration surface ships in the separate Maven artefact day8/re-frame2-ssrre-frame.ssr namespace, the pure hiccup → HTML emitter (render-to-string), the FNV-1a structural render-tree hash (render-tree-hash), the :rf/hydrate event with :replace-frame-state semantics, the seven :rf.server/* server-only fxs (set-status, set-header, append-header, set-cookie, delete-cookie, redirect, safe-redirect) registered at ns-load time, the per-request HTTP response accumulator in a framework-private side-channel atom keyed by frame-id (read via get-response, NOT an app-db path — see §Response storage substrate), the reg-error-projector registry kind plus the built-in :rf.ssr/default-error-projector, the SSR error-projection trace listener, and the data-rf2-source-coord annotation on registered-view roots. The core artefact (day8/re-frame2) no longer carries any of this; apps that don't render server-side build an :advanced bundle clean of every re-frame.ssr / :rf.ssr/* / :rf.server/* symbol and trace string. See MIGRATION §M-32 for the deps swap.

Pattern-level requirements

Views are pure functions of (state, props) → render-tree

A view does not read its frame from ambient context at render time. The frame is a parameter (or implicit-parameter via a serialisable id). React-context-driven frame resolution may exist as a CLJS-implementation optimisation, but the underlying contract is explicit-frame addressing — otherwise SSR cannot resolve the right frame on the server.

The render-tree is serialisable data

The output of a view is a nested data structure (hiccup, JSX-as-data, virtual-DOM nodes, template strings — implementation choice). It must be serialisable enough that the server can render it to a string and the client can hydrate against it.

Frames are per-request

A server-side request creates a frame, runs setup events (the frame's :initial-events, computed per request from the request), renders, serialises the resulting state, destroys the frame. The frame contract is unchanged from Spec 002 — frames are isolated runtime boundaries; "per-request" is just one more use case alongside multi-instance / per-test / per-session.

The override seam is id-based

The dispatch envelope's :fx-overrides and :interceptor-overrides cannot be raw functions — functions don't serialize across the wire. Overrides are {registered-fx-id → registered-fx-id} maps, looked up at consumption time. The CLJS reference may keep function-valued overrides as a client-only convenience, but the pattern's contract is id-based.

Hydration is a defined protocol

Not magic:

  1. Server creates a frame for the request.
  2. Server dispatches setup events (events that resolve via JVM-runnable handlers).
  3. Server serialises the resulting app-db (and any other frame state needed for hydration).
  4. Server renders the view to a string and ships both the HTML and the serialised state to the client.
  5. Client creates a frame, dispatches a :rf/hydrate event with the serialised state as payload.
  6. Client renders against the now-seeded state.

Hydration equivalence rule (canonical)

Equivalence is structural, not textual. The contract is: the client, given the hydrated payload, computes the same view the server rendered. Operationally this is verified by hashing the canonical-EDN serialisation of the render-tree on both sides and comparing the hashes (per §Hydration-mismatch detection below).

This rule is the lock. Byte-for-byte HTML equality is not required — different HTML serialisers may emit semantically-equivalent strings that differ in attribute order, whitespace, or boolean-attribute spelling. Tools and tests assert structural equivalence (the canonical-EDN hash) and may also compare HTML strings as a stricter check, but the contract requires only the structural form. The single equivalence rule applies to body, head (§Mismatch detection — head), and any other render-tree fragment the runtime hashes.

Mismatches at step 6 are detectable and surface as structured trace events per 009.

Payload scope (canonical boundary)

The :rf/hydration-payload is bounded — it carries the minimum data the client needs to recompute the server's view. The schema is in Spec-Schemas §:rf/hydration-payload; the canonical scope at this layer is:

In payload Purpose
:rf/version Integer pattern-protocol version stamp (per Spec-Schemas §:rf/hydration-payload:int, NOT a semver string); mismatches emit :rf.ssr/version-mismatch. Server-side source-of-truth (host adapter): the host adapter resolves the stamp in this order — (1) explicit :version opt passed to the payload builder; (2) the framework-global :rf2/runtime-version late-bind hook (the same source the client-side :rf.ssr/check-version fx reads, per §The :rf/hydrate event — both sides of the wire pin the same value when the host registers the hook at boot); (3) terminal fallback 1 (the v1 pattern-protocol stamp). Each source is coerced/validated to an integer: an int is taken verbatim, a whole-number string ("7") is tolerantly parsed, and any other value (a semver "1.0.0", a float, a keyword) is REJECTED with a :rf.ssr/invalid-version warning so resolution falls through to the next source — the assembled payload therefore always carries an integer :rf/version (per Spec-Schemas §:rf/hydration-payload). Hosts that don't register :rf2/runtime-version (or pass :version explicitly) ship the terminal 1 — the check-version fx still no-ops cleanly because the client's lookup hits the same fallback.
:rf/frame-id The frame the server rendered under — the SSR-wire spelling of the frame stamp. Payload metadata + validation evidence, not a no-opts target resolver (EP-0002): the client passes its hydration target explicitly to hydrate!, and the runtime validates this :rf/frame-id against that explicit target. A present-and-different value raises :rf.error/hydration-frame-id-mismatch; an absent value is no conflict (the explicit target stands). The validation is enforced at two boundaries (rf2-nv3mua): the boot helper hydrate! validates + throws pre-dispatch, AND the :rf/hydrate handler itself fails CLOSED on a present-and-different :rf/frame-id against the dispatch target — so the direct-dispatch-sync split path (hydrate!'s documented post-mount-verify escape hatch) cannot silently install a server slice into the wrong frame.
:rf/app-db The serialised app-db partition — server's authoritative application state. Replace policy on hydrate.
:rf/runtime-db (optional) The serialised SERIALIZABLE runtime-db projection — machine snapshots, route slice, elision declarations, SSR metadata. Carries only durable facts; transient side channels are excluded (Mike ruling #13). Together with :rf/app-db it installs a coherent frame-state. Replace policy on hydrate.
:rf/schema-digest (optional) Hash of the server's registered app-schema set; mismatches emit :rf.ssr/schema-digest-mismatch.
:rf/render-hash (optional) Structural hash of the server-rendered render-tree (covers body + head). The :rf/hydrate handler stashes it at [:rf.runtime/ssr :hydration :server-hash] for verify-hydration! to compare against the client's first-render hash (per §Hydration-mismatch detection).
:rf/ssr-rendered-at (optional) ms-since-epoch the server completed the render; SSR metadata for diagnostics.
Route slice (carried inside :rf/runtime-db at [:rf.runtime/routing :current]) Active route, populated by :rf.route/handle-url-change server-side. Rides the runtime-db projection (EP-0001 — the route slice is runtime-db state).
Machine snapshots (carried inside :rf/runtime-db at [:rf.runtime/machines :snapshots]) State-machine snapshots; survive the round-trip per 011 §:after is no-op under SSR. Ride the runtime-db projection (EP-0001 — machine snapshots are runtime-db state).
:rf/sub-warmups (optional, future-additive) Pre-computed sub values; absent in v1 (see §Hydration of non-state runtime artefacts).

Out of payload scope (explicitly not carried):

  • The trace stream and trace ring buffer (dev-only; see 009 §Production builds).
  • Server-side handler closures, fx implementations, and any function-valued state (overrides are id-based per §The override seam is id-based).
  • In-flight HTTP request continuations (host-side concern; not part of v1).
  • Sub-cache contents beyond the optional :rf/sub-warmups slot.
  • Internal trace-event detail (the security boundary in §Server error projection — error pages carry only the locked :rf/public-error shape).

The bounded payload is the lock: implementations may emit additional optional keys per the additive-fields rule, but never alter the required keys, and the boundary above is what consumers (clients, tests, host adapters) rely on.

:rf/app-db projection — explicit fail-closed policy

The :rf/app-db slice is projected from the request frame's app-db per an explicit, fail-closed policy. The host adapter MUST receive a single declarative opt — :payload — at construction time. It carries the policy in one of two shapes:

  • :payload [<top-level-app-db-keys>] (a non-empty sequential of keywords — a vector, the canonical spelling, or a list / lazy-seq, e.g. a computed (filterv …) / (keep …) result) — an allowlist. Only the listed keys ride the wire; everything else is dropped, including any keys added later as the app evolves. This is the recommended primary mechanism — a denylist would silently leak each new server-only key as the app evolves; an allowlist forces a deliberate edit per new wire-bound key. The policy selector is collection-vs-keyword, so any sequential keyword collection is accepted (a sequential can never be confused with the whole-app-db keyword); a set is rejected — the allowlist is an ordered key selection. Every element MUST be a keyword (top-level app-db keys are keywords); a non-empty sequential coll carrying a non-keyword element — a string typo for a keyword (["public/articles"] or '("public/articles")), a stray nil, a nested coll — is a malformed allowlist and fails loud at construction time with :rf.error/ssr-malformed-payload-allowlist rather than silently shipping a wrong/empty select-keys slice.
  • :payload :rf.ssr.payload/whole-app-db (the policy keyword) — explicit opt-in to ship the whole app-db verbatim. Use only when the app's app-db is structurally safe to expose end-to-end (e.g. small SPAs where every server-set key is intended for the client).

Absence of :payload is a structural error — the host adapter throws :rf.error/ssr-missing-payload-policy at handler-construction time so misconfigured deployments fail at boot, not at first request. The contract makes the privacy decision explicit at every host site (no fail-OPEN default at a security boundary). The CLJS reference's projection helper lives in re-frame.ssr.payload-policy/apply-policy (consumed by both re-frame.ssr.ring.payload/build-payload for non-streaming and re-frame.ssr.streaming/build-final-payload for streaming SSR — the policy contract is shared).

The single-opt shape is deliberate: a single value holds exactly one policy, so there is nothing to arbitrate. The allowlist-vs-whole-app-db choice is the value's SHAPE (sequential keyword collection vs keyword), not a contest between two opts — which removes the prior surface's precedence rule and silent-ignore branch. An empty :payload ([] / '()) is treated as no-allowlist — shipping zero keys is almost certainly a programmer error, not intent — and falls into the missing-policy bucket. An unrecognised :payload keyword surfaces as the distinct :rf.error/ssr-unknown-payload-policy, and a non-empty sequential allowlist with a non-keyword element surfaces as the distinct :rf.error/ssr-malformed-payload-allowlist (carrying the offending entries under :bad-entries), so the developer can tell the three failure modes apart at construction time.

SSR flow

Server flow (per request)

HTTP request arrives
re-frame.frame/make-frame { :initial-events [[:rf/server-init request-context]] }  ;; record-config path: :initial-events rides reg-frame / make-frame
:initial-events dispatched-sync
    └─ run setup events (read session, load initial data via :http server-platform fx)
drain to fixed point (run-to-completion)
final app-db captured via (app-db-value frame-id)
view rendered to render-tree by calling the registered root view fn against (state, props)
render-tree → string by hiccup→HTML emitter (pure, JVM-runnable)
serialise app-db → wire format (EDN by default in the CLJS reference; JSON acceptable for cross-language)
HTTP response: HTML + serialised state injected as a `<script>` payload
destroy-frame!

:initial-events at two layers — the same name, one feeding the other. The frame config key :initial-events is the declarative per-request frame setup (EP-0027): an ordered vector of events, computed per request and passed to make-frame / reg-frame. The retired frame keys :on-create / :initial-db are gone — a supplied :on-create fails loud with :rf.error/on-create-retired. The ssr-ring adapter's handler-construction opt :initial-events on ssr-handler / stream-handler (re-frame.ssr.ring.lifecycle) is the adapter-level surface that produces that frame key: it accepts a vector directly OR a (fn [request] → initial-events-vector), resolves it once per request, and lowers the result verbatim into the per-request frame's :initial-events. The handler opt and the frame key deliberately share the name because the opt's value simply becomes the frame's :initial-events — the (fn [request] …) form is the adapter's request→vector lowering, which EP-0027 leaves as an adapter detail (per EP-0027 §SSR / Out of scope).

Client flow (on page load)

HTML loads, browser parses
client bootstraps; reads serialised state from the embedded `<script>` payload
make-frame on the client
dispatch-sync [:rf/hydrate serialised-state] — installs the frame-state (app-db + serializable runtime-db)
client renders root view; first render-tree should match the server's HTML
react/reagent attaches event listeners to the existing DOM
hydration-mismatch detector compares first client render-tree against server-supplied marker (if any) and emits a trace event on mismatch
app is interactive

Detailed design

Server-side init flow

Per the SSR namespace exports an adapter Var of the same ten-fn shape Spec 006 §The reactive-substrate adapter contract specifies. Server-side bootstrap is one explicit call:

(require '[re-frame.core :as rf]
         '[re-frame.ssr :as ssr])

(rf/init! ssr/adapter)

The SSR adapter is plain-atom-shaped — make-state-container is clojure.core/atom, read-container is deref, replace-container! is reset!, make-derived-value is a recompute-on-deref IDeref reify; the JVM has no React reactivity layer. The adapter binds re-frame.ssr/render-to-string directly into the :render-to-string slot, so callers using rf/render-to-string (which delegates through the installed adapter) get the SSR emitter without any late-bind wiring at the call site. The :render slot deliberately throws (:rf.error/render-on-headless-adapter) — SSR uses render-to-string exclusively; calling render on a server-side process is a programmer error worth surfacing loudly.

CLJS hosts that ship Reagent on the browser AND need SSR on the JVM use the appropriate adapter per platform branch:

;; .cljc shared between JVM (server) and CLJS (browser):
#?(:cljs
   (defn ^:export run []
     (rf/init! reagent-adapter/adapter)
     (rdc/render react-root [(rf/view :app/root)])))

#?(:clj
   (defn ssr-handler [request]
     (rf/init! ssr/adapter)
     ...))

Per Spec 006 §Adapter selection at boot the init! call is idempotent — re-calling it after the adapter is installed is a no-op. It installs the adapter/runtime capabilities only; it does not create or ensure any frame (EP-0002).

:platforms metadata on reg-fx

Every registered effect handler declares which platforms it runs on:

(rf/reg-fx :http
  {:doc       "HTTP request — runs on both server and client"
   :platforms #{:server :client}
   :schema    HttpFxSchema}
  (fn [m args] ...))

(rf/reg-fx :localstorage
  {:doc       "Browser localStorage — client only"
   :platforms #{:client}}
  (fn [m args] ...))

(rf/reg-fx :rf.server/set-status
  {:doc       "Set HTTP response status — server only"
   :platforms #{:server}}
  (fn [m args] ...))

Default if absent: #{:server :client} (universal). Fx run wherever they are dispatched, including JVM headless tests. Fx that cannot run server-side (:localstorage/set, browser-DOM mutations, things that require js/window) declare :platforms #{:client} explicitly.

The fx resolver consults the active platform on dispatch (a runtime-static value: :server on the JVM-side server, :client in the browser). If an effect's :platforms set doesn't include the active platform, the resolver:

  1. Emits a :rf.fx/skipped-on-platform trace event with {:fx-id :localstorage, :platform :server}.
  2. Treats the effect as a no-op for that invocation.

This gives a clean, deterministic story for SSR: every effect declares its compatible platforms; the resolver enforces. No runtime (when (browser?) ...) checks scattered through handler bodies.

The render-tree → HTML emitter (CLJS reference)

A pure function (hiccup-form, opts) → string. Pattern-level: implementations supply equivalent. CLJS reference shape:

(rf/render-to-string
  view-or-hiccup           ;; a hiccup form (including [:view-id arg…] refs)
  {:doctype? true          ;; prepend "<!DOCTYPE html>"
   :emit-hash? true})      ;; embed data-rf-render-hash on the root element

The active frame is the one bound by the surrounding (rf/with-frame frame-id …) call (host adapters wrap their per-request frame). View arguments travel as inline hiccup positions — [:view-id arg1 arg2] — not via a separate :props opt. Per Q5 / cluster.

Return shape — locked to STRING. render-to-string always returns one shape: an HTML string. It does NOT return a map of {:html :hash :status :headers ...}. Callers that need the structural hash use the separate render-tree-hash fn (or read the data-rf-render-hash attribute the emitter embeds when :emit-hash? is set). Callers that need the HTTP response triple read the per-request response accumulator via get-response (per §HTTP response contract; the accumulator lives in a framework-private side-channel atom keyed by frame-id, NOT an app-db path — see §Response storage substrate) — that accumulator is the carrier for :status / :headers / :cookies / :redirect. Hosts that want the bundled {:html :payload :response} request-result shape (per §Request-handler return shape) build it from these three primitives — render-to-string is the string-yielding piece, get-response reads the resolved response, and the host builds the hydration payload.

The emitter:

  • Walks the hiccup tree.
  • Resolves DOM tags into HTML strings; void elements (<br>, <img>, ...) self-close per HTML5 rules.
  • Escapes text content per the position (attribute values, text nodes, raw inside <script>/<style>).
  • Calls registered views inline (looking each up via the registrar; same path as client-side).
  • Resolves subscribe calls inside view bodies against the frame's static app-db value (no reactive tracking; subs are pure derivations during SSR).
  • Returns a string.

JVM-runnable. No React, no DOM, no JS runtime. Hiccup is data; the emitter is a pure function over data.

Streaming/chunked emission ships as the :rf/suspense-boundary primitive — see §Streaming SSR under Detailed design.

XSS at output boundaries

The emitter renders a host render-tree to a string that crosses the trust boundary into a browser. Three emission positions have different escaping rules — text nodes, attribute keys/values, and raw-script bodies (<script> for JSON-LD, <style> for inline CSS) — and the emitter MUST apply the position-appropriate escape at every leaf. Mixing positions or under-escaping any one of them is the XSS vector. The CLJS reference's escapes are catalogued below; other-language ports re-bind the same three rules to their HTML emitter.

  • JSON-LD <script> body — escape < as &lt;. String values inlined into a <script type="application/ld+json"> body MUST have every < re-encoded so an attacker-supplied substring (a product title, an article summary, a partner-supplied payload) cannot close the script context with </script> and pivot into HTML. The escape applies to every string leaf in the JSON-LD payload; other characters inside the script body (&, >, ", ') are JSON-string-safe and need no additional encoding at the script-body layer. Per Security.md §XSS at output boundaries.
  • Body-position raw <script>/<style> author content is fail-loud, not silently escaped. A <script> or <style> element appearing in the body render-tree (the author's view hiccup, not the structured <head> channel or the trusted host shell) with a raw STRING child MUST raise :rf.error/ssr-raw-text-in-body rather than route the string through the text-node escape. There is no single correct escape for raw script/style content in the body position: JSON-LD needs <&lt; (round-trips through JSON.parse), but raw inline JS/CSS must NOT be <-escaped (a browser does not decode &lt;/< outside a JS string literal, so if (a < b) would break). Applying the text-node escape is XSS-safe but silently corrupts legitimate content; guessing per-content escapes risks an XSS or a broken script. The structured channels are reg-head (JSON-LD / structured head content, whose emitter applies the JSON-LD < escape) and the host shell's trusted :body-end / :head-extra opts (caller-trusted inline JS/CSS, not author data). An element-only or empty <script>/<style> (no raw string child) is inert and emits unchanged.
  • Attribute key escape, not just value. Attribute keys (not just values) MUST be escaped at the emitter so an attacker-controlled key cannot break out of the attribute namespace. The threat path: a registered view receives keyed data (a routing-param map, an MCP-resolved props bag, a deserialised hydration slot) and uses the key as an attribute name; under-escaping the key allows a key=" onload=alert(1) garbage=" shape to insert an event handler. The emitter MUST treat attribute keys as untrusted text by default — strings containing ", ', >, <, =, or whitespace surface as a structured emission error rather than as wire output. Per Security.md §XSS at output boundaries.
  • on* event-handler prop filter + reserved-prop-keys gate. SSR static-markup emission MUST strip on* event-handler props (:on-click, :on-mouse-down, …) and function-valued props at attribute-emit time, matching react-dom/server behaviour. The client-side substrate adapters (Reagent, UIx, Helix) wire event handlers at hydration; the server-side rendered string MUST NOT carry them inline. Additionally, the emitter MUST drop reserved prototype-pollution keys (__proto__, constructor, prototype) from the props map before they reach the underlying host's createElement-equivalent. Closes both the event-handler-injection vector and the prototype-pollution path on the client. Per Security.md §XSS at output boundaries.

The three escapes compose at the emitter walk: per-element attribute-key check → per-attribute prop-name filter (on* / reserved keys) → per-attribute value escape → per-text-node escape → per-script-body JSON-LD escape. The composition order is locked — relaxing any one position breaks the closed-set guarantee. Other-language ports MUST mirror all three rules against their host's render-tree shape; the rule numbers are the contract, not the CLJS function names.

Cross-reference: see Security.md §XSS at output boundaries for the framework-wide threat-model entry and rationale.

Source-coord annotation under SSR

Per Spec 006 §Source-coord annotation every substrate adapter MUST inject data-rf2-source-coord="<ns>:<sym>:<line>:<col>" on the rendered root DOM element of each registered view. The JVM SSR emitter mirrors that contract string-side: when emitting HTML for a registered view (the (registrar/lookup :view head) branch in emit-element), the emitter walks the view's hiccup output, merges :data-rf2-source-coord into the root element's attrs map, and then proceeds with HTML emission as usual.

The attribute value format is identical to the CLJS-side wrapper: <ns>:<sym>:<line>:<col>, derived from the registry id and the coords stamped onto the slot at reg-view macro-expansion time. The same documented exemption applies — fragments / non-DOM roots are skipped, and the JVM emitter resolves to the registry's :rf/id for those cases.

Exempt-keyword enumeration. The "non-DOM root" exemption is closed-set: a hiccup vector is exempt from :data-rf2-source-coord (and from the data-rf-render-hash root injection per §Hydration-mismatch detection) when its head keyword is one of:

Exempt head Meaning
:<> Fragment shorthand — no DOM element emitted; children are spliced into the parent.
:> Reagent-native interop head — children pass through to a React component, not a DOM tag. Not statically renderable server-side: the JVM emitter raises :rf.error/ssr-reagent-native-head — there is no React on the JVM, so the author must wrap the component in a reg-view (which the emitter resolves) or render it client-only. The exemption row remains for ports/substrates that can render a native head server-side.
:rf/suspense-boundary Streaming-only marker — recognised ONLY by the streaming shell walker (§Streaming SSR), which materialises a <template> fallback + registers a continuation for the subtree. Not renderable by the standard emitter: its name passes the [A-Za-z][A-Za-z0-9-]* tag grammar, so a misused marker that reaches render-to-string outside a stream would otherwise emit a phantom <suspense-boundary> DOM element with the {:id … :fallback …} attrs serialised as bogus attributes. The non-streaming emitter raises :rf.error/ssr-suspense-boundary-outside-stream (parallel to :>) so the misuse fails loud. Render trees containing :rf/suspense-boundary via stream-handler.

A hiccup vector whose head is a Var-ref ((fn? head) — fn-headed component), a registered-view keyword (resolved via (registrar/lookup :view head)), or a lazy-seq is passed through the injection — the attribute lands on the eventual DOM root once the indirection resolves. The exemption is per-call: it only skips the current level. A registered view that returns [:<> ...] resolves to the fragment, so the source-coord injection no-ops (consistent with the CLJS-side wrapper).

The same enumeration governs the data-rf-render-hash root-attrs injection: :<> and :> are the two skip-list heads; everything else either receives the injection directly or threads it through to the resolved root. Other-language ports MUST mirror the exemption against their substrate's analogous "no-DOM-element-emitted" heads — the contract is the closed-set above for the CLJS reference and (no-DOM-element-emitted) semantically for ports.

Production-elision differs from CLJS: the JVM has no goog.DEBUG constant-fold concept and no Closure DCE. The JVM half of the interop layer provides the runtime counterpart — re-frame.interop/debug-enabled? is a def read ONCE at ns-load from the system property -Dre-frame.debug=false or the env var RE_FRAME_DEBUG=false (false-y vocabulary: false, 0, no, off, empty string; system property wins on conflict). The annotation site is gated on the same interop/debug-enabled? as CLJS, so an SSR / long-running JVM that sets the flag at process startup gets equivalent suppression — a runtime short-circuit rather than compile-time DCE. The default is true (dev parity); SSR / webhook receivers / long-running JVMs facing untrusted input MUST set the gate false explicitly per 009 §JVM builds and Security §Production gates. Hosts that want finer-grained per-request control can still branch on the resolved frame's :ssr config (or a host-supplied flag) on top of the gate.

The :rf/hydrate event

Pattern-level standard event:

{:event       [:rf/hydrate hydration-payload]
 :frame       (active client frame)
 :source      :ssr-hydration}

The hydration-payload is the canonical :rf/hydration-payload shape (per Spec-Schemas). The reference handler is registered automatically by the runtime:

(rf/reg-event :rf/hydrate
  {:doc       "Install a coherent frame-state (app-db + serializable runtime-db) from the server-supplied payload."
   :platforms #{:client}}                                                  ;; hydration is client-side only
  (fn [_ [_ {:rf/keys [version frame-id app-db runtime-db render-hash schema-digest] :as payload}]]
    ;; Replace policy: server is authoritative for the INITIAL client frame-state.
    ;; The framework :rf/hydrate handler is framework-authority, so it may emit
    ;; the reserved :rf.db/runtime effect — it installs BOTH partitions in one
    ;; atomic frame-state transition (per [002 §Write authority is by convention]).
    {:db            app-db                                                  ;; app-db partition (server-authoritative)
     :rf.db/runtime (-> runtime-db                                         ;; serializable runtime-db projection
                        (assoc-in [:rf.runtime/ssr :hydration :server-hash] render-hash)
                        (cond-> version (assoc-in [:rf.runtime/ssr :hydration :version] version)))
     :fx [(when schema-digest
            [:rf.ssr/check-schema-digest schema-digest])
          [:rf.ssr/check-version version]]}))

Merge policy is :replace-frame-state. Server is authoritative for the initial client frame-state: the handler sets :db to the server's serialised app-db slice AND :rf.db/runtime to the server's serialized runtime-db projection (machine snapshots, route slice, elision declarations, SSR metadata), installing a coherent frame-state in one atomic transition — replacing whatever the client bootstrap had pre-seeded. This is locked. Hydration installs a frame-state, not just an app-db slice — the old "seed the frame's app-db" framing is superseded by the partition.

Only the SERIALIZABLE runtime-db projection rides the payload (Mike ruling #13). The payload carries the durable runtime-db facts needed to reconstitute the client (machine snapshots, route slice, elision declarations, the SSR hydration metadata). It MUST NOT carry transient runtime state — server-only request/response accumulators, head snapshots, streaming continuation registries, pending-error buffers, in-flight HTTP handles, or host handles (per 002 §Durable vs transient). The server-side allowlist projects app-db plus the serializable runtime-db projection; transient side channels are absent by default.

The payload is an untrusted transport input — the handler fails CLOSED on a malformed one. The payload is the server's pr-str'd EDN round-tripped through cljs.reader/read-string at the boot site; a truncated render, mid-stream corruption, or a hostile fragment can deliver a non-map payload or a non-map partition slice. Because the merge policy is :replace-frame-state, blindly installing a non-map slice would coerce corrupt input into a partition (a fail-OPEN). The handler therefore REJECTS a payload that is not a map, or whose app-db / runtime-db slice is present-but-not-a-map: the existing client frame-state is left unchanged, no compatibility-check fxs fire, and :rf.error/malformed-hydration-payload (per 009 §Error event catalogue) is emitted. Both partitions validate fail-closed before installation. A wholly-absent app-db or runtime-db slice is NOT malformed — it is the documented client-only first-load fallback. The boot helper's read-server-payload applies the symmetric guard: a payload script that does not parse as EDN fails closed to nil (client-only) rather than throwing through the mount.

The handler ALSO fails CLOSED on a frame-id mismatch (rf2-nv3mua). Beyond shape, the handler validates the payload's :rf/frame-id against the frame the dispatch is installing into (the :rf.frame/id coeffect). A present-and-different :rf/frame-id means the server's slice was rendered for a different frame, so installing it here would defeat the frame-isolation evidence the payload carries. The handler REJECTS it: the existing client frame-state (app-db AND runtime-db) is left unchanged, no compatibility-check fxs fire, and :rf.error/hydration-frame-id-mismatch is emitted on both the dev trace and the always-on error-emit axis (carrying :target-frame / :payload-frame-id). This is the same validation the boot helper hydrate! runs pre-dispatch (where it throws§Client-side hydration boot helper), enforced at the handler boundary so the direct-dispatch-sync split path (the post-mount-verify escape hatch the boot helper's docstring documents) cannot bypass it. An absent :rf/frame-id is no conflict — the dispatch target stands.

Server-hash slot at [:rf.runtime/ssr :hydration :server-hash]. As shown above, the reference handler writes the payload's :rf/render-hash value at [:rf.runtime/ssr :hydration :server-hash] in the installed runtime-db partition per Conventions §Reserved runtime-db keys (it is durable, serializable SSR metadata — a runtime-db fact, not transient side-channel state). This slot is the carrier verify-hydration! reads later (after the first client render) to compare against the client-side render-tree hash — see §Hydration-mismatch detection. The slot is a runtime-managed runtime-db path; user code MUST NOT write to it. The companion :version key under [:rf.runtime/ssr :hydration] carries the payload's :rf/version value when present (consumed by :rf.ssr/check-version). Implementations that override the reference :rf/hydrate handler with their own merge policy MUST preserve the [:rf.runtime/ssr :hydration :server-hash] write (or pass a :server-hash opt to verify-hydration! per the fn's docstring) — otherwise verify-hydration! has nothing to compare against and silently no-ops.

Off-box redaction (Mike ruling #14). runtime-db is redacted/omitted off-box by default — the hydration payload ships only the serializable runtime-db facts the client needs, and Xray / pair / epoch egress redact or omit runtime-db per projection policy (per Privacy §Rule summary and 009 §Privacy). Trusted-local tools may request richer diagnostics explicitly; the default fails closed.

If the user wants client-only transient state to survive hydration: the customisation point is re-registering :rf/hydrate with a custom handler that performs an explicit merge in the user's intended order. The default is replace; opt-in merge is the user's choice and they own the semantics.

Mismatch detection between server and client schemas runs as part of :rf/hydrate's :fx:

  • :rf.ssr/check-version compares the payload's :rf/version against the runtime's. A mismatch emits :rf.ssr/version-mismatch (a structured trace event) and the handler still applies (best-effort).
  • :rf.ssr/check-schema-digest (when the payload includes one) hashes the client's currently-registered app-schema set and compares to the server's digest. Mismatch emits :rf.ssr/schema-digest-mismatch. Useful for catching deploy drift where the server is rendering against a newer/older schema set than the client's bundle.

fx-input shape: each fx accepts either a scalar[:rf.ssr/check-version <server-value>] (the form the reference handler dispatches) — or an explicit map[:rf.ssr/check-version {:expected <server-value> :actual <client-value>}]. The scalar form treats the value as the server-supplied "expected" and looks up the client-side "actual" via a published hook (:rf2/runtime-version for version, :schemas/app-schemas-digest for schema-digest); when no hook is registered the fx emits :rf.ssr/compatibility-check-skipped (warning) and no-ops the comparison rather than crashing. Both fxs gate on :platforms #{:client} — server-side dispatches no-op via the standard fx-gating contract. The fxs NEVER throw — degraded-but-running is the locked posture.

The three compatibility-check trace categories — :rf.ssr/version-mismatch, :rf.ssr/schema-digest-mismatch, :rf.ssr/compatibility-check-skipped — are catalogued in 009 §Error event catalogue (the single source of truth for every :rf.ssr/* category, per Ownership).

Hash-based render-tree mismatch (a separate concern) lives in §Hydration-mismatch detection.

The payload shape is fixed; implementations may emit additive optional keys (per Spec-Schemas §:rf/hydration-payload) but never alter the required keys.

Client-side hydration boot helper

The server side ships ONE explicit handler-constructor (ssr-handler§HTTP response contract) that renders, builds the __rf_payload <script>, and writes the wire response. The client side ships its symmetric counterpart — ONE explicit boot call that reads __rf_payload, dispatches :rf/hydrate, and verifies. The pair reads as one contract; a host should not have to re-derive the read → dispatch → render → verify ordering by hand at every boot site.

The CLJS reference ships it as re-frame.ssr/hydrate! (re-exported from the façade; implemented in re-frame.ssr.boot):

#?(:cljs
   (defn ^:export run []
     (rf/init! reagent-adapter/adapter)
     ;; hydrate! seeds app-db AND verifies synchronously (it computes the
     ;; client render-tree itself via :render-tree-fn — see step 3) BEFORE
     ;; the host mounts. Then the host renders that same tree. `:frame` is
     ;; required (EP-0002): the hydration target is carried — the same frame
     ;; flows to hydrate! and the root frame-provider-existing (the SCOPE-only
     ;; member — the hydration target frame already exists, so the root scopes
     ;; it into the React tree rather than owning a new lifetime; per EP-0024).
     (let [payload (ssr/hydrate! {:frame          :app/main
                                  :render-tree-fn #((rf/view :app/root))})]
       (rdc/render react-root
         [rf/frame-provider-existing {:frame :app/main}
          [(rf/view :app/root)]])
       payload)))

hydrate! performs the three steps in the order the §Client flow mandates:

  1. READ — the payload. Supplied explicitly via :payload, or read from the DOM's __rf_payload <script> (via read-server-payload, which reads the id pinned in the CLJS reference's re-frame.ssr.constants/payload-script-id — the same id the host shell stamps) when :payload is omitted. Returns nil on a client-only first load (no payload script) — the host renders against the empty app-db.
  2. HYDRATEdispatch-sync [:rf/hydrate payload] against the target frame BEFORE the first render, so the frame's frame-state (app-db + serializable runtime-db) is the server's authoritative slice when the view first evaluates (the locked :replace-frame-state policy above). :frame is required (EP-0002): the client hydration target is carried — the host passes the same frame to hydrate!, the root provider, streaming install!, resource preload, and Xray. An absent :frame raises :rf.error/no-frame-context; the runtime never synthesises :rf/default. The payload's :rf/frame-id (below) is validated against this explicit target — a conflict raises :rf.error/hydration-frame-id-mismatch rather than silently picking a side.
  3. VERIFYverify-hydration! compares the client render-tree hash against the server hash stashed at [:rf.runtime/ssr :hydration :server-hash] (see §Hydration-mismatch detection). The verify step takes a :render-tree-fn — a 0-arity fn returning the client render-tree to hash (typically #((rf/view :app/root))). hydrate! calls it synchronously, immediately after :rf/hydrate and before the host's own render. This is sound because a re-frame2 view is a pure function of app-db: evaluating (rf/view :app/root) against the just-hydrated app-db yields the same render-tree the host is about to mount, so hashing it pre-mount is equivalent to hashing the mounted tree — without a post-render callback the helper cannot observe (it does not own the DOM mount). hydrate! is therefore a seed-and-synchronously-compute-tree convenience, not a true post-mount verifier; :render-tree-fn is a pure client-tree computation, not a "read back what was mounted" hook. Omit :render-tree-fn to skip verification (the host opts out of hash-mismatch detection, or runs verify-hydration! itself at its own render site).

hydrate! returns the applied payload (or nil) so the caller can branch on "was this server-rendered?" without re-reading the DOM. It is the convenience that fuses the common ordering. The synchronous-compute model fits any host whose view tree is a pure projection of app-db (the re-frame2 norm — Reagent / UIx / Helix all qualify). A host that genuinely must observe the mounted DOM tree (e.g. a substrate that mutates the tree at mount time, or an async mount where the render-tree is not yet computable when hydrate! returns) splits the convenience: call dispatch-sync [:rf/hydrate …] to seed, mount, then call verify-hydration! at the post-mount site with the observed tree. read-server-payload is CLJS-only (it reaches into the DOM); hydrate! is platform-neutral so a JVM test harness can drive the server-build-payloadhydrate! → post-hydrate-sub round-trip on a :client-platform frame without a browser.

Hydration-mismatch detection

After the first client render, a comparison pass:

  • Server emits the rendered string AND a structural marker (a hash of the render-tree, computed before stringification) on the root element. Placement: a data-rf-render-hash="<hex-string>" attribute on the root view's outermost element. Encoding: lowercase hex of the raw hash bytes; no prefix.
  • Injection point — structural injection on the hiccup root. When render-to-string is called with :emit-hash? true, the attribute is injected structurally on the first DOM-tag element of the hiccup tree before stringification — not as a post-emit regex pass on the output string. The implementation threads a root-attrs map down through the hiccup walk: view-refs ((registrar/lookup :view head)), fn-headed components ((fn? head)), and the resolved bodies they return all pass root-attrs through to the eventual DOM root; the first DOM-tag emission merges and consumes it. Existing user-supplied data-rf-render-hash on the root wins (the merge is non-overwriting). The exempt-keyword enumeration above (§Source-coord annotation — :<> and :>) governs the skip-list: a hiccup vector whose head is one of those fragment / Reagent-native heads drops root-attrs rather than emitting a wrapper DOM element, matching the source-coord exemption. The two opts :doctype? and :emit-hash? compose: :doctype? prepends <!DOCTYPE html> to the already-injected body string. Non-DOM-rooted trees silently no-op on the injection — the structural mechanism cannot over-inject onto a doctype or text node the way a string-level regex would. The on-wire shape (data-rf-render-hash="<hex>" on the outermost rendered DOM element) is the contract; other-language ports MUST mirror the structural-walk semantics against their substrate's hiccup-equivalent tree.1

Hash algorithm (CLJS reference): the FNV-1a 32-bit hash over a canonical EDN serialisation of the render-tree (depth-first traversal; attribute maps in sorted-key order; nil pruned). FNV-1a is chosen because it is fast, has no platform dependencies (no crypto/SubtleCrypto), and produces an 8-character hex string that fits comfortably in a <meta> / data- attribute. Cryptographic strength is not needed — the hash is a tamper-evident structural marker, not a security primitive.

Other-language ports may pick a different hash as long as the canonical-EDN traversal is the same; the hash value crosses the wire only between one server and one client of the same implementation, so the algorithm choice is a per-host commitment rather than a pattern-level one. The traversal IS pattern-level: render-tree shape, sorted attribute keys, nil pruning. Conformance fixtures pin the canonical-traversal output for a small render-tree corpus so a port can verify its serialiser. - On mismatch, the runtime emits a trace event:

{:id           (gensym)
 :operation    :rf.ssr/hydration-mismatch
 :op-type      :error
 :tags         {:server-hash "abc123"
                :client-hash "def456"
                :frame       :app/main             ;; the app's carried frame-id (placeholder; the runtime stamps the active frame)
                :failing-id  :rf/hydrate           ;; the only value the bundled v1 runtime emits (see §Mismatch detection — head)
                :first-diff-path [...]}            ;; optional: path into the render tree where divergence first occurs
 :start        (...)
 :end          (...)}

The :failing-id tag is a generic host-supplied attribution seam, not a runtime-toggled enum. The mismatch-detection entry point (verify-hydration!) accepts a host-supplied :failing-id override and, when none is supplied, defaults it to :rf/hydrate. The bundled v1 runtime never supplies an override, so under one :operation it emits exactly one shape: :failing-id :rf/hydrate. A host that runs its own head-only diffing MAY attribute a mismatch with any value of its choosing — e.g. :rf.ssr/head-mismatch (per §Mismatch detection — head) — and that value flows through to the trace; the v1 runtime itself does not. Consumers branch on :failing-id rather than maintaining a parallel category keyword per case, and the seam keeps that branch open for hosts (and the post-v1 head-only-hash channel) without a runtime change.

Recovery is implementation-policy: the CLJS reference defaults to warn-and-replace — log the trace event, then re-render client-side, replacing the server's HTML. Strict mode (the frame's :ssr {:on-mismatch :hard-error} config — see §Mismatch recovery and configuration) escalates to a hard error for dev/CI builds that want to fail fast on misalignment.

Server-only reg-cofx for request context

A standard cofx for accessing the current request:

(rf/reg-cofx :rf.server/request
  {:doc       "The active HTTP request. Server only."
   :platforms #{:server}}
  (fn [] *current-request*))                  ;; value-returning supplier (EP-0017)

Setup events take delivery by declaring {:rf.cofx/requires [:rf.server/request]} on their registration metadata; the request map then arrives flat under :rf.server/request in the handler's coeffects so it can read the URL, headers, session, etc. The :platforms metadata mirrors reg-fx.

:rf.server/request is an ambient coeffect (the default EP-0017 grade): its supplier reads the per-frame request slot at context assembly, the value is never recorded, and replay re-runs the supplier. Per EP-0017 §1 the ambient grade is legal only where no durable write depends on the value — so :rf.server/request is for non-durable request reads (branching on :request-method, reading a header for a decision that does not fold into durable app-db / runtime-db). A handler that folds a request-derived fact into durable state through this ambient read is the SSR analogue of the ambient-localStorage replay hole: replay re-runs the live supplier instead of re-presenting the value the recorded run actually folded (and reads nil after the per-request frame's slot is cleared). Durable request-derived facts use the boundary pattern below.

Durable request-derived facts

When a setup handler writes durable state from the request — auth user / session state read from a cookie folded into the hydration payload, an accept-language folded into a locale slice — the value MUST arrive as a recordable fact so the causal token carries it and replay re-presents it verbatim (002-Frames §The recordable-coeffect rule). The host adapter sanitizes the request at the boundary and supplies the derived projection (never the whole request map) by one of two slice-A-legal shapes:

  • Event payload. The host dispatches the setup event WITH the derived fact: [:auth/server-init {:user (extract-user request)}]. The fact rides :event, recorded as part of the dispatch.
  • Provided recordable :rf.cofx leaf. The app registers an owner-qualified {:recordable? true :provided? true} coeffect (e.g. :auth.session/user) and the host adapter STAMPS the sanitized value onto the boot dispatch token ({:rf.cofx {:auth.session/user …}}). The handler declares :rf.cofx/requires [:auth.session/user]; a record missing it fails loudly with :rf.error/missing-required-cofx rather than silently re-reading the host.

The whole request map MUST NOT ride the causal token — it carries Cookie / Authorization / raw bodies (secrets / PII) and is a host handle (recording a secret makes it durable, not safe). Stamp only the sanitized derived projection.

Request storage substrate

The :rf.server/request cofx surfaces host-controlled wire-shape input (Ring request map, Pedestal context, raw-HTTP request, edge-runtime request, etc.) into the handler's :coeffects. The storage substrate for the active request map is normative — getting this wrong has direct privacy consequences.

  • MUST NOT ride app-db. The active request map MUST NOT be stored under any key in app-db — neither the request frame's nor any other frame's. app-db is the hydration payload's source (§Payload scope, :rf/app-db): every value in it is a candidate to ship to the client on bootstrap. Request maps routinely carry Host, Cookie, Authorization, X-Forwarded-For, raw bodies, and other secrets-or-PII whose leakage to the client is a security incident. The hydration boundary must remain shippable-without-redaction; storing the request in app-db violates that invariant.
  • MUST use a framework-private side-channel keyed on frame-id. The substrate is per-frame so two simultaneous per-request frames (the canonical SSR shape under concurrent load) carry independent request slots that cannot bleed into each other. The slot is framework-private — not a public app-db key, not a registered cofx-able value — read exclusively by the runtime's :rf.server/request cofx and written exclusively by the host adapter. The CLJS reference uses a defonce atom keyed by frame-id (mirroring pending-error-traces); a JVM-only port may equivalently use a ConcurrentHashMap; any other-language port chooses its own concurrent-map shape. The contract is what's pinned — not the data structure: per-frame isolation, framework-private access, and exclusion from app-db.
  • Host adapter MUST populate and clear the slot. The host adapter (the bundled Ring adapter; future Pedestal / raw-HTTP / edge-runtime adapters) MUST set the slot before kicking off the drain and MUST clear it after the response is materialised. The CLJS reference exposes re-frame.ssr/set-request! / clear-request! as the host-adapter surface; ports name their equivalents per host conventions. Failure to clear after the response is built constitutes a memory leak across request lifetimes; failure to set before the drain causes a handler declaring {:rf.cofx/requires [:rf.server/request]} to resolve nil.
  • Per-frame isolation under load. Two concurrent per-request frames MUST observe their own request maps and only their own. The substrate MUST NOT use a single dynamic Var / thread-local / module-level binding that could bleed across frames sharing a thread (e.g., async drain steps, ForkJoin work-stealing). Frame-id keying is the canonical implementation; any equivalent isolation primitive that preserves per-frame separation under concurrent drains satisfies the contract.

The :platforms #{:server} gate on the cofx itself is orthogonal to substrate choice — it ensures client-side dispatches no-op via :rf.cofx/skipped-on-platform per the standard cofx-gating contract. Substrate isolation is the privacy guarantee; the platform gate is the dispatch guarantee.

HTTP response contract

SSR is not just HTML + state — it is a full HTTP response. The runtime owns a per-request response accumulator keyed on the request frame's frame-id; the canonical shape is registered as :rf/response in Spec-Schemas. Standard server-only fx populate the slot during the drain; the host adapter consumes the resolved value (via re-frame.ssr/get-response) to build the wire response.

The accumulator's default shape:

{:status   200                                          ;; default if no fx sets one
 :headers  {"content-type" "text/html; charset=utf-8"}  ;; default content-type for HTML
 :cookies  []
 :redirect nil}

Response storage substrate

The response accumulator's storage substrate is normative for the same reasons as the request slot's (§Request storage substrate) — getting it wrong has direct privacy and performance consequences.

  • MUST NOT ride app-db. The response accumulator MUST NOT be stored under any key in app-db — neither the request frame's nor any other frame's. app-db is the hydration payload's source (§Payload scope, :rf/app-db): every value in it is a candidate to ship to the client on bootstrap. The response accumulator routinely carries server-only data — Set-Cookie headers (auth tokens, session ids), internal X-* headers, redirect URLs that may encode internal hostnames — whose leakage to the client is a security incident. The hydration boundary must remain shippable-without-redaction; storing the accumulator in app-db would default-leak that surface onto the wire and force every host adapter to remember a defensive (dissoc :rf/response) before serialising the payload. A privacy boundary that's a constant caller-vigilance burden is a leak waiting to happen; side-channel storage makes the boundary self-enforcing.
  • MUST use a framework-private side-channel keyed on frame-id. The substrate is per-frame so two simultaneous per-request frames (the canonical SSR shape under concurrent load) carry independent accumulators that cannot bleed into each other. The slot is framework-private — not a public app-db key, not a registered subscription — read exclusively by the runtime (via re-frame.ssr/get-response) and written exclusively by the seven :rf.server/* fxs and the projector's status-stamp. The CLJS reference uses a defonce atom keyed by frame-id (mirroring request-slots and pending-error-traces); a JVM-only port may equivalently use a ConcurrentHashMap; any other-language port chooses its own concurrent-map shape. The contract is what's pinned — not the data structure: per-frame isolation, framework-private access, and exclusion from app-db.
  • Per-fx writes MUST be O(small-map). A naïve implementation that stored the accumulator in app-db paid a full app-db replacement on every :rf.server/* fx (read-container → assoc → replace-container!); for a 7-fx response shape (typical login flow: set-status + 2× set-cookie + 3× set-header + redirect), that's seven full-app-db replacements per request. The side-channel substrate's swap is O(small-map): one atom CAS against a {frame-id → response-map} table. The contract is the algorithmic class — per-fx response writes scale with the response shape, not with app-db size.
  • Runtime MUST clear the slot on frame teardown. Per §Per-request frame teardown contract the slot MUST be released when the request frame is destroyed. The CLJS reference clears via the :ssr/on-frame-destroyed late-bind hook — the same hook that drops the request slot and the pending-error-trace buffer.

Standard fx

All seven are :platforms #{:server} — registered, schema-validated, and silently no-op'd by :rf.fx/skipped-on-platform if dispatched client-side.

Fx Args Notes
:rf.server/set-status <int> (e.g., 404) Set the response status code. Last-write-wins (per §Multiple-status policy below).
:rf.server/set-header {:name "X-Foo" :value "bar"} Replaces an existing header (case-insensitive name match). Wire format is host-adapter business.
:rf.server/append-header {:name "X-Foo" :value "bar"} Appends another instance — required for Set-Cookie-style multi-valued headers. Deduplication is host-adapter policy.
:rf.server/set-cookie a :rf.server/cookie map (:name, :value, :max-age, :secure, :http-only, :same-site, :path, :domain, :expires) Adds a structured cookie to :cookies; the host adapter serialises to wire form (avoids cookie-attribute quoting bugs).
:rf.server/delete-cookie {:name "session" :path "/"} Adds a delete-cookie marker (set-cookie with :max-age 0); semantics are host-adapter business.
:rf.server/redirect {:status 302 :location "/login"} (:status defaults to 302) Sets :redirect on the accumulator and short-circuits HTML rendering (per §Redirect precedence below). The redirect target is keyed under :location — the fx writes an HTTP Location response header, so it uses header vocabulary (one name per fact, EP-0007; routing/navigation surfaces may use :url / :to). The retired :url / :to spellings are rejected with :rf.error/redirect-retired-target-key naming :location — no back-compat alias. Caller-trusted :location — accepts arbitrary URL strings without allowlist or relative-only gating. For caller-untrusted strings (e.g. a ?next= query param), use :rf.server/safe-redirect (below).
:rf.server/safe-redirect {:location "/dashboard" :relative-only? true} or {:location "https://app.example.com/..." :allow ["app.example.com" "alt.example.com"]} Validates :location before populating :redirect. Validation order (per 009 §Error event catalogue): (1) URL must parse — :rf.error/safe-redirect-invalid-url on failure; (2) reject javascript: / data: / vbscript: schemes — :rf.error/safe-redirect-scheme-rejected; (3) :relative-only? true and the URL has a host — :rf.error/safe-redirect-host-disallowed (:reason :relative-only-violation); (4) :allow [...] allowlist mismatch — :rf.error/safe-redirect-host-disallowed (:reason :not-in-allowlist); (5) on pass, sets :redirect (same shape as :rf.server/redirect). Mitigation for the open-redirect class — an attacker-controlled ?next=… URL parameter cannot redirect off-origin.

The fx-args schemas (:rf.fx.server/set-status-args, etc.) are registered per Spec-Schemas §Standard fx args schemas. Args validation runs as part of the standard :schema boundary check (per 010 §Validation timing).

CRLF fail-fast on header values

Header values cross the HTTP wire boundary as text lines terminated by CRLF. A \r or \n embedded inside a header value would split the header into adjacent header lines on the wire — a response-splitting attack (injection of an additional header, or even a second response body). The framework's policy is fail-fast at fx-handler time, no strip-and-warn: silent normalisation masks bugs and lets through downstream-encoded attack vectors.

Normative contract.

  • :rf.server/set-header and :rf.server/append-header MUST reject CRLF in :value. A :value string containing \r or \n throws the fx with :rf.error/header-invalid-value and the rejecting fx-id in :tags. No fall-through; no strip-and-pass; no normalised reissue. Per Security.md §CRLF injection at HTTP-response boundaries.
  • :rf.server/redirect MUST reject CRLF / NUL in :location. The Location: header is a header value subject to the same CRLF check; a \r, \n, or NUL in the redirect target surfaces :rf.error/redirect-invalid-location. This is the only gate on the caller-trusted path: the header-splitting invariant (RFC 7230 §3.2.4) is enforced, but no structural URL-shape check is applied — :rf.server/redirect is caller-trusted, so a raw space or other RFC 3986 shape quirk that every browser accepts in a Location header is passed through. The fail-fast CRLF/NUL posture applies whether :location came from a constant, a route binding, or a ?next= query parameter forwarded through :rf.server/redirect. (URL-shape and origin validation belong to :rf.server/safe-redirect above — caller-untrusted strings should route through it for open-redirect mitigation; the CRLF/NUL check applies to both.)
  • :rf.server/set-cookie MUST CRLF-check every attribute, not just :value. Set-Cookie's attribute fields (:name, :value, :max-age, :same-site, :path, :domain, :expires) are individually checked before the host adapter serialises the cookie line. Apps frequently build cookies from host-data values (a user-id flowing into :value, a partner-supplied tenant string into :domain, an arbitrary :path from request context); an attacker who controls any one of those values must not be able to re-enter the header line as CRLF-bearing payload. Per Security.md §CRLF injection at HTTP-response boundaries.

The error categories :rf.error/header-invalid-value and :rf.error/redirect-invalid-location are catalogued in 009 §Error event catalogue. The fail-fast posture composes cleanly with the structured-fx-args schemas above — args validation surfaces the bug at the dispatch site, not at the wire boundary.

Cross-reference: see Security.md §CRLF injection at HTTP-response boundaries for the framework-wide threat-model entry and the rationale for fail-fast over strip-and-warn.

Request-handler return shape

After drain settles, the runtime returns the structured request result to the host adapter:

{:html     "<!doctype html>..."   ;; absent when :redirect is set
 :payload  hydration-payload      ;; the :rf/hydration-payload (per Spec-Schemas)
 :response response-map}          ;; the resolved :rf/response

The host adapter is responsible for materialising :response into the wire format its server framework expects (Ring map, Express response, Fastify reply, etc.). The runtime never writes to a network socket directly — the response shape is the contract; transport is the host's concern.

Redirect precedence

Lock: redirect truncates HTML. If :rf.server/redirect fires anywhere in the drain (an :initial-events setup step, an :on-match route handler, a downstream cascade), the runtime:

  1. Sets :redirect {:status N :location "..."} on the accumulator.
  2. Skips the HTML render step entirely — :html is absent from the request result.
  3. Skips the hydration-payload serialisation — :payload is also absent (no client to hydrate).
  4. The host adapter sees {:response {:redirect {:status 302 :location "/login"} ...}} and emits a status-and-Location-header response with no body.

Multiple redirects: last-write-wins on the :redirect slot itself, with a :rf.warning/multiple-redirects trace (same shape as the multiple-status warning below).

Multiple-status policy

Lock: last-write-wins, with a structured warning. If two handlers in the drain both emit :rf.server/set-status, the runtime:

  1. Records each write to the :status slot.
  2. After drain, if more than one distinct write occurred, emits a :rf.warning/multiple-status-set trace event (per 009 §Error event catalogue).
  3. The final :status is the last write — same default :db-style semantics re-frame uses elsewhere.

The warning is advisory: production response is the last-write value. Tools (10x, error monitors) surface the warning so authors can find the conflicting handlers.

Header replacement vs append

Lock: :rf.server/set-header replaces; :rf.server/append-header adds another instance. Both are documented; choosing the wrong one is a contract bug, not a runtime error.

The runtime stores headers internally as an ordered vector of [name value] pairs (case-insensitive name match). On serialisation, the host adapter chooses how to wire the multi-valued case (most frameworks accept [name value] arrays; Ring uses string-or-vector values).

Deduplication is host-adapter policy. The runtime does not deduplicate — if user code emits two :rf.server/append-header calls with the same value, both go on the wire. The adapter may collapse them or pass them through.

Lock: structured maps, not raw header strings. The :rf.server/cookie schema (registered in Spec-Schemas) names the canonical attributes:

{:name      "session"
 :value     "abc123"
 :max-age   3600
 :secure    true
 :http-only true
 :same-site :lax              ;; one of :strict :lax :none
 :path      "/"
 :domain    "example.com"
 :expires   <int-ms>          ;; optional; either :max-age or :expires (or neither)
 }

The host adapter serialises this to a Set-Cookie: header value per RFC 6265. User code never builds the wire string. This intentionally avoids the per-attribute quoting / encoding bugs that plague raw-string cookie APIs.

:rf.server/delete-cookie is sugar over :rf.server/set-cookie with :max-age 0 and an empty :value.

Status defaults

Lock:

  • Default status: 200 when no handler emits :rf.server/set-status.
  • Default content-type: text/html; charset=utf-8 for HTML responses (set on the accumulator at request start; user fx can replace it).
  • Error pages. When the projector (§Server error projection below) maps a server-side exception to a public error, the runtime emits the public-error's :status (typically 500) and a default text/html; charset=utf-8 content-type unless user code has set a different one.

These defaults are the runtime's; user fx can override any of them. The runtime never interferes with explicit user-supplied values.

Trusted shell hook contract

The host adapter's default HTML envelope (CLJS reference: re-frame.ssr.ring/default-html-shell for non-streaming, default-streaming-prefix + default-streaming-suffix for streaming SSR) exposes four convenience opts the handler-constructor surface accepts as caller-trusted strings. They split by injection position:

  • Content-position opts (:head, :body-end) are injected RAW into free-form HTML content positions, with no escaping, no validation, and no sandbox. Free-form HTML content has no single-correct escape, so the framework cannot escape here without breaking the legitimate use (injecting a <script> analytics tag). These are the parallel of the caller-trusted :rf.server/redirect surface (§Standard fx above + Security.md §Open-redirect mitigation): the framework's contract is that the caller composes them from caller-controlled data at handler-construction time (app boot decides what analytics tag / doctype-extension / head fragment to inject); the framework names the trust boundary; the content trust itself remains the caller's.
  • Attribute-value-position opts (:script-src, :app-element-id) land inside double-quoted HTML attribute values (<script src="..."> / <div id="...">). An attribute-value position HAS a single correct escape, so the framework escape-attr-escapes them at the shell — escaping & + " is lossless and position-correct. This is structural-correctness, not a sandbox: it stops a stray " in an otherwise-benign id / URL from breaking out of the attribute and emitting malformed markup. The values are still caller-supplied configuration; the escaping does not make them safe carriers for untrusted content (a fully attacker-controlled URL can still point at a malicious origin), but it removes the structural footgun for the trusted-but-quote-bearing case.

The four opts:

Opt Wire position Injection
:head Verbatim HTML inside <head>...</head> (overrides the route-resolved head fragment when supplied as a string) RAW (content position). Caller-trusted string. The route-driven reg-head path (§Head/meta contract below) is the structured alternative — head models are derived from app-db through registered fns, and the SSR emitter applies position-appropriate escaping at every leaf. The :head string opt is the escape hatch for bespoke fragments the caller composes from trusted data.
:body-end Verbatim HTML before </body> RAW (content position). Caller-trusted string. The escape hatch for analytics / third-party scripts / chat widgets the app boot decides to inject.
:script-src Written into <script src=\"...\"></script> (the client-side bootstrap script URL) escape-attr-escaped (attribute-value position). Caller-trusted string. Default: the host's bundled bootstrap entry point (e.g. \"/main.js\").
:app-element-id Written into <div id=\"...\"> wrapping the rendered body escape-attr-escaped (attribute-value position). Caller-trusted string. Default: \"app\". The client-side hydrator reads this element by id; changes here must be matched on the client.

Normative contract.

  • The content-position opts (:head, :body-end) are injected RAW; the attribute-value-position opts (:script-src, :app-element-id) are escape-attr-escaped. The framework MUST accept all four as strings (or nil — meaning "no override, use the default"). For :head / :body-end the shell MUST inject the string verbatim — no framework-level escaping, the content trust is the caller's; apps that wire either from untrusted input (a CMS field, a tenant-admin form, a query-string parameter, a partner-supplied configuration blob) accept an arbitrary-script-injection XSS vector — the framework will not gate the content. For :script-src / :app-element-id the shell MUST run the value through attribute-value escaping (&&amp;, "&quot;) so a quote-bearing-but-otherwise-trusted value cannot break out of the attribute and emit structurally-broken markup; the escape is lossless and position-correct.
  • Construction-time structural-shape validation. The framework MUST validate at handler-construction time that each of the four, if supplied, is a string (or nil). Non-string non-nil values (a map, a vector, a symbol, a number) surface :rf.error/ssr-trusted-shell-opt-invalid at boot — the structural mistake is caught before the first request rather than as a ClassCastException deep in the rendering path. The error's ex-data carries :opt-key (the offending opt's keyword), :got (the rejected value), :got-type (its type), and :recovery :supply-string-or-nil. The check is contains?-aware so absent opts pass; nil opts pass.
  • Documented structured alternative for untrusted-customization use cases. Apps offering admin- or tenant-configurable shell customization (a "customize site head" admin form; per-tenant analytics blocks driven by tenant settings; user-driven body-end widget configuration) MUST NOT wire the raw input through these four opts. The structured alternative is:
  • reg-head for head fragments. The head/meta registry (§Head/meta contract below) carries {:title :meta :link :script} shape; the SSR emitter renders the structured head through the standard hiccup → HTML walker, which applies position-appropriate escaping (text-node, attribute-value, raw-script-body — per §XSS at output boundaries) at every leaf. An admin-editable title / meta-description / OG-tag flows through structured data through reg-head — never as a raw :head string.
  • reg-view* + :rf.server/* fx for body content. Body content the admin / tenant customizes belongs in a registered view that takes the customization as a sub'd value off app-db. The view's hiccup goes through the same SSR emitter — string content lands as text-node children (HTML-escaped); attribute values land as escaped attribute pairs; the trust boundary is the schema-validated app-db slice, not the rendered string.
  • :rf.server/set-header for header-shaped customization. Custom response headers go through the structured fx — CRLF-checked, schema-validated.
  • No content-shape validation on the trusted-string slot. The framework MUST NOT add a "looks like HTML" / "looks like a URL" / "looks like an identifier" content check to any of the four. Such a check would be a leaky abstraction (it would either accept some XSS vectors as "valid-looking HTML" or reject some legitimate content) AND it would invite false-confidence wiring of untrusted input through the slot. The structural shape check (string vs not-a-string) is the entire framework gate; content trust is upstream. (The escape-attr pass on :script-src / :app-element-id is position-correct attribute encoding, not a content-shape validation — it is lossless, gates nothing, and rejects no value; it merely encodes the two attribute-value-position opts correctly for the position they land in.)

The contract composes with the runtime production gate (Security.md §Production gates) and the SSR side-channel response substrate (§Response storage substrate): trusted-shell opts ride the construction-time handler-opts map (per-deployment, not per-request); the structured alternatives ride per-request data through app-db; the privacy boundary between response state and hydration payload is enforced by the side-channel substrate. Three layered boundaries, each with one job.

Cross-reference: see Security.md §Trusted shell hook contract for the security-posture entry that names this surface among the framework's named trust boundaries.

Head/meta contract

Status: shipped. The reg-head / render-head / active-head surface described in this section ships in the day8/re-frame2-ssr artefact (re-frame.ssr.head). The hash-on-wire path (per §Hydration-mismatch detection) covers head as part of the unified render-tree hash, so the bundled v1 runtime cannot tell a head-only divergence from a body-only one — it emits a single :failing-id :rf/hydrate on any mismatch. The :rf.ssr/head-mismatch value is host-suppliable now through the generic :failing-id seam (a host doing its own head-diffing may attribute it) but is not emitted by the bundled v1 runtime. A dedicated data-rf-head-hash payload key + wire attribute remains reserved for a post-v1 head-only-hash extension that would let the runtime itself emit it.

The server-rendered HTML must carry head metadata — <title>, <meta>, <link>, JSON-LD — on first byte, because crawlers and link-unfurlers don't run JS. The pattern's commitment: the head model is data derived from app-db, not an imperative DOM API.

The standard head model (registered as :rf/head-model in Spec-Schemas):

{:title       "Article: re-frame2 SSR"
 :meta        [{:name "description" :content "..."}
               {:property "og:title" :content "..."}
               {:property "og:image" :content "https://..."}]
 :link        [{:rel "canonical" :href "https://example.com/articles/123"}]
 :script      [{:src "https://..." :async true}]
 :json-ld     [{"@context" "https://schema.org" "@type" "Article" ...}]
 :html-attrs  {:lang "en"}
 :body-attrs  {:class "page-article"}}

Mechanism — registered head function + route metadata

Lock: head logic is registered with reg-head; routes name which head to use via :head route metadata.

(rf/reg-head :head/article
  {:doc "Article-page head model — derives title/meta/og from the article."}
  (fn [db {:keys [params] :as route}]
    (let [{:keys [title summary image]} (get-in db [:articles (:id params)])]
      {:title  (str title " — Example")
       :meta   [{:name "description" :content summary}
                {:property "og:title" :content title}
                {:property "og:image" :content image}]
       :link   [{:rel "canonical" :href (route-url :route/article params)}]
       :json-ld [{"@context" "https://schema.org"
                  "@type"    "Article"
                  "headline" title}]})))

(rf/reg-route :route/article
  {:path "/articles/:id"
   :head :head/article})                                                  ;; route declares which head to use

reg-head adds a new registry kind :head (per 001 §Registry model). The query API surfaces it: (rf/registrations :head) returns id → metadata; tools can enumerate registered heads.

reg-head returns its id argument per the family-wide reg-* return-value convention.

The function signature is (fn [db route] head-model) — pure, deterministic, no side-effects. Same shape and discipline as a sub. Subscriptions inside head functions are evaluated against the static app-db value (same path as views; per compute-sub).

Default flow

  1. SSR request renders the body view.
  2. The runtime resolves the active route's :head metadata → a registered head id.
  3. (rf/render-head head-id {:frame frame-id}) (or equivalently (compute-head head-id db route)) returns the head model.
  4. The runtime emits <head>...</head> from the model, in canonical order: <title> first, then <meta> in declaration order, then <link>, then <script>, then JSON-LD <script type="application/ld+json">. :html-attrs populate <html>; :body-attrs populate <body>.
  5. Client on hydration recomputes the head model from the now-seeded state (the hydrated payload's :rf/app-db plus the route slice carried in :rf/runtime-db at [:rf.runtime/routing :current]).
  6. Mismatch detection: in v1 the head rides the unified :rf/render-hash channel (it is part of the hashed render-tree), so the client cannot tell a head-only divergence from a body-only one — any disagreement re-renders client-side and emits :rf.ssr/hydration-mismatch with the runtime's only value, :failing-id :rf/hydrate. The server head is replaced (consistency with body-mismatch handling). A host doing its own separate head diffing may attribute the mismatch as :rf.ssr/head-mismatch through the generic :failing-id seam, but the bundled v1 runtime does not.

Head-mismatch detection in v1 piggy-backs on the unified :rf/render-hash channel: the server-rendered HTML carries a single structural hash that covers both head and body; the runtime emits one :rf.ssr/hydration-mismatch trace on disagreement, with its only value :failing-id :rf/hydrate (it cannot discriminate head from body on a single hash). The :failing-id tag is a generic host-attribution seam (per §Hydration-mismatch detection), so a host running its own head diffing may attribute the mismatch as :rf.ssr/head-mismatch; the bundled v1 runtime does not. A dedicated :rf/head model on the wire (and its companion :rf/head-hash) is reserved for a post-v1 reg-head payload extension — when reg-head lands as a non-deferred surface those keys will be defined under :rf/hydration-payload-postv1 in Spec-Schemas, and a head-only hash would then let the runtime itself emit :rf.ssr/head-mismatch. The v1 client recomputes the head from the hydrated :rf/app-db (plus the route slice carried in :rf/runtime-db); SPAs that route post-load see the same graceful path because no separate head channel is required.

render-head

(rf/render-head head-id
  {:frame :app/main                              ;; required (EP-0002 — carried, not defaulted)
   :route active-route})                                 ;; optional; defaults to (subscribe [:route])

Returns the head model map. Pure, JVM-runnable, used by the SSR pipeline to materialise <head>...</head> and by tooling to inspect the active head without re-rendering the body. Head rendering is a frame-scoped read (it reads the frame's app-db + the runtime-db route slice), so the frame is carried — supplied explicitly. An absent :frame raises :rf.error/no-frame-context (EP-0002); the pre-EP :rf/default-from-absence floor is removed.

(rf/active-head frame-id) is sugar — looks up the active route's :head for the given frame, calls render-head, returns the model. Useful in dev tools and the SSR pipeline (called inside the request frame's with-frame block with the explicit request frame-id). The frame is required; there is no no-arg (active-head) form (EP-0002 — it synthesised :rf/default).

Single :head per route in v1

Lock: one registered :head per route. No composition (parent + child route head fragments) in v1 — that's a follow-up if real cases emerge. Routes that want to share head logic do so by referencing the same registered :head id, or by registering a head fn that calls a helper.

Mismatch detection — head

Same shape as body-mismatch (§Hydration-mismatch detection above):

  • The render-tree's structural hash (per §Hydration-mismatch detection) covers head as well as body in v1 — head and body share the unified :rf/render-hash channel. A dedicated data-rf-head-hash attribute is reserved for the post-v1 reg-head extension; v1 implementations emit only data-rf-render-hash on the root element.
  • Client computes the same hash on the recomputed render-tree (head + body) and compares against the server-supplied hash.
  • In v1, head and body share the unified :rf/render-hash channel, so the bundled runtime cannot distinguish a head-only divergence from a body-only one and emits exactly one shape on any mismatch: :rf.ssr/hydration-mismatch with :tags {:server-hash "..." :client-hash "..." :failing-id :rf/hydrate}. Recovery is :warned-and-replaced — the client renders its computed head, replacing the server's.
  • The :failing-id tag is a generic host-supplied attribution seam (per §Hydration-mismatch detection), not a value the v1 runtime toggles. The :rf.ssr/head-mismatch value is therefore host-suppliable now — a host that runs its own head-only diffing may supply it through the seam and it flows through to the trace — but it is not emitted by the bundled v1 runtime. The public contract for the value is locked so consumers can branch on it today (host-attributed) and so the runtime can begin emitting it once the post-v1 head-only-hash channel lands, both without a contract change. (Audit S5.)

Default head when no route declares :head

Sensible default (from frame metadata + the runtime's HTML defaults):

{:title (or (:doc (frame-meta frame-id)) "")
 :meta [{:charset "utf-8"}
        {:name "viewport" :content "width=device-width, initial-scale=1"}]}

No registered head is required. The default is silent — no warning. Routes that want explicit head data declare :head.

Server error projection

The trace surface (009 §Error contract) carries internal error detail — stack traces, exception data, internal codes — for monitoring and debugging. The HTTP response carries a public projection — a sanitised, client-safe shape that crawlers, browsers, and unauthenticated users may see. The two surfaces have different audiences and different security profiles.

The standard public error shape (registered as :rf/public-error in Spec-Schemas):

{:status     500
 :code       :internal-error           ;; stable category keyword for response-page templates
 :message    "Something went wrong"    ;; one-sentence human-facing
 :retryable? false}

Mechanism — registered projector + per-frame :ssr metadata

Lock: both a registry-first projector and a per-frame :ssr metadata map naming which projector is active. The :ssr config sits on the frame's metadata (per Conventions §Configuration surfaces bucket 3) — so a single process can run a server-rendering frame with one projector and a dev tooling frame with another.

(rf/reg-error-projector :myapp/public-error
  {:doc "Project internal error trace events to public response shapes."}
  (fn [trace-event]
    (case (:operation trace-event)
      :rf.error/no-such-handler {:status 404 :code :not-found
                                 :message "Page not found" :retryable? false}
      :rf.error/schema-validation-failure {:status 400 :code :bad-request
                                           :message "Invalid input" :retryable? false}
      ;; default — generic 500 in prod
      {:status 500 :code :internal-error
       :message "Something went wrong" :retryable? false})))

;; Wire the projector at frame-creation time — server frames opt in
;; via `:ssr` metadata; the runtime reads it through `frame-meta`.
;; `:platform` and `:ssr` are record-config keys, so they ride `reg-frame` /
;; the advanced `re-frame.frame/make-frame`, not the EP-0023 object constructor.
(re-frame.frame/make-frame {:platform :server
                            :ssr {:public-error-id   :myapp/public-error
                                  :dev-error-detail? true}})   ;; dev: include :details with full trace

reg-error-projector adds a new registry kind :error-projector (per 001 §Registry model). The query API surfaces it: (rf/registrations :error-projector) returns id → metadata. The runtime consults exactly one projector per response — the one named in the frame's :ssr {:public-error-id ...} metadata. If unset, the runtime uses its default projector (below).

reg-error-projector returns its id argument per the family-wide reg-* return-value convention.

Default projector

The runtime ships a default projector (:rf.ssr/default-error-projector) implementing the canonical mapping:

Internal :operation Public :status Public :code
:rf.error/no-such-handler (in routing context) 404 :not-found
:rf.error/no-such-route (route-id not in registrar — per 009 §Error event catalogue) 404 :not-found
:rf.error/schema-validation-failure (:where :event or :cofx) 400 :bad-request
:rf.error/handler-exception 500 :internal-error
:rf.error/sub-exception 500 :internal-error
:rf.error/fx-handler-exception 500 :internal-error
:rf.error/drain-depth-exceeded 500 :internal-error
:rf.error/ssr-render-failed (render-time view throw) 500 :internal-error
view exception (during render-to-string) 500 :internal-error
anything not enumerated above 500 :internal-error

The two 404 rows are condition-gated: only a :rf.error/no-such-handler raised in routing context (a request URL that resolved to no route handler) and a :rf.error/no-such-route map to 404. A :rf.error/no-such-handler raised outside routing context — a dispatch to an unregistered event id mid-render — is not a missing-page condition; it falls through the unenumerated tail row to 500. The non-condition fall-through is the locked default: when a category's gating condition does not hold, it is treated as unenumerated and projects 500.

The 400 row is likewise condition-gated — on the failure's :where tag, and the default projector enforces the gate: a :rf.error/schema-validation-failure maps to 400 :bad-request only when (get-in trace-event [:tags :where]) is :event (an inbound event payload) or :cofx (a request cofx) — the surfaces through which client-supplied input enters the cascade. A schema-validation-failure on any other surface — notably :where :fx-args (a server-fx args schema, per 010 §Validation order step 5) or :where :sub-return — is a server-side defect: the server's own handler built a malformed fx args map or a sub returned a non-conforming value. Projecting such a failure as a client-facing 400 would mislabel a server bug as bad user input, so it falls through the unenumerated tail row to 500. A schema-validation-failure that carries no :where tag also falls through to 500 (the 400 arm is opt-in on a client-surface :where — fail-safe). The default projector encodes exactly this gate; custom projectors that want :where :fx-args to stay 400 may override the arm, but the runtime default does not.

Plus app-level conventions a custom projector typically adds:

User-defined Public :status Public :code
:auth/unauthorised (or equivalent) 401 :unauthorised
:auth/forbidden 403 :forbidden

In dev mode (:dev-error-detail? true), the public shape carries an additional :details key with the original trace event. In prod (default), :details is absent — the public shape is exactly the four locked keys.

Where sanitisation happens — before render

Lock: before render. The pipeline is:

  1. Drain runs; an exception occurs (handler, fx, sub, render-time view).
  2. Runtime captures the structured trace event (per 009 §Error contract).
  3. Runtime invokes the active projector with the trace event → public-error map.
  4. Runtime sets :rf.server/set-status to the public-error's :status and writes any default content-type / cache-control headers per the public-error's :code.
  5. Runtime renders an error page — a registered view (or the host's default error template) — receiving the public-error map as its prop. The error-page view sees only the sanitised projection; it cannot accidentally leak the internal trace.
  6. The host adapter serialises the response.

The HTML response is the public projection — error pages read the public shape, the rendered HTML never contains internal detail. This is the security boundary.

If the projector itself throws (or returns a non-conforming shape), the runtime emits :rf.error/sanitised-on-projection (per 009 §Error event catalogue) and falls back to the locked generic-500 shape {:status 500 :code :internal-error :message "Something went wrong" :retryable? false}. The fallback ensures the boundary cannot be bypassed by a bug in the projector.

Substrate — projector rides the always-on error-emit listener

the SSR error-projection pipeline installs onto the always-on error-emit substrate (the :errors stream of register-listener!) (per 009 §What IS available in production §Error-emit listener) — NOT the dev-only register-listener! surface. The error-emit substrate is documented as production-survivable; it fires under :advanced + goog.DEBUG=false builds and under the JVM -Dre-frame.debug=false production-hardening gate. Server error projection is a production-required surface, not a dev-only one: SSR / webhook receivers / long-running JVMs facing untrusted input MUST set re-frame.debug=false per 009 §JVM builds, and the projector's status-stamping contract must hold under that posture. The categories that fire on this always-on substrate — and so project a fail-closed status under production hardening — are :rf.error/handler-exception (router), the :rf.error/fx-handler-exception family (fx), :rf.error/flow-eval-exception (flows), and :rf.error/sub-exception (reactive sub-run).

The substrate boundary maps the always-on error-emit record ({:error :event :event-id :frame :time :exception :elapsed-ms :source-coord}) onto the projector's trace-event-shaped envelope ({:operation :op-type :tags}) before invoking the active projector. Custom projectors that case on (:operation event) or read (get-in event [:tags :exception]) work unchanged across both substrates.

Per-frame attribution. The projection pipeline routes an error trace to a response accumulator solely by the frame named in the trace's [:tags :frame] (the always-on record's flat :frame slot, or the dev-trace envelope's :tags :frame). A trace whose frame is absent — or names a non-server frame — is genuinely unroutable and no-ops explicitly: no projector runs and no status is stamped. There is no single-active-server-frame fallback. Under concurrent SSR many server frames are live simultaneously (the canonical request shape), so a fallback that guessed "the one server frame" would silently mis-attribute or drop the projection — shipping a 200 for a request that should have been a 4xx/5xx. Correctness therefore depends on every error-emit site reachable inside a server-frame drain stamping [:tags :frame] from the drain's known frame. The CLJS reference upholds this at every such site (boundary schema validation, reactive sub-exceptions, sub-override validation, programmatic/URL-driven navigation rejects, stale nav-token suppression — alongside the per-step validate-*! validators and the router miss paths, which already stamp it). The same [:tags :frame] contract is what makes these traces visible in the per-frame epoch record / Xray (epoch capture buffers only frame-tagged traces).

Four :rf.error/* categories do NOT have an always-on emission path — they ride the dev-only trace/emit-error! and DCE under production hardening: :rf.error/no-such-handler, :rf.error/no-such-route, :rf.error/schema-validation-failure, :rf.error/drain-depth-exceeded. For dev parity these are also routed through the projector via a secondary register-listener! install. Under production hardening these categories silently elide along with the trace surface — apps that need the 404/400 stamping in production deployments must explicitly route them through a :rf.error/* category that fires on the always-on substrate (typically by rethrowing inside the failing handler so the cascade lands on :rf.error/handler-exception).

:rf.error/sub-exception is NOT among them. A reactive subscription that throws during a server-frame render emits the category on both the always-on error-emit substrate and the dev-only trace surface. The always-on emission is the production status source of truth: a sub that throws mid-render-to-string is fail-closed to a non-200 (the default projector maps :rf.error/sub-exception500) under re-frame.debug=false, exactly as :rf.error/handler-exception / :rf.error/fx-handler-exception / :rf.error/flow-eval-exception are. The runtime recovers the sub's value to nil so the render does not crash, but the projected :status makes the request fail closed rather than ship a silent 200 with broken HTML. The projected response body carries only the locked public-error shape — the exception, its message, and any internal detail never cross the HTTP boundary (§Where sanitisation happens). The dev-only trace emission carries the rich internal detail for monitoring (§Internal trace events are not leaked).

View-time exceptions

A view or subscription that throws during render-to-string (e.g., a missing key on an attempted (get-in db [...])) flows through the same projector, and is fail-closed to a non-200 in production (re-frame.debug=false) — not only in dev. The user does not write a separate "view exception" path. Two routes reach the projector, both always-on:

  • A reactive subscription that throws mid-render emits :rf.error/sub-exception on the always-on error-emit substrate (see §Substrate). The runtime recovers the sub's value to nil (the render does not crash), but the projected :status makes the request fail closed: the default projector maps :rf.error/sub-exception500, so a sub-throw under production hardening yields a 500 error page, never a silent 200 with the recovered-to-nil HTML.
  • A view fn that throws (an exception the hiccup walker cannot recover) is caught at the render-time seam and routed through re-frame.ssr/project-render-exception!, which synthesises :rf.error/ssr-render-failed and drives the same projector. This seam is unconditional — it does not depend on the dev trace surface.

Either way the projector runs, the response :status is stamped, and the rendered error page sees only the sanitised public-error shape (§Where sanitisation happens).

Hosts that prefer eager exceptions during dev (to surface bugs early) can opt in via the frame's :ssr {:on-view-exception :throw} metadata — dev convenience; production should always project. The CLJS reference reads this knob at the render-time projection entry point (re-frame.ssr/project-render-exception!,.10): when set to :throw, the original throwable is re-thrown unchanged to the host's outer handler instead of being projected to a sanitised public-error.

The JVM reference adapter (re-frame.ssr.ring) unifies the two render-side failure surfaces under one pipeline. Render-time throws are caught at the host-adapter render call site and routed through ssr/project-render-exception! (synthesises a :rf.error/ssr-render-failed trace event and applies the active projector); the wire body is the projector's :message / :code. The outer :on-error hook is reserved for transport-layer / projector-undeliverable failures (no server frame, projector pipeline catastrophically fails) where the fixed-body contract applies.

:on-error vs :error-view — the error-handling division

The host adapter exposes TWO error opts that handle TWO different failures. They are not alternatives — a robust deployment usually wires both. The division (the CLJS reference's ssr-handler docstring carries the same decision table verbatim):

:error-view :on-error
Which failure An exception INSIDE the drain or render walk — something the error PROJECTOR catches (steps 1–5 above). A transport / Ring-layer failure the projector CANNOT see — per-request frame setup throw, a render-time host exception outside the walker, a header/cookie materialise throw, a thrown :initial-events setup step.
What it produces The PROJECTED error-page body (hiccup) — a registered-view keyword or a (public-error) → hiccup fn, rendered through the SSR emitter. A raw HTTP response map {:status … :headers … :body …} returned verbatim to the server.
Its input The SANITISED :rf/public-error map — safe to render. The raw (request throwable). The locked default NEVER reads the throwable (the .getMessage topology-leak boundary).
HTTP path Normal response, projector's status (typically 500) + the rendered page. Last-resort net OUTSIDE the normal pipeline (the projector never ran / can't run).
Default when omitted Minimal default error template. Minimal locked 500 (topology-leak-safe generic body).

Mnemonic: :error-view is the projected page (drain-time, sanitised, hiccup); :on-error is the transport net (Ring-layer, raw throwable, outside the projector). Both are bug-contained: a buggy :error-view falls back to the default error template; a buggy :on-error falls back to the locked default-on-error — neither bypasses the error boundary.

Dev vs prod default behaviour

Lock:

  • Dev (:dev-error-detail? true, configurable; defaults to true when the build's goog.DEBUG / equivalent is on) — public shape carries :details (the original trace event); the error page can render full detail for the developer.
  • Prod (:dev-error-detail? false, the production-build default) — public shape is the locked four keys only; :details is absent; no internal detail leaks regardless of projector implementation.

Implementations may key the dev/prod default off the build flag (e.g., goog.DEBUG); the configuration API is the same for both. The default is safe by default in prod — leaking detail requires explicit opt-in.

Internal trace events are not leaked

The internal trace stream remains unchanged by projection. Monitoring listeners (register-listener! — dev-only; the :errors stream of register-listener! — always-on; the latter is the production-survivable channel per 009 §What IS available in production) see the full structured error record with :exception-message, :exception, stack traces, and any other detail the runtime captured. Projection only governs what crosses the HTTP boundary.

This separation is the operational benefit: monitoring stays rich; the wire stays clean.

Operational rules

These are normative rules implementations must follow — active contract, not historical notes. Each subsection below is part of the load-bearing SSR surface: platform gating, JVM-runnable rendering, the server/client routing handshake, fragment behaviour, auth/session flow, the :after carve-out for machines, and hydration-payload scope. Read this section as a continuation of §Detailed design. Implementations that want a working SSR surface must satisfy every rule below.

Mismatch recovery and configuration

The detection mechanism is in §Hydration-mismatch detection above. Recovery and configuration:

  1. Default recovery: :warned-and-replaced — the runtime renders the client's view, replacing the server's HTML. The page becomes interactive.
  2. Strict mode (frame's :ssr {:on-mismatch :hard-error} metadata): the runtime throws a structured exception with the same payload. Used in dev/CI to surface mismatches loudly.
  3. External monitoring integrations register a trace listener and ship mismatch events to their backend.
  4. Mismatch detection is mandatory in dev builds; production builds can disable the hash-comparison work via the frame's :ssr {:detect-mismatch? false} metadata for a small first-render perf win, at the cost of silent mismatches. Default: detection on in all builds.

Effect handling on the server

:platforms metadata on every reg-fx and reg-cofx. Server-side fx resolver filters by platform; absence of the key defaults to #{:server :client} (universal).

The full rule:

  1. reg-fx (and reg-cofx) takes optional :platforms metadata: a set containing :server, :client, or both.
  2. The runtime tracks the active platform — the CLJS reference sets this at startup based on cljs.core/*target* for client builds, or via an explicit (rf/init-platform :server) for server-side bootstraps.
  3. When the fx resolver encounters an effect whose :platforms doesn't include the active platform, it skips the fx and emits a :rf.fx/skipped-on-platform trace event (not an error; :op-type :warning) with :fx-id, :platform, :registered-platforms. Recovery: :skipped (per 009 §Error contract). The fx silently no-ops with the trace. SSR does not get stricter than this; the trace event is sufficient observability without aborting render. Tools that want strict-mode behaviour register a trace listener on :rf.fx/skipped-on-platform and escalate. The same rule applies symmetrically to cofx: when a handler declares (:rf.cofx/requires) an ambient cofx whose :platforms excludes the active platform, the cofx's value-returning supplier is NOT invoked, no value is delivered into :coeffects, and the runtime emits :rf.cofx/skipped-on-platform (same shape; :rf.cofx/id + :rf.cofx/platform + :rf.cofx/registered-platforms). The event handler still runs — only that one coeffect's delivery is skipped.
  4. Default if :platforms absent: #{:server :client} (universal). SSR-shared fx and headless-test fx are universal by default. Explicit :platforms #{:client} is required for fx that genuinely cannot run server-side (browser-only).
  5. Setup events that only matter on the server (:rf/server-init, handlers declaring the :rf.server/request coeffect) carry :platforms #{:server} themselves so they don't run client-side after hydration.

This is the single mechanism for platform-gating; no per-fx branching inside handler bodies. Same handler dispatches the same effects on both platforms; the runtime decides which actually fire.

JVM-runnable view rendering

Pure hiccup → string emission as a JVM-runnable function. No JVM React. No component lifecycle on the server.

Concrete contract:

  1. The CLJS reference ships re-frame.render/render-to-string as a pure function over hiccup data. Implementation is a walk: tag → HTML element, attrs → escaped attribute pairs, children recursed, void elements (<br>, <img>, <input>, etc.) self-closed per HTML5, text content escaped per XSS rules.
  2. The function is in .cljc. No Reagent, no React, no DOM dependencies. JVM-runnable.
  3. Registered views are resolved by id at emission time; the registry is just data, queryable from the JVM.
  4. Subscriptions inside view bodies use compute-sub (not the reactive subscribe) — pure derivations against the static app-db value.
  5. Component lifecycle hooks (:component-did-mount, etc.) do NOT fire on the server. Form-3 components render their :reagent-render only; lifecycle is client-side after hydration.

The JVM-runnable scope in 008's table reflects this: hiccup → string is JVM-runnable; React mount/commit is CLJS-only.

Routing and SSR

The request URL is fed in via :rf/server-init, which dispatches :rf.route/handle-url-change with the URL. Same handler runs server- and client-side (:platforms absent, so default-universal).

Concrete handshake:

  1. Server's handle-request creates the per-request frame with :initial-events [[:rf/server-init request]] (a record-config key on reg-frame / make-frame; the request-derived setup vector is computed per request — see EP-0027 §SSR).
  2. :rf/server-init (:platforms #{:server}) reads the request's URI and dispatches [:rf.route/handle-url-change uri].
  3. :rf.route/handle-url-change (universal — runs on both platforms) calls (rf/match-url uri), sets the route slice in app-db at [:rf.runtime/routing :current]. Per 012-Routing.
  4. Per-route data fetches happen via the route's matched-state side-effects: a registered fx like [:route/fetch-data route-id params] is dispatched as part of :rf.route/handle-url-change's effects.
  5. Drain settles. The frame's app-db has the route slice ([:rf.runtime/routing :current]) and any route-specific data in place. The view renders.

On the client, the same handshake runs after hydration restores state. The client's :rf.route/handle-url-change is fired by popstate listeners (browser back/forward) and by initial-load detection — server-pre-rendered pages already have the right route slice from hydration, so the client's initial render uses it without re-firing.

Fragments under SSR

Per 012 §Fragments, the :route slice carries :fragment. SSR rule:

  1. Parse the fragment from the request URL when the host's request abstraction exposes it. Most server frameworks (Ring, Pedestal, Express, Rails) include the #fragment only in proxy/test scenarios — browsers do not send #fragment to the server, so a server-side :fragment is typically nil for browser-initiated requests. Static-site generators and crawlers that synthesise URLs with fragments (e.g., for anchored documentation pages) DO supply them; SSR honours those.
  2. Include :fragment in the seeded :route slice. Views that subscribe to :rf.route/fragment produce structurally-identical output server-side and client-side (per the hydration equivalence rule).
  3. :rf.nav/scroll does not run on the server. It's :platforms #{:client} per §Effect handling on the server. The server has no DOM; scroll-to-fragment is meaningless. The post-hydrate scroll behaviour is host-implementation choice (per 012 §Fragments §SSR).

The contract: the :fragment value is preserved across the SSR round-trip; no SSR-specific scroll behaviour exists.

Authentication / sessions

Server-side auth/session lives in app-db via a declared coeffect (:rf.cofx/requires) at :rf/server-init time. No SSR-specific auth surface.

The session feeds durable app-db that ships in the hydration payload, so it MUST arrive as a recordable fact via the §Durable request-derived facts boundary pattern — NOT an ambient :rf.server/request read folded into durable state at the write site (that is a replay hole: replay re-runs the live cofx supplier instead of re-presenting the session the recorded run folded).

Concrete:

  1. Server middleware (Ring/Pedestal/etc.) extracts + sanitizes the session from the request — cookie-based, JWT-based, whatever the host uses — to a wire-safe derived projection (e.g. {:user "alice" :authed? true}), never the raw cookie / token.
  2. The host adapter supplies the sanitized session as a recordable fact, either as event payload — the per-request frame's :initial-events [[:auth/server-init {:user … :authed? …}]] (a record-config key on reg-frame / make-frame) — or by stamping a provided recordable :rf.cofx leaf (:auth.session/user) onto the boot token.
  3. :auth/server-init (:platforms #{:server}) reads the fact off :event (or declares :rf.cofx/requires [:auth.session/user]) and sets the relevant app-db slice: {:auth/user (or user nil) :auth/state (if authed? :authed :idle)}. A missing provided fact fails loudly with :rf.error/missing-required-cofx rather than silently re-reading the host.
  4. The client side is hydrated with this slice; the user's authed-state survives the round-trip, and epoch-restore / replay re-presents the SAME session fact off the token.

The framework provides no auth-specific machinery. The pattern's primitives — events, recordable coeffects, frames per request — are sufficient.

:after is no-op under SSR

State machines that declare :after (per 005 §Delayed :after transitions) do not schedule timers in SSR mode. The state node's entry action skips the re-frame.interop/schedule-after! call when the active platform is :server; the synthetic timer-elapsed event is never queued; the request frame is destroyed before any timer could fire anyway.

The rule:

  1. The server renders the machine's current :state statically. Whatever timer-driven transitions might be pending have not happened — they don't exist on the server.
  2. The serialised app-db snapshot includes [:rf.runtime/machines :snapshots <id>] with the current :state and :data (including the per-decl-path :rf/after-epoch map); the client hydrates that snapshot.
  3. After hydration, the client's first render of the relevant view is what triggers the machine to "enter" the state for client-side purposes — the implementation may re-fire entry actions on hydration to begin scheduling, or may treat hydration as a special case that schedules :after timers without re-running other entry effects. The exact handoff is a host-implementation choice; the contract is that the snapshot value is preserved across the round-trip and that :after timers begin running on the client (per the snapshot's epoch) rather than on the server.

Symptoms of getting this wrong: the server schedules a 5-second :dispatch-later; the request frame is destroyed; the timer fires against a destroyed frame (CLJS) or a freed channel (host-equivalent); a stray trace event surfaces or, worse, the timer's effect lands in some other in-flight frame's app-db.

Why it's safe to elide:

  • :after timers are state-entry-relative. They have no semantic meaning until the user is interacting with the page — i.e., until after hydration. There's nothing to lose by deferring scheduling to the client.
  • Epoch-based stale detection makes the round-trip idempotent: even if a server-scheduled timer somehow leaked, the client's epoch would be different and the timer would be ignored at expiry. Eliding scheduling outright avoids the leak.
  • Per :platforms gating (§Effect handling on the server), the implementation can register the timer-scheduling fx with :platforms #{:client} — the server-side fx resolver silently no-ops it without further machine-handler awareness.

This is the only SSR-specific carve-out the state-machine substrate needs; all other machine semantics (transitions, :always, :spawn, hierarchical entry/exit cascading) run identically on both platforms.

Hydration of non-state runtime artefacts

:rf/hydration-payload carries the canonical durable frame-state:rf/app-db (the app-db partition) plus the optional :rf/runtime-db (the serializable runtime-db projection: machine snapshots, route slice, elision declarations, SSR metadata). Sub-cache warmups, in-flight request continuations, and other transient runtime artefacts are out of scope.

Rationale: the hydration contract is small and tractable. Adding sub-cache warmups requires the client to know the same sub-graph topology the server used (which is true, since registrations are static — but it adds wire bytes and serialisation complexity). In-flight request continuations require persistent fx implementations on both ends. Both can be added later as additive payload fields without breaking the contract.

The schema for :rf/hydration-payload (in Spec-Schemas) lists :rf/sub-warmups as optional.

On hydration:

  1. Client receives the :rf/app-db + :rf/runtime-db slices and replaces its frame-state (both partitions) with them (per the locked :replace-frame-state policy in §The :rf/hydrate event). Server is authoritative for the initial client frame-state.
  2. Client's reactive subscriptions, on first read, compute against the now-seeded state. Same values the server saw.
  3. There is intentionally no special-case for "warm" subs vs "cold" subs. The first read is the warmup.

Streaming SSR

Status: shipped. The :rf/suspense-boundary hiccup marker described below ships in the day8/re-frame2-ssr artefact (re-frame.ssr.streaming ns); the chunked-response wiring ships in day8/re-frame2-ssr-ring (re-frame.ssr.ring.streaming ns + stream-handler).

Streaming SSR lets the server flush a usable shell on first byte, then stream subtrees as their data resolves. Crawlers, low-latency rendering, and SOTA parity with Next/Remix defer and Solid Suspense all benefit. The primitive is one hiccup marker — declarative, walker-driven, no per-host streaming-render-mode API.

The :rf/suspense-boundary hiccup marker

[:rf/suspense-boundary
 {:id      :news/comments              ;; required, namespaced keyword or string
  :fallback [:p "Loading comments…"]}  ;; required, hiccup
 [:comments/section]]                  ;; subtree — the deferred body

Operational semantics (the emitter contract):

  1. The streaming emitter walks the hiccup tree top-down.
  2. On a :rf/suspense-boundary node, the emitter:
  3. emits the rendered :fallback wrapped in <template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1">…</template>,
  4. records a continuation entry {:id <id> :subtree <body-hiccup>} in the per-request streaming-continuations registry,
  5. continues walking sibling nodes (the shell is single-pass; nested boundaries are recursed into the continuation's body when the continuation later renders).
  6. After the shell HTML is materialised, the host adapter drains continuations one by one (order is FIFO over registration order; nested boundaries inside a continuation register their own entries during the continuation render). For each continuation:
  7. render the subtree to HTML via render-to-string (same emitter, recursion-friendly — a nested :rf/suspense-boundary re-recurses through this same drain),
  8. build the per-subtree hydration delta (the subset of app-db keys touched between the start of the continuation render and its end; see §Hydration interleaving below for the partitioning rule),
  9. emit one chunk carrying <template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…subtree-html…</template> followed by <script data-rf2-suspense-hydrate="<id>" type="application/edn">…delta-edn…</script>,
  10. flush the chunk.
  11. After the last continuation drains, the host adapter emits the final-hydration-payload chunk (<script id="__rf_payload" type="application/edn">…full-payload…</script>) and closes the response. The final payload carries the canonical :rf/hydration-payload shape — it is the source of truth on hydration; the per-subtree deltas are speculative chunks the client may apply progressively.

The boundary :id is stable per render — the hiccup author picks it. The runtime does not autogenerate boundary ids; that would defeat hydration matching when the client re-walks the tree.

Client-side hydration semantics (the shipped re-frame.ssr.streaming.client/install! contract):

  1. On install: the client materialises each inert <template data-rf2-suspense-fallback="1"> into a live, visible mount — an <rf-suspense data-rf2-suspense-mount="<id>">…fallback…</rf-suspense> wrapper holding the fallback markup. This is load-bearing: a <template>'s content is inert by the HTML spec (.content is a detached DocumentFragment, never painted), so the user would see nothing until swap if the fallback stayed wrapped. The visible mount is also the stable swap target the resolved chunk replaces. (Same model React 18 / Solid use — a visible fallback plus a stable mount — expressed over the server's <template>-marker protocol.) The shell otherwise hydrates against whatever payload is already inlined (none, in the streaming case) — the streaming bootstrap waits for the __rf_payload script, which arrives last.
  2. As resolved-subtree chunks stream in (driven by a MutationObserver, plus an initial synchronous sweep for chunks that landed before the bundle booted): the runtime, per matched boundary id, replaces the live mount's content with the resolved <template>'s parsed content in-place, and merges the per-subtree hydration delta into the target frame's app-db via a top-level (into existing delta) merge (so a subscription reading the now-resolved region sees the speculative state). This happens progressively, before the final payload — the speed prop of suspense boundaries. A data-rf2-suspense-failed="1" chunk swaps the fallback HTML and applies no delta, surfacing a client-side :rf.ssr/suspense-boundary-failed trace (Spec §Failure semantics — inline fallback) without a 500.
  3. After the final chunk: the bootstrap (ssr/hydrate!) dispatches :rf/hydrate with the full payload — this is the consistency moment; the deltas were speculative, the final payload is canonical (:replace-frame-state). The runtime auto-disconnects its observer the moment it sees the __rf_payload node, so no late delta can race the canonical replace.

The streaming runtime (re-frame.ssr.streaming.client/install!, re-exported as ssr/streaming-install!) ships in day8/re-frame2-ssr and is host opt-in — a streaming-aware bootstrap calls it; non-streaming pages skip it entirely. It is CLJS-only (it installs a MutationObserver + swaps DOM). install! takes {:frame :payload-id :root} where :frame is required (EP-0002 — the delta-merge target is carried, the same frame the bootstrap hydrate!s into; an absent :frame raises :rf.error/no-frame-context, the pre-EP :rf/default default is removed) and :payload-id / :root default to "__rf_payload" / js/document. It returns a 0-arity stop! fn (auto-disconnect on final payload means most hosts never call it). It is idempotent per chunk (a seen-set guards against observer batching + the initial sweep racing the same node).

Delta wire shape (shipped). The <script data-rf2-suspense-hydrate="<id>" type="application/edn"> body is the bare delta-map EDN ((pr-str delta)) — the boundary id is carried by the data-rf2-suspense-hydrate attribute only, NOT a {:rf/app-db-delta … :rf/boundary-id …} wrapper. The client reads the attribute back into an id via the EDN reader and merges the bare delta-map. (Both the template data-rf2-suspense-id and the script data-rf2-suspense-hydrate attributes carry (escape-attr (str id)), so a keyword id round-trips unchanged through read-string.)

Failure semantics — inline fallback

Per (a) sub-rec: when a continuation's render throws, the runtime does NOT fail the whole response. Instead:

  1. The throwable is caught at the continuation drain step.
  2. A :rf.ssr/suspense-boundary-failed trace event fires (per 009 §Error event catalogue) with {:id <boundary-id> :exception t :recovery :inline-fallback}.
  3. The runtime emits the chunk as <template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1" data-rf2-suspense-failed="1">…fallback-html…</template> — the fallback's HTML, materialised against the original fallback hiccup, with the data-rf2-suspense-failed marker for client-side observability.
  4. The per-subtree hydration delta is omitted for failed boundaries (there is no resolved state to ship; the client keeps its pre-failure delta).
  5. The response continues — sibling boundaries, the final payload, and the close all proceed normally.

Rationale: streaming SSR's whole point is partial-render robustness. A failed sub-subtree (a flaky comments service, a slow third-party dependency) should not 500 a page whose shell, head, and other subtrees rendered successfully. The fallback is already the author's declared "things-are-loading" surface; reusing it on hard failure is a defensible default and the simplest opt-in alternative (:on-error [:hiccup-vec]) is additive future work.

The failure boundary stops at the continuation. An exception inside the shell walk (before the first chunk has flushed) is NOT covered by this contract — the shell is the request's structural foundation and a shell-render throw escalates to :rf.error/ssr-render-failed per the standard error-projection path. The streaming contract only covers continuations.

Hydration interleaving — per-subtree deltas

Per (a) sub-rec: the hydration payload is interleaved per subtree, not shipped last. Each resolved-subtree chunk carries a delta of the app-db keys touched by that continuation's render path; the final payload carries the canonical complete state.

The partitioning rule for what each delta carries:

  • The streaming runtime snapshots app-db at the start of each continuation render (before-db) and again at the end (after-db).
  • The delta carries the top-level keys present-or-changed in after-db, and for each such key its full after-db value. The changed-or-new key set is (clojure.data/diff before-db after-db)'s second return slot (the keys only-in / changed-in after-db); implementations may use any equivalent structural-diff strategy that yields the same key set. The delta ships the full value for each of those keys — not data/diff's partial second-slot value, which for a changed nested map returns only the changed sub-portion. Shipping the full value is what keeps the client's top-level (into existing delta) merge below lossless: a changed nested top-level key is replaced wholesale with its complete new value rather than having its untouched sub-keys silently dropped. Unchanged top-level keys are omitted (the client already holds them).
  • The per-subtree delta is shipped as the bare delta-map EDN in the <script data-rf2-suspense-hydrate="<id>" type="application/edn"> chunk body (the boundary <id> is carried by the data-rf2-suspense-hydrate attribute, not a wrapper); the client merges it into app-db via (into existing delta) over the top-level keys. Because each delta value is a complete after-db value, this top-level merge is lossless even for changed nested keys. (This is the shipped contract: the server emits (pr-str delta), not a {:rf/app-db-delta … :rf/boundary-id …} envelope; carrying the id once on the attribute keeps the script body a plain delta-map both sides agree on.)

Boundary ordering (registration → drain → chunk emit) is FIFO over the shell's walk order (depth-first, document order). A nested :rf/suspense-boundary inside a continuation's body registers DURING that continuation's render — the inner entry lands at the tail of the registry and drains after all originally-registered entries. This preserves the document-order intuition: each chunk hydrates a strictly-later DOM region than the previous chunk.

Final-payload precedence: the __rf_payload chunk arrives last and carries the canonical app-db. Implementations may detect drift between the accumulated deltas and the final payload (a non-empty diff after applying both); the v1 contract is the final payload wins:rf/hydrate runs :replace-frame-state semantics, the same lock as non-streaming SSR. Deltas are the progressive-rendering speed prop; the final payload is the correctness lock.

Chunk-ordering contract (the wire shape)

The host adapter MUST emit chunks in this order:

  1. Shell chunk<!DOCTYPE html><html>…<body>…<div id="app"><shell-html-with-template-fallbacks/></div> — flushed immediately after the shell walk completes.
  2. N resolved-subtree chunks — one per boundary, in registration-order — each chunk is <template data-rf2-suspense-id="<id>" data-rf2-suspense-resolved="1">…</template><script data-rf2-suspense-hydrate="<id>" type="application/edn">…</script>.
  3. Final-payload chunk<script id="__rf_payload" type="application/edn">…full-payload…</script>.
  4. Closing chunk</body></html>.

The chunk content uses HTTP Transfer-Encoding: chunked framing; the application-layer ordering above is what the conformance fixture pins.

Streaming does NOT accept :html-shell

stream-handler shares the non-streaming handler's construction contract — the required opts (:initial-events / :root-view), the hydration-payload policy, the :on-error precedence, and the four trusted shell-hook opts (:head / :body-end / :script-src / :app-element-id, §Trusted shell hook contract) — with one exception: it does NOT accept a custom one-piece HTML-shell override (the CLJS reference's :html-shell opt, a (body-html payload-edn opts) → string fn honoured by the non-streaming ssr-handler).

The chunk-ordering contract above is why. The non-streaming handler has the full body + payload in hand before it composes the envelope, so a one-piece shell can wrap them arbitrarily. The streaming handler flushes the envelope as a split prefix/suffix straddling the continuation chunks — the prefix on the shell chunk (1), the suffix on the closing chunk (4), with N continuation chunks and the final payload in between. A one-piece shell callback can never run after streaming has started; it could only ever apply to a body the streaming path does not assemble in one piece.

The framework therefore MUST fail closed at handler-construction time: stream-handler rejects a non-nil :html-shell (any shape) with :rf.error/ssr-streaming-unsupported-opt (ex-data carries :opt-key :html-shell, :got, and :recovery). An absent or explicit-nil :html-shell constructs cleanly (no override requested). Silently dropping the opt would be a fail-OPEN gap — a custom shell commonly carries CSP nonces, asset URLs, analytics/script policy, or root markup, and a deployment switching ssr-handlerstream-handler would lose all of it with no signal.

Callers needing custom shell content under streaming use the split-envelope surface instead: the four trusted shell-hook opts above (honoured by default-streaming-prefix / default-streaming-suffix). A caller that genuinely needs a single one-piece shell fn must use the non-streaming ssr-handler.

Boundary nesting and recursion

:rf/suspense-boundary nodes nest. When a continuation's render walks into another :rf/suspense-boundary, the inner boundary registers a new continuation at the tail of the drain queue. The inner subtree's resolved chunk arrives after all originally-registered continuations — the order is strictly registration-order (FIFO).

Edge case — the same :id appearing twice (e.g. a buggy author using :id :news/comments on two separate boundaries): the runtime emits :rf.error/suspense-boundary-duplicate-id (per 009 §Error event catalogue) and the second registration overwrites the first in the drain queue. The wire ships only the last-registered continuation's chunk; the earlier <template data-rf2-suspense-id="<id>" data-rf2-suspense-fallback="1"> placeholder is left in place (the client-side runtime never finds a matching resolved chunk and keeps the fallback rendered). Duplicate ids are a programmer error; the contract is fail-soft (no 500), with a visible trace.

Duplicate detection is keyed on the canonical wire id(str id), the exact value stamped into data-rf2-suspense-id / data-rf2-suspense-hydrate and matched by the client — not the raw :id value. Two boundaries whose ids differ as values but collide under str (e.g. the keyword :a and the string ":a") therefore count as duplicates: they share one data-rf2-suspense-id on the wire, so the client cannot tell them apart, and shipping both chunks would let the client resolve the wrong mount or skip the later chunk via its seen-set. Keying detection on the wire id keeps the server's duplicate contract aligned with the one string the client actually matches against.

Late-bind hook surface

The streaming surface composes from three late-bind hooks (per Conventions §Late-bind hook key grammar):

Hook Producer Consumer Purpose
:ssr.streaming/render-shell! re-frame.ssr.streaming host adapters Walk the root hiccup, emit the shell HTML with <template> fallbacks, return {:shell-html "…" :continuations [{:id … :subtree …} …]}.
:ssr.streaming/render-continuation! re-frame.ssr.streaming host adapters Render one continuation's subtree to {:html "…" :delta {…} :failed? false} (or fallback-html + :failed? true on throw).
:ssr.streaming/build-final-payload re-frame.ssr.streaming host adapters Build the final __rf_payload chunk's payload map after all continuations have drained — the canonical :rf/hydration-payload shape, including the post-drain app-db.

Host adapters call these three hooks in order; the streaming runtime owns the walker, the registry, and the delta computation. The Ring adapter wires them via re-frame.ssr.ring.streaming/stream-handler (per §Cross-references).

Writer concurrency model — one daemon thread per in-flight stream

The chunked-response body is a PipedInputStream/PipedOutputStream pair: the Ring server reads from the input side while a writer runs on its own thread pumping shell → continuations → final-payload → close into the output side. The CLJS-JVM reference (re-frame.ssr.ring.streaming/stream-handler) spawns one raw java.lang.Thread per in-flight streamed request — there is intentionally no framework-imposed thread pool, executor, or in-flight cap. The concurrency posture is documented here as a deliberate v1 choice:

  • The writer thread is a daemon, named rf2-ssr-streaming-<frame-id>. The daemon flag is load-bearing for shutdown: a writer blocked on .write to a slow-loris client's full 16 KiB pipe must not pin the JVM open at shutdown. The thread body is wrapped try/catch Throwable/finally: the catch emits :rf.error/ssr-streaming-writer-failed and the finally always closes the pipe (signalling a clean EOF to the server), then the spawning finally tears the per-request frame down (destroy-frame-quietly!) off the response-close path. This teardown contract is no-leak — every writer thread terminates and is reclaimed once its request completes or its client disconnects; the count decays to zero (verified by concurrency_stress_test's daemon-thread-count-bounded-during-burst).
  • The in-flight CEILING is the host server's concern, not the framework's. The number of simultaneously-live writer threads equals the number of in-flight streamed responses, which is bounded by the HTTP server's own accept-queue / worker-thread limits (Jetty, http-kit, Aleph all impose these in front of the handler). The framework does NOT add a second ceiling. Operators sizing for high streaming concurrency — or hardening against a pathological slow-client population that could otherwise hold one ~1 MB-stack platform thread per stuck request — MUST size the host server's request-concurrency limits accordingly; that is the single, authoritative knob. A framework-side pool would either duplicate that limit or, worse, risk breaking the proven no-leak teardown by decoupling thread lifetime from request lifetime.
  • Forward path (non-normative). JDK 21+ virtual threads make per-request threads effectively free; a future opt-in writer thread-factory (:writer-thread-factory) could let hosts supply a virtual-thread or bounded-executor factory without changing the per-request-thread model or its teardown contract. This is additive future work, not a v1 requirement — the raw-daemon-thread-per-request model is the locked baseline.

Other-language ports mirror the contract — one isolated writer context per in-flight stream, daemon/background semantics so it can't pin shutdown, guaranteed pipe-close + frame-teardown on every exit path, and the in-flight ceiling delegated to the host server — not the literal java.lang.Thread.

Per-request frame teardown contract

Per §Server flow every per-request server frame ends with destroy-frame!. The destroy step is load-bearing for memory hygiene on a long-running server process — leaks here compound at request-rate, and a slow leak under prod load is the kind of bug that ships SSR-broken.

The framework owns the following per-frame allocation sites; all MUST be released by destroy-frame!:

Slot Owning ns Storage Released by
app-db (the frame's state container) re-frame.frame per-frame, on the frame record the frame record is dropped (dissoc-frame!)
router queue + drain-lock re-frame.frame per-frame, on the frame record the frame record is dropped
sub-cache re-frame.subs per-frame, on the frame record tear-down-sub-cache! disposes every cached reaction; the cache atom is reset to {}
HTTP response accumulator re-frame.ssr defonce atom keyed by frame-id, side-channel the :ssr/on-frame-destroyed hook drops the slot
pending error-trace buffer re-frame.ssr defonce atom keyed by frame-id, side-channel the :ssr/on-frame-destroyed hook drops the slot
per-frame HTTP request slot re-frame.ssr defonce atom keyed by frame-id, side-channel the :ssr/on-frame-destroyed hook drops the slot; host adapters MAY also clear inline via clear-request!
epoch ring buffer re-frame.epoch defonce atom keyed by frame-id the :epoch/on-frame-destroyed hook drops the slot

What deliberately survives a per-request frame's destruction (these are NOT leaks — they are process-wide registries that mirror handler registration shape):

  • The global registrar (re-frame.registrar/kind->id->metadata) — event / sub / fx / cofx / view / route / error-projector / flow registrations are process-wide; they exist independently of any frame and do not leak per-request.
  • The retain-N trace ring buffer (re-frame.trace.tooling/trace-buffer-state, default depth 200) — a process-wide, capacity-bounded buffer; the size is fixed by configuration regardless of request count. (Per the buffer state lives in the tooling sibling ns split off from re-frame.trace for production DCE; tools and tests reach it through re-frame.trace/trace-buffer's late-bind wrapper.)
  • The substrate adapter slot (re-frame.substrate.adapter/installed-adapter) — set once at boot.

The contract for side-channel atoms keyed by frame-id: every such atom MUST register a cleanup hook with re-frame.late-bind and that hook MUST be invoked from frame/destroy-frame!. The SSR side-channel keys are :ssr/on-frame-destroyed and :epoch/on-frame-destroyed (the destroyed-frame cleanup callback verb). New artefacts that introduce per-frame side-channel state MUST publish such a hook — using whichever of the two normative verb forms fits the work: <feature>/on-frame-destroyed! for a side-table cleanup callback (the SSR shape here), or <feature>/teardown-on-frame-destroy! for an artefact-owned teardown recipe carrying lifecycle/registrar-consistency invariants (the machines/flows shape). The two verbs are a real semantic distinction, not a style choice — see 002 §Two destroy-hook verbs for the rule and 012 §Frame-destroy teardown for the per-artefact catalogue.

Verification: the load test at implementation/ssr/test/re_frame/ssr_teardown_load_test.clj drives the documented per-request SSR flow N times against the same host adapter, snapshots the JVM heap before and after a GC pause, and asserts the heap delta and the side-channel atom sizes return to baseline.

Open questions

SA-4 classification. Per SPEC-AUTHORING §SA-4: no items outstanding at the 011-SSR tier. The streaming surface formerly listed here was classified :resolved when §Streaming SSR landed; the resolved entry lives at ## Resolved decisions below.

(None outstanding — add new open questions inline as decisions land or are deferred.)

Resolved decisions

Streaming SSR shipped as :rf/suspense-boundary

Per §Streaming SSR the framework now ships a hiccup-marker streaming primitive (:rf/suspense-boundary) plus a chunked-response host adapter (re-frame.ssr.ring.streaming/stream-handler). Earlier drafts of this Spec carried streaming under "Open questions" as a host-implementation concern; closed with Mike's (a) pick — declarative marker, walker-driven, no per-host streaming-render-mode API — plus three sub-recs (accepted): (1) inline-fallback failure semantics, (2) interleave-per-subtree hydration ordering, (3) the :rf/suspense-boundary name. SOTA parity with Next/Remix defer and Solid Suspense. The conformance fixture at spec/conformance/fixtures/ssr-streaming.edn pins chunk ordering plus final-payload hash equality; the worked example at examples/reagent/ssr-streaming/ exercises the dashboard-with-slow-cards scenario.

:replace-frame-state is the locked hydration merge policy

Per §The :rf/hydrate event the client's :rf/hydrate handler replaces the frame-state (both partitions — app-db and the serializable runtime-db projection) with the server's serialised slices; earlier sketches considered a merge policy that would have preserved client-side pre-seeded state. The merge variant was rejected because the hydration contract is small and tractable only if the server is authoritative for the initial client frame-state — a merge policy makes "did the server's value win?" undecidable at every key. Apps that want client-only seeded state run it after the hydration event, not before.

Sub-cache warmups out of scope for v1 :rf/hydration-payload

Per §Hydration of non-state runtime artefacts, :rf/hydration-payload carries :rf/app-db plus the serializable :rf/runtime-db projection — no pre-computed sub values. Adding sub-cache warmups requires the client to know the same sub-graph topology the server used and adds wire bytes plus serialisation complexity. The first read is the warmup. :rf/sub-warmups remains an optional additive payload field in Spec-Schemas §:rf/hydration-payload; a future iteration can land it without breaking the contract.

Per-request frame teardown contract added

Per §Per-request frame teardown contract the framework now documents every per-frame allocation site that destroy-frame! MUST release, including three re-frame.ssr defonce side-channel atoms (HTTP response accumulator, pending error-trace buffer, per-frame HTTP request slot). All three are released via the :ssr/on-frame-destroyed re-frame.late-bind hook; the contract for side-channel atoms keyed by frame-id is locked. The load test at implementation/ssr/test/re_frame/ssr_teardown_load_test.clj (2000-request synthetic SSR loop) verifies the heap delta and side-channel atom sizes return to baseline.

Head/meta surface is live, not deferred

Earlier drafts of §Head/meta contract carried a deferral banner pointing to ; the contract was normative-looking prose but the impl was absent. The decision in landed the impl: reg-head registers under a :head registry kind; routes name a :head in route metadata; render-head computes the head model; active-head is sugar for the active route. The SSR emitter wraps body output with <head>...</head> from the model when a frame's active route declares :head. Head mismatch detection piggy-backs on the unified :rf/render-hash channel in v1: the bundled runtime cannot separate head from body and emits a single :failing-id :rf/hydrate. The :failing-id tag is a generic host-attribution seam (:rf.ssr/head-mismatch is host-suppliable now but not v1-runtime-emitted); a head-only hash that would let the runtime emit it is deferred post-v1.

:rf.ssr/check-version and :rf.ssr/check-schema-digest are framework-registered

Per §The :rf/hydrate event the reference :rf/hydrate handler dispatches :rf.ssr/check-version and (when payload carries a digest) :rf.ssr/check-schema-digest. Both fxs are registered by re-frame.ssr at ns-load time with :platforms #{:client}; version-mismatch emits :rf.ssr/version-mismatch, schema-digest-mismatch emits :rf.ssr/schema-digest-mismatch. Earlier drafts named both events in normative prose but registered neither — a silent :rf.error/no-such-handler trace at hydration time was the visible symptom. The two-handler set is locked; apps that want app-specific checks register additional handlers, they don't replace these.

HTTP response accumulator stored in a side-channel atom, not in app-db

Per §Response storage substrate the per-request HTTP response accumulator MUST live in a framework-private side-channel atom keyed by frame-id (mirroring request-slots and pending-error-traces), NOT under any path in app-db. Earlier drafts pinned the accumulator at the [:rf/response] app-db path; the audit (parent) identified two failures of that placement: (a) the hydration payload at ssr-ring/build-payload ships the whole app-db by default, so the response accumulator — including Set-Cookie auth tokens and internal X-* headers — defaulted to riding the wire to the client; (b) every :rf.server/* fx swapped the whole app-db container (read → assoc → replace) to update the accumulator, allocating a fresh app-db value per fx call. Moving the substrate to a side-channel atom makes the privacy boundary self-enforcing (the accumulator cannot be misconfigured into the payload) and reduces per-fx writes to an O(small-map) atom CAS. The CLJS reference's re-frame.ssr/response-slots is ^:private; tests reach it via (resolve 're-frame.ssr/response-slots) for between-fixture reset.

:rf.server/safe-redirect ships alongside caller-trusted :rf.server/redirect

Per §HTTP response contract the runtime ships two redirect fxs: :rf.server/redirect is caller-trusted (arbitrary :location strings, no allowlist) and :rf.server/safe-redirect is the caller-untrusted variant (URL parse, scheme reject, relative-only / allowlist gating). The audit at 2026-05-14 §P3.2 identified the open-redirect class: an app that reads a ?next=… query parameter and dispatches [:rf.server/redirect {:location next-param}] against attacker-controlled input will happily redirect to a phishing site after auth. Three positions were on the table — (A) ship safe-redirect alongside, (B) doc-string warning + recipe in guide, (C) both — and resolved Option A: ship the primitive, the programmer chooses. Pro: discoverable, opt-in by API; the safe variant is first-class, not buried in a recipe. Con: adds one public fx-id surface; mitigated by the cross-reference in :rf.server/redirect's docstring. The four error categories (:rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected, :rf.error/safe-redirect-host-disallowed — the latter discriminates :relative-only-violation vs :not-in-allowlist via the :reason tag) are catalogued in 009 §Error event catalogue.

Render-side validator failures unified under the projector

Per §View-time exceptions the JVM reference adapter (re-frame.ssr.ring) routes render-time throws through the SAME projector pipeline that catches drain-time fx / handler / sub exceptions. re-frame.ssr/project-render-exception! synthesises a :rf.error/ssr-render-failed trace event and applies the active projector; the host-adapter render call site wraps a try/catch that drives projection then emits a minimal HTML body from the projector's :message / :code. The outer :on-error hook is reserved for transport-layer failures (no server frame registered, projector pipeline catastrophically fails) where the fixed-body topology-leak rule applies. The category :rf.error/ssr-render-failed is catalogued in 009 §Error event catalogue and appears in the default projector table above (§Default projector).

resolve-head emits :rf.error/ssr-head-resolution-failed before fallback

Per §Head/meta contract the host-adapter helper that walks the active route's :head (the Ring adapter's re-frame.ssr.ring.lifecycle/resolve-head) wraps the resolution in try/catch and degrades to an empty fragment when the user's :head fn throws. Earlier drafts of the helper's docstring promised "the trace surface still carries the throw," but the impl just caught and returned "" with no emit — a silently-broken :head fn produced a visually-broken page with zero diagnostic. Two positions were on the table — (A) silent fallback (tighten the docstring to match the impl), (B) trace-emit (match the docstring's promise via :rf.error/ssr-head-resolution-failed) — and resolved Option B: the spec is the artefact, the impl drifted, and the always-on error-emit substrate carries the trace to user observability stacks. The category is catalogued in 009 §Error event catalogue; host adapters for non-Ring substrates (Express, Fastify, plain servlet) MUST emit the same category when their resolve-head equivalent's catch arm fires.

EP-0008 executes this ruling. "the always-on error-emit substrate carries the trace" had been only PARTIALLY true: resolve-head emitted via the dev-gated re-frame.trace/emit-error!, which DCEs / -Dre-frame.debug=false-elides — so an off-box shipper on a production JVM SSR host saw nothing. The category is now promoted to the always-on axis (Spec 009 §Error event catalogue marks it always-on): it rides re-frame.error-emit/dispatch-error-record! ALONGSIDE the dev trace, so a register-listener! (:errors stream) consumer (Sentry / Datadog) receives the structured record under -Dre-frame.debug=false. It stays NON-PROJECTINGre-frame.ssr.error-listener/non-projection-eligible-errors skips it, so promotion ships the off-box record but NEVER flips the deliberate degraded-200 outcome. The recoverable-degradation sibling :rf.error/ssr-ring-error-view-failed was promoted in the same wave on the same NON-PROJECTING terms.

Trusted shell hook contract — host-adapter convenience opts named as TRUSTED STRINGS

Per §Trusted shell hook contract the host adapter's default-shell convenience opts (:head, :body-end, :script-src, :app-element-id) are caller-trusted strings. The two content-position opts (:head, :body-end) are injected RAW into the rendered HTML envelope, no escaping; the two attribute-value-position opts (:script-src, :app-element-id) are escape-attr-escaped at the shell (position-correct attribute encoding, not a sandbox). The security audit (parent finding, closed; local-only doc) surfaced the documented-vs-undocumented gap: the four opts had always been trusted strings in practice, but the trust semantic was not normatively named, so apps wiring any of them from untrusted input (a CMS field, a tenant-admin form) had no spec-level signal that they were opting into an arbitrary-script-injection XSS vector. resolved by (a) NAMING the four opts as trusted-string surfaces at the spec level, (b) adding construction-time structural-shape validation (:rf.error/ssr-trusted-shell-opt-invalid rejects maps / vectors / symbols / numbers — the framework catches the structural mistake even though it does not gate the content), and (c) documenting the structured alternatives (reg-head for head fragments; reg-view* + :rf.server/* fx for body content; :rf.server/set-header for header-shaped customization) for untrusted-customization use cases. A later refinement split the two attribute-value-position opts off the raw-content treatment: a stray \" in an otherwise-benign id / bootstrap URL was breaking out of its attribute and emitting structurally-broken markup with no signal, so those two are now escape-attr-escaped (lossless, position-correct) while :head / :body-end (content positions, no single-correct escape) stay raw. The trust call itself remains the caller's — the framework names the boundary, validates the shape, encodes the attribute-value opts for their position, and points at the structured surfaces; it does not pretend to gate content the caller declares trusted.

SSR ships in a separate Maven artefact (day8/re-frame2-ssr)

Per the abstract's CLJS-reference artefact statement, the SSR surface (re-frame.ssr namespace, the seven :rf.server/* per-request fxs, the reg-error-projector registry kind, the FNV-1a render-tree hash, the data-rf2-source-coord annotation, the SSR error-projection trace listener) ships in a separate Maven artefact, not the core. Apps that don't render server-side build an :advanced bundle clean of every re-frame.ssr / :rf.ssr/* / :rf.server/* symbol and trace string. The per-feature artefact split (under Strategy B) was chosen over a single-jar build with build-time elision because the optional-dependency boundary is cleaner to communicate and the static-classpath cost-of-presence is zero. See MIGRATION §M-32.

Cross-references

  • 011-SSR.md — the goal-level statement and rationale.
  • 002-Frames.md — frame lifecycle (per-request frames are the same shape as multi-instance / per-test).
  • 004-Views.md — view contract; this Spec forces the (state, props) → render-tree pure-fn shape at the pattern level.
  • 008-Testing.md — JVM-runnable scope; SSR moves view rendering across that line.
  • 009-Instrumentation.md — hydration-mismatch trace events.

  1. Post-emit regex injection (matching the first <tag opener of shape <[a-zA-Z][^\s>/]* in the emitted body string, with the letter-prefix skipping a <!DOCTYPE html> prefix) is a valid alternative for ports whose substrate lacks a hiccup-walk equivalent. It is not the canonical mechanism: it over-injects onto non-DOM roots (a tree that resolves to text or to a fragment placeholder gets a spurious attribute on whatever opener follows in the output) and cannot honour the :<> / :> exemption. Ports using the regex form MUST document the divergence and accept the edge-case mismatch with the CLJS reference. - Client renders the root view, computes the same hash on the client-side render-tree, and compares.