Opt-in per-tab stream deduplication with linearizable claims.
The registry is a single coordinator process. It serializes the small, non-blocking state transition for every claim, while handover remains asynchronous and bounded per key. A successful claim means the caller owns the key at that linearization point; later contenders replace it in the same total order.
Problem
With full-page navigation, SSE stream processes do not learn that the client disconnected until their next write. Stale processes keep subscriptions, perform wasted work, and can exhaust HTTP/1.1's per-origin connection limit.
Setup
Add the registry to your application's supervision tree:
children = [
Dstar.Utility.StreamRegistry,
# ...
]Then put a per-tab id in your root layout:
<body data-signals:tab-id="sessionStorage.getItem('_ds_tab') || (() => { const id = crypto.randomUUID(); sessionStorage.setItem('_ds_tab', id); return id; })()">sessionStorage is per-tab. Use data-signals:tab-id, not tabId (HTML
lowercases attribute names and Datastar camelizes the hyphenated form), and
do not prefix the signal with _ because Datastar keeps underscore-prefixed
signals client-side.
Claim and handover contract
start_stream/2,3 reads the validated tabId, atomically claims
{scope_key, tab_id}, and only then starts SSE. Missing or invalid tabId
intentionally falls back to an ordinary, non-deduplicated stream for rollout
compatibility. A valid keyed request whose claim fails instead returns a
halted, non-SSE 503 response — it never starts an untracked stream.
On replacement, the old holder receives a generation-tagged exit signal.
Dstar.Stream.run/2 (which Dstar.Page streams run on) recognizes only
the generation stored on its conn, synchronously releases it, and then
performs graceful application teardown. A trapping holder that does not
release is killed after the grace period. Release and escalation are
serialized by this coordinator, so a release that has returned cannot be
followed by a stale kill against a keep-alive process now serving unrelated
work. Stale replacement messages from an older generation cannot stop a newer
stream in the same process.
The coordinator monitors every claim, so process death also cleans it up. It remains the package's only opt-in process.
Summary
Functions
Returns a specification to start this module under a supervisor.
Synchronously releases the exact stream claim stored on conn.
Replaces the active owner of key and atomically claims it for the caller.
Starts an SSE stream with per-tab deduplication.
Returns a fetched signal map's (or already-fetched conn's) usable tabId.
Releases every active or handing-over claim owned by the calling process.
Types
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec release(Plug.Conn.t()) :: :ok
Synchronously releases the exact stream claim stored on conn.
This is the preferred teardown operation for keyed streams. A stale conn can release only its own generation and can never clear a newer claim held by a reused keep-alive process. Unkeyed conns are a no-op.
Replaces the active owner of key and atomically claims it for the caller.
Returns :ok only after the coordinator has made the caller the active owner
at a linearization point. Concurrent contenders are ordered by the
coordinator; each may succeed and then be replaced by a later claim. The
displaced generation remains tracked through its bounded handover until it
releases or is escalated.
The holder's internal exit signal is
{:EXIT, registry_pid, {:replaced, claim_generation}}. Dstar.Stream
(and so Dstar.Page) validates the generation and presents the legacy
{:EXIT, registry_pid, :replaced} shape to application callbacks.
Controllers should use Dstar.Stream; hand-rolled loops should use
start_stream/2,3 and release/1 rather than consuming this internal
signal directly.
@spec start_stream(Plug.Conn.t(), term(), keyword()) :: Plug.Conn.t()
Starts an SSE stream with per-tab deduplication.
Signals are fetched through Dstar.Signals.fetch/2. Malformed/non-object
input receives 400 and oversized input receives 413 before any claim or SSE
start. Pass max_bytes: n as the third argument to change that input limit.
With a usable tabId, the claim is fail-closed: success stores its ownership
generation in conn.private and starts SSE; coordinator failure returns a
halted plain-text 503 conn without calling Dstar.start/1. With no usable
tabId, the documented non-deduplicated fallback still starts ordinary SSE.
@spec tab_id(Plug.Conn.t() | map()) :: String.t() | nil
Returns a fetched signal map's (or already-fetched conn's) usable tabId.
A usable id is a non-whitespace binary of 1..64 bytes. The
exact client-supplied value becomes part of the key; no normalization occurs.
This helper does not own raw bodies — call start_stream/2,3 or
Dstar.Signals.fetch/2 first when params are unfetched.
@spec unregister_self() :: :ok
Releases every active or handing-over claim owned by the calling process.
The call is synchronous. Once it returns, no escalation for a released
generation can kill that process. This remains as a compatibility helper for
code that can hold multiple manual claims; new hand-rolled loops should call
release/1 with their stream conn. It is a no-op when the opt-in coordinator
is not running.