defmodule DurableStash do
@moduledoc """
Keep Phoenix LiveView state alive across reconnects, crashes, and redeploys.
A LiveView's assigns live in memory and vanish when the socket drops, the
process crashes, or you deploy. `DurableStash` saves the assigns you pick to
object storage and restores them the next time the LiveView mounts. The saved
copy belongs to the browser session, so every LiveView the user opens shares
it. Under the hood it plugs into
[LiveStash](https://hex.pm/packages/live_stash) and stores state through
[DurableServer](https://hex.pm/packages/durable_server). State dies with the
browser session.
## Usage
defmodule MyAppWeb.SomeLive do
use MyAppWeb, :live_view
use LiveStash, adapter: DurableStash, stored_keys: [:count, :username]
def mount(_params, _session, socket) do
socket = assign(socket, count: 0, username: nil)
{_status, socket} = LiveStash.recover_state(socket)
{:ok, socket}
end
end
Call `LiveStash.recover_state/1` in `mount/3` *after* assigning defaults
(recovered values overwrite them), and `LiveStash.stash/1` whenever a
stored assign changes — or pass `auto_stash: true`.
Unlike the stock ETS adapter, `DurableStash` recovers on **every** mount —
fresh navigations included — and never deletes the stash on mount. State is
keyed by the browser session, not by the socket.
## Setup
1. Register the adapter and configure the backend:
config :live_stash, adapters: [DurableStash]
config :durable_stash,
backend: {DurableServer.Backends.ObjectStore, bucket: "...", ...},
prefix: "durable_stash/",
secret: "some-stable-secret"
With that config, `LiveStash.Application` starts the DurableServer
supervisor automatically through `child_spec/1`. Alternatively, run your
own `DurableServer.Supervisor` and point the adapter at it with the
`:supervisor` option.
2. Put a session id into the cookie session, in the `:browser` pipeline
after `plug :fetch_session`:
plug :ensure_session_id
defp ensure_session_id(conn, _opts) do
if get_session(conn, "sid") do
conn
else
sid = 16 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
put_session(conn, "sid", sid)
end
end
## Scopes
Not all state wants the same recovery policy. Each stored key declares a
scope:
use LiveStash, adapter: DurableStash,
stored_keys: [
theme: :session, # recover on every mount (the default)
draft: :reconnect # recover only on reconnects; cleared on fresh mounts
]
* `:session` — recovered on every mount: live navigation, reconnects,
crashes, redeploys. Right for settings the user expects to stick.
* `:reconnect` — recovered only when the client *rejoins* an existing
view (`_mounts > 0`): Wi-Fi drops, LiveView crashes, and redeploys —
the browser stays on the page through all of these. A fresh navigation
to the view clears the stored values, so starting a "new thing" starts
blank. Right for in-progress form drafts.
## Options (via `use LiveStash, adapter: DurableStash, ...`)
* `:stored_keys` (required) — assigns to persist. Bare atoms mean
`:session` scope; see *Scopes* above for `:reconnect`. `:permanent` is
reserved and raises for now.
* `:vsn` (default `1`) — version of this view's stored shape. On
recovery, a stored slice with a different vsn is discarded to defaults
unless `:migrate` is given.
* `:migrate` — 2-arity function `(old_vsn, data) :: data` receiving the
stored string-keyed data map and returning the migrated one. The
migrated set is written back under the new vsn.
* `:supervisor` — DurableServer supervisor name (default
`config :durable_stash, :supervisor_name`, falling back to
`DurableStash.Supervisor`).
* `:secret` — mixed into the storage-key hash (default
`config :durable_stash, :secret`).
* `:session_id_key` — cookie-session key holding the session id
(default `"sid"`).
## What's storable
JSON-safe values only: no structs, tuples, pids, or functions; maps come
back with string keys. Offending values are skipped with a logged error, or
raise when `config :durable_stash, on_invalid_value: :raise` is set
(recommended for dev and test). Values are normalized through a JSON
round-trip at stash time, so what you recover in dev is byte-for-byte what
you'd recover after a redeploy in prod.
"""
@behaviour LiveStash.Adapter
require Logger
alias DurableStash.Session
alias Phoenix.Component
alias Phoenix.LiveView
defmodule Context do
@moduledoc false
defstruct supervisor: nil,
storage_key: nil,
view: nil,
stored_keys: [],
vsn: 1,
migrate: nil,
fingerprints: %{},
reconnected?: false
end
@private_key :durable_stash
@default_secret "durable_stash"
@default_session_id_key "sid"
## LiveStash.Adapter callbacks
@impl true
def init_stash(socket, session, opts) do
stored_keys = parse_stored_keys!(Keyword.fetch!(opts, :stored_keys))
vsn = Keyword.get(opts, :vsn, 1)
migrate = parse_migrate!(Keyword.get(opts, :migrate))
supervisor =
Keyword.get(opts, :supervisor) ||
Application.get_env(:durable_stash, :supervisor_name, DurableStash.Supervisor)
secret =
Keyword.get(opts, :secret) ||
Application.get_env(:durable_stash, :secret, @default_secret)
session_id_key = Keyword.get(opts, :session_id_key, @default_session_id_key)
context = %Context{
supervisor: supervisor,
storage_key: derive_storage_key(session, session_id_key, secret),
view: view_name(socket),
stored_keys: stored_keys,
vsn: vsn,
migrate: migrate,
reconnected?: reconnected?(socket)
}
clear_stale_reconnect_keys(socket, context)
LiveView.put_private(socket, @private_key, context)
end
@impl true
def stash(socket) do
case operable_context(socket) do
{:ok, context} -> do_stash(socket, context)
:error -> socket
end
end
@impl true
def recover_state(socket) do
case operable_context(socket) do
{:ok, context} -> do_recover(socket, context)
:error -> {:error, socket}
end
end
@impl true
def reset_stash(socket) do
case operable_context(socket) do
{:ok, context} -> do_reset(socket, context)
:error -> socket
end
end
@doc """
Starts the DurableServer supervisor from `:durable_stash` config when a
`:backend` is configured; otherwise starts an empty, harmless supervisor.
Invoked automatically by `LiveStash.Application` for registered adapters.
"""
@impl true
def child_spec(_args) do
%{
id: __MODULE__,
start: {__MODULE__, :start_configured_supervisor, []},
type: :supervisor
}
end
@doc false
def start_configured_supervisor do
children =
case Application.get_env(:durable_stash, :backend) do
nil ->
[]
backend ->
supervisor_opts = Application.get_env(:durable_stash, :supervisor_opts, [])
[
{DurableServer.Supervisor,
[
name:
Application.get_env(:durable_stash, :supervisor_name, DurableStash.Supervisor),
prefix: Application.get_env(:durable_stash, :prefix, "durable_stash/"),
backend: backend
] ++ supervisor_opts}
]
end
Supervisor.start_link(children, strategy: :one_for_one)
end
## Stash
defp do_stash(socket, %Context{} = context) do
{changes, fingerprints} = changed_entries(socket.assigns, context)
if changes == %{} do
socket
else
case call_session(context, &Session.merge(&1, context.view, changes, context.vsn)) do
:ok ->
put_context(socket, %{context | fingerprints: fingerprints})
{:error, reason} ->
Logger.error("[DurableStash] stash failed for #{context.view}: #{inspect(reason)}")
socket
end
end
end
defp changed_entries(assigns, %Context{} = context) do
context.stored_keys
|> Enum.map(fn {key, _scope} -> key end)
|> Enum.reduce({%{}, context.fingerprints}, fn key, {changes, fingerprints} ->
with {:ok, value} <- Map.fetch(assigns, key),
{:ok, encoded} <- encode_value(key, value) do
name = Atom.to_string(key)
fingerprint = fingerprint(encoded)
if fingerprints[name] == fingerprint do
{changes, fingerprints}
else
{Map.put(changes, name, JSON.decode!(encoded)),
Map.put(fingerprints, name, fingerprint)}
end
else
:error -> {changes, fingerprints}
{:skip, _key} -> {changes, fingerprints}
end
end)
end
defp encode_value(key, value) do
{:ok, JSON.encode!(value)}
rescue
error ->
message =
"[DurableStash] value for #{inspect(key)} is not JSON-safe " <>
"(no structs, tuples, pids, or functions): #{Exception.message(error)}"
case Application.get_env(:durable_stash, :on_invalid_value, :log) do
:raise ->
reraise ArgumentError.exception(message), __STACKTRACE__
_log ->
Logger.error(message)
{:skip, key}
end
end
## Recover
defp do_recover(socket, %Context{} = context) do
case call_session(context, &Session.fetch_view(&1, context.view)) do
:not_found ->
{:not_found, socket}
{:ok, %{"vsn" => stored_vsn, "data" => data}} ->
recover_slice(socket, context, stored_vsn, data)
{:error, reason} ->
Logger.error("[DurableStash] recover failed for #{context.view}: #{inspect(reason)}")
{:error, socket}
end
end
defp recover_slice(socket, %Context{vsn: vsn} = context, vsn, data) do
apply_recovered(socket, context, data)
end
defp recover_slice(socket, %Context{migrate: nil}, _stored_vsn, _data) do
# Version mismatch without a migration: UI-grade state, discard to
# defaults. The next stash overwrites the slice under the current vsn.
{:not_found, socket}
end
defp recover_slice(socket, %Context{} = context, stored_vsn, data) do
migrated = context.migrate.(stored_vsn, data)
case apply_recovered(socket, context, migrated) do
{:recovered, recovered_socket} ->
write_back_migrated(recovered_socket, migrated)
other ->
other
end
rescue
error ->
Logger.error(
"[DurableStash] migrate from vsn #{stored_vsn} failed for #{context.view}: " <>
Exception.message(error)
)
{:error, socket}
end
# Persist the full migrated set under the new vsn so the slice can never
# end up as a mix of old- and new-shape keys.
defp write_back_migrated(socket, migrated) do
context = socket.private[@private_key]
restored = Map.take(migrated, Enum.map(recoverable_keys(context), &Atom.to_string/1))
case call_session(context, &Session.merge(&1, context.view, restored, context.vsn)) do
:ok ->
{:recovered, socket}
{:error, reason} ->
Logger.error(
"[DurableStash] migrated write-back failed for #{context.view}: #{inspect(reason)}"
)
{:recovered, socket}
end
end
defp apply_recovered(socket, %Context{} = context, data) do
{recovered, fingerprints} =
context
|> recoverable_keys()
|> Enum.reduce({%{}, context.fingerprints}, fn key, {recovered, fingerprints} ->
# Atom keys come from the declared whitelist — never String.to_atom
# on stored input.
case Map.fetch(data, Atom.to_string(key)) do
{:ok, value} ->
fingerprint = fingerprint(JSON.encode!(value))
{Map.put(recovered, key, value),
Map.put(fingerprints, Atom.to_string(key), fingerprint)}
:error ->
{recovered, fingerprints}
end
end)
if recovered == %{} do
{:not_found, socket}
else
socket
|> Component.assign(recovered)
|> put_context(%{context | fingerprints: fingerprints})
|> then(&{:recovered, &1})
end
end
## Reset
defp do_reset(socket, %Context{} = context) do
case call_session(context, &Session.reset_view(&1, context.view)) do
:ok ->
put_context(socket, %{context | fingerprints: %{}})
{:error, reason} ->
Logger.error("[DurableStash] reset failed for #{context.view}: #{inspect(reason)}")
socket
end
end
## Session access
defp call_session(%Context{} = context, fun) do
case DurableServer.Supervisor.ensure_started_child(
context.supervisor,
{Session, key: context.storage_key, initial_state: %{}}
) do
{:ok, {pid, _meta}} -> fun.(pid)
{:error, reason} -> {:error, reason}
:ignore -> {:error, :ignore}
end
catch
kind, reason -> {:error, {kind, reason}}
end
## Context plumbing
defp operable_context(socket) do
case socket.private[@private_key] do
%Context{} = context ->
if operable?(context), do: {:ok, context}, else: :error
nil ->
Logger.error(
"[DurableStash] no stash context on socket — did you `use LiveStash, adapter: DurableStash`?"
)
:error
end
end
defp operable?(%Context{storage_key: storage_key, view: view}) do
is_binary(storage_key) and is_binary(view)
end
defp put_context(socket, %Context{} = context) do
LiveView.put_private(socket, @private_key, context)
end
defp derive_storage_key(session, session_id_key, secret) do
case session do
%{^session_id_key => sid} when is_binary(sid) ->
:sha256
|> :crypto.hash(sid <> secret)
|> Base.url_encode64(padding: false)
_session ->
Logger.error(
"[DurableStash] no #{inspect(session_id_key)} in the cookie session — " <>
"add the ensure_session_id plug to your :browser pipeline (see the DurableStash docs)"
)
nil
end
end
defp view_name(%{view: view}) when is_atom(view) and not is_nil(view), do: Atom.to_string(view)
defp view_name(_socket) do
Logger.error("[DurableStash] socket has no view — cannot scope the stash")
nil
end
defp keys_for_scope(%Context{stored_keys: stored_keys}, scope) do
for {key, ^scope} <- stored_keys, do: key
end
defp recoverable_keys(%Context{reconnected?: true} = context) do
keys_for_scope(context, :session) ++ keys_for_scope(context, :reconnect)
end
defp recoverable_keys(%Context{} = context) do
keys_for_scope(context, :session)
end
# A reconnect (Wi-Fi drop, LiveView crash, deploy) rejoins the same client
# view, so `_mounts` is positive. A fresh navigation mounts at zero.
defp reconnected?(socket) do
match?(%{"_mounts" => mounts} when is_integer(mounts) and mounts > 0, connect_params(socket))
end
defp connect_params(socket) do
if LiveView.connected?(socket) do
LiveView.get_connect_params(socket) || %{}
else
%{}
end
rescue
# get_connect_params is only available while mounting; treat anything
# else as a fresh mount.
_error -> %{}
end
# Stock LiveStash semantics for :reconnect keys: a fresh mount starts the
# task over, so the stored values must go — otherwise a crash right after
# navigating here would resurrect a stale draft.
defp clear_stale_reconnect_keys(socket, %Context{} = context) do
reconnect_keys = keys_for_scope(context, :reconnect)
if reconnect_keys != [] and LiveView.connected?(socket) and not context.reconnected? and
operable?(context) do
keys = Enum.map(reconnect_keys, &Atom.to_string/1)
case call_session(context, &Session.drop(&1, context.view, keys)) do
:ok ->
:ok
{:error, reason} ->
Logger.error(
"[DurableStash] clearing reconnect keys failed for #{context.view}: #{inspect(reason)}"
)
end
end
:ok
end
defp fingerprint(encoded) when is_binary(encoded) do
:crypto.hash(:sha256, encoded)
end
## Option parsing — config errors should be loud, so these raise.
defp parse_stored_keys!(stored_keys) when is_list(stored_keys) do
Enum.map(stored_keys, fn
key when is_atom(key) ->
{key, :session}
{key, scope} when is_atom(key) and scope in [:session, :reconnect] ->
{key, scope}
{key, :permanent} when is_atom(key) ->
raise ArgumentError,
"[DurableStash] the :permanent scope is not yet supported (key #{inspect(key)})"
other ->
raise ArgumentError,
"[DurableStash] invalid stored_keys entry: #{inspect(other)} — " <>
"expected an atom, `{atom, :session}`, or `{atom, :reconnect}`"
end)
end
defp parse_stored_keys!(other) do
raise ArgumentError, "[DurableStash] stored_keys must be a list, got: #{inspect(other)}"
end
defp parse_migrate!(nil), do: nil
defp parse_migrate!(migrate) when is_function(migrate, 2), do: migrate
defp parse_migrate!(other) do
raise ArgumentError,
"[DurableStash] :migrate must be a 2-arity function (old_vsn, data), got: #{inspect(other)}"
end
end