The client-held recovery snapshot: signing, verification, and hydration.
This is the piece that lets a live view survive a deploy, a
server restart, or a dropped connection — and it is extra machinery LiveView
does not need. The BEAM's process durability lets Phoenix assume the resident
process usually survives; the JVM cannot, so :recoverable state has to live
somewhere a server restart cannot reach. That means the browser, which means it
crosses a trust boundary and has to be treated as hostile on the way back.
{:component :counter ; which component to remount
:params {:user-id 42} ; its params
:basis "tx-1234" ; optional Source basis token
:state {:draft "typed"}} ; :recoverable fields only (tiers/snapshot)
The component name plus params is what a rebuild needs; route from the
original sketch also carried route, which was redundant with it. Everything
else in the view is
rebuilt server-side — sourced state from :mount, disposable state from its
default, derived state recomputed — so keeping it off the wire is what makes the
snapshot small enough to ride in a signal.
Three independent layers, because any one of them alone has a failure mode:
verify) so the
core stays dependency-free ; the built-in default admits only scalars
and collections, never records or arbitrary types.tiers/restore, which replays only
fields declared :recoverable. So even a validly-signed snapshot cannot
write a :sourced field or introduce an undeclared key.Layer 2 is the one worth calling out: it closes the deserialization class of hole where hydration constructs arbitrary types from wire data.
Encoded to a string and carried in an _-prefixed datastar signal, which
datastar excludes from ordinary action requests. So it costs nothing per
interaction and is asked for explicitly at reconnect.
The client-held recovery snapshot: signing, verification, and hydration.
This is the piece that lets a live view survive a deploy, a
server restart, or a dropped connection — and it is **extra machinery LiveView
does not need**. The BEAM's process durability lets Phoenix assume the resident
process usually survives; the JVM cannot, so `:recoverable` state has to live
somewhere a server restart cannot reach. That means the browser, which means it
crosses a trust boundary and has to be treated as hostile on the way back.
## What it carries
{:component :counter ; which component to remount
:params {:user-id 42} ; its params
:basis "tx-1234" ; optional Source basis token
:state {:draft "typed"}} ; :recoverable fields only (tiers/snapshot)
The component name plus params is what a rebuild needs; `route` from the
original sketch also carried `route`, which was redundant with it. Everything
else in the view is
rebuilt server-side — sourced state from `:mount`, disposable state from its
default, derived state recomputed — so keeping it off the wire is what makes the
snapshot small enough to ride in a signal.
## Defence in depth
Three independent layers, because any one of them alone has a failure mode:
1. **HMAC signature.** Detects tampering and forgery. Without it a client could
invent a snapshot naming any component with any params.
2. **Schema validation** on the decoded payload, before anything is used. The
signature proves *we* produced the bytes, not that they are still meaningful
— a snapshot signed by an older version of the app can decode to a shape the
current code does not expect. Validation is pluggable (see `verify`) so the
core stays dependency-free ; the built-in default admits only scalars
and collections, never records or arbitrary types.
3. **The tier filter**, applied later by `tiers/restore`, which replays only
fields declared `:recoverable`. So even a validly-signed snapshot cannot
write a `:sourced` field or introduce an undeclared key.
Layer 2 is the one worth calling out: it closes the deserialization
class of hole where hydration constructs arbitrary types from wire data.
## Transport
Encoded to a string and carried in an `_`-prefixed datastar signal, which
datastar excludes from ordinary action requests. So it costs nothing per
interaction and is asked for explicitly at reconnect.(create {:keys [secret]} {:keys [component params basis recoverable]})Builds a signed snapshot string for a live context.
state is the component's tier declaration and view its current value; only
:recoverable fields are included, via tiers/snapshot — which the caller
passes in as recoverable, so this namespace does not depend on tiers.
Builds a signed snapshot string for a live context. `state` is the component's tier declaration and `view` its current value; only `:recoverable` fields are included, via `tiers/snapshot` — which the caller passes in as `recoverable`, so this namespace does not depend on `tiers`.
(recoverable-state verified)The :state map from a verified snapshot, or nil.
Convenience for the reconnect path, which passes this straight to
tiers/restore — where the tier filter applies the final check on which fields
may be replayed.
The `:state` map from a verified snapshot, or `nil`. Convenience for the reconnect path, which passes this straight to `tiers/restore` — where the tier filter applies the final check on which fields may be replayed.
(safe-value? x)(safe-value? x depth)The conservative built-in validator: scalars and plain collections only.
Deliberately excludes records, types, functions, refs and anything else whose
reconstruction could run code or exhaust memory. A caller wanting richer
validation passes :validate-fn to verify — Malli, for instance — which is
how the core stays dependency-free while still supporting 's
schema-validation requirement.
Depth-limited, so a deeply nested payload cannot blow the stack during validation itself.
The conservative built-in validator: scalars and plain collections only. Deliberately excludes records, types, functions, refs and anything else whose reconstruction could run code or exhaust memory. A caller wanting richer validation passes `:validate-fn` to `verify` — Malli, for instance — which is how the core stays dependency-free while still supporting 's schema-validation requirement. Depth-limited, so a deeply nested payload cannot blow the stack during validation itself.
(verify {:keys [secret validate-fn]} signed)Verifies and decodes a snapshot string.
Returns {:ok true :snapshot {...}}, or {:ok false :reason ...} — never
throws on bad input, since every failure here is an expected condition rather
than a bug: an old signature after a secret rotation, a truncated signal, a
tampered payload.
Options:
:secret required, the HMAC key:validate-fn optional (fn [payload] -> boolean), run after the signature
and structural checks pass. Defaults to safe-value?. Pass a
Malli-backed predicate for 's schema validation without
the core taking the dependency.Order matters and is deliberate: signature first, then structure, then the validator. Nothing decoded is used for anything before the signature checks out.
Verifies and decodes a snapshot string.
Returns `{:ok true :snapshot {...}}`, or `{:ok false :reason ...}` — never
throws on bad input, since every failure here is an expected condition rather
than a bug: an old signature after a secret rotation, a truncated signal, a
tampered payload.
Options:
- `:secret` required, the HMAC key
- `:validate-fn` optional `(fn [payload] -> boolean)`, run after the signature
and structural checks pass. Defaults to `safe-value?`. Pass a
Malli-backed predicate for 's schema validation without
the core taking the dependency.
Order matters and is deliberate: signature first, then structure, then the
validator. Nothing decoded is used for anything before the signature checks
out.cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |