Dstar
The batteries-included Datastar toolkit for Elixir. SSE helpers, event dispatch, CSRF handling, stream deduplication — everything you need to ship Datastar apps, not just the wire protocol.
Successor to PhoenixDatastar.
Why Dstar?
Other libraries give you SSE primitives and leave the rest to you. Dstar gives you the primitives and the utilities you'd end up building yourself:
- Pages —
use Dstar.Pageputs render, event handlers, streaming callbacks, and components in one module. One router line wires it. - Event dispatch — One route, unlimited handlers.
Dstar.Plugs.Dispatchroutes events to handler modules by convention, so you never hand-wire a route per action. - URL generation —
Dstar.post/2,Dstar.get/2,Dstar.delete/2generate@post(...)expressions with correct paths. No hand-written URLs in templates. - CSRF handling — Phoenix wants
_csrf_token, but Datastar keeps_-prefixed signals client-side. So the token travels as a non-prefixed signal, andDstar.Plugs.RenameCsrfParammaps it back — one plug, one<body>attribute, and forgery protection just works. - Stream deduplication —
Dstar.Utility.StreamRegistrykills zombie SSE processes when users navigate between pages. One process per tab, always. - Console logging —
Dstar.console_log/2sends log/warn/error messages straight to the browser DevTools. Debug from the server, read in the browser. - Phoenix.HTML support —
patch_elementsaccepts both raw strings andPhoenix.HTML.safe()tuples, so HEEx template output works without conversion.
The functional core is still a small bag of functions with no processes. The page layer on top is one behaviour, one plug, and two router macros — all opt-in, all readable. The one optional process — StreamRegistry — is opt-in only if you need stream deduplication.
Drop it into any Plug-based app: Phoenix controllers, plain Plug, Bandit. If you have a %Plug.Conn{}, you can use Dstar.
Installation
Add dstar to your deps in mix.exs:
def deps do
[
{:dstar, "~> 0.2.0"}
]
end
Pages need {:phoenix, "~> 1.7"} and {:phoenix_live_view, "~> 1.0"} in your app (any Phoenix app already has them). The functional core needs neither.
Then add the Datastar client script to your root layout's <head>:
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0/bundles/datastar.js"></script>
That's it. No generators, no config, no application callback.
Quick Start
A page is one module and one router line.
# router.ex
import Dstar.Router
dstar "/counter", MyAppWeb.CounterPage
defmodule MyAppWeb.CounterPage do
use Dstar.Page
# GET — load data, assign, render
def mount(conn, _params) do
assign(conn, count: 0, page_title: "Counter")
end
def render(assigns) do
~H"""
<div data-signals:count={@count}>
<h1 data-text="$count"></h1>
<span id="history">—</span>
<button data-on:click={event("increment")}>+1</button>
<button data-on:click={event("reset")}>Reset</button>
</div>
"""
end
# POST /counter/_event/<name> — SSE already started for you
def handle_event(conn, "increment", signals) do
count = (signals["count"] || 0) + 1
conn
|> patch_signals(%{count: count})
|> patch(&history/1, last: "+1 → #{count}")
end
def handle_event(conn, "reset", _signals) do
conn
|> patch_signals(%{count: 0})
|> patch(&history/1, last: "Reset")
|> console_log("Counter reset")
end
# Colocated components — used by render/1 and by patches alike
defp history(assigns) do
~H"""
<span id="history">Last: {@last}</span>
"""
end
end
That's the whole page. Notice what's absent:
- No separate controller, HTML, or components module — one file.
- No
handler={...}/prefix={...}threading:event("increment")resolves its URL in the browser (location.pathname + '/_event/...'), so path params like/:workspace_slugneed no server-side plumbing. - No
Dstar.start()— event POSTs are SSE by definition, so the library starts the stream before calling you. - No allowlist registration — the
dstarroute is the allowlist.
Routing through
:protect_from_forgery? Event POSTs need the CSRF token as a signal — one plug plus one<body>attribute. See CSRF Protection Setup.
Streaming
Declare how to subscribe; the library owns the receive loop:
# In the same page module:
def handle_connect(conn, _params) do
MyAppWeb.Endpoint.subscribe("ticker")
conn
end
def handle_info(%Phoenix.Socket.Broadcast{payload: p}, conn) do
patch_signals(conn, %{tick: p.count})
end
<div data-init={connect()} data-on:online__window={connect()}>
<span data-text="$tick"></span>
</div>
The loop checks connection liveness every 30s (tune with
use Dstar.Page, idle_check: 10_000) and survives stray messages. Add a
stream_key/1 callback to enable per-tab stream deduplication via
Dstar.Utility.StreamRegistry.
When the loop ends — a {:halt, conn}, a dead client, or a takeover by a
newer stream for the same key — the library unregisters the stream from
Dstar.Utility.StreamRegistry. App-owned state (PubSub subscriptions,
presence, caches) is yours to release in an optional handle_disconnect/1:
def handle_disconnect(conn) do
Phoenix.PubSub.unsubscribe(MyApp.PubSub, "room:#{conn.assigns.room_id}")
end
This matters on HTTP/1.1 keep-alive, where the connection process outlives the stream and goes on to serve unrelated requests.
Shared components
UI used across many pages — with its event handlers in the same module:
defmodule MyAppWeb.DetailDrawer do
use Dstar.Component
def drawer(assigns) do
~H"""
<div id="detail-drawer">
<input data-on:change={event("change_title:#{@item.id}")} value={@item.title} />
</div>
"""
end
def handle_event(conn, "change_title:" <> id, signals) do
# update the record, then patch
conn |> start() |> patch_signals(%{saved: true})
end
end
# router.ex — one line for ALL components:
dstar_components "/ds", [MyAppWeb.DetailDrawer]
Pages embed <MyAppWeb.DetailDrawer.drawer item={@item} /> and need zero
handle_event clauses for it. If your app mounts routes under a prefix,
declare the dispatch base once in the root layout: <body data-ds-base={...}>
(defaults to /ds; it must match the base given to dstar_components/2,
including any app path prefix).
Unlike page handlers, component handlers call start() themselves — the dispatch plug doesn't start the SSE response for them.
The functional core
Everything above is built from these functions. Use them directly in plain controllers, custom plugs, or anywhere you have a %Plug.Conn{} — pages are optional sugar, the core is the contract.
Everything goes through the Dstar convenience module, which delegates to the lower-level modules listed below. Full signatures, options and examples live in the API docs — this table is a map, not a reference.
| Function | Does |
|---|---|
start/1 | Open an SSE connection (text/event-stream, no-cache, chunked). |
start_stream/2 | Open an SSE stream with per-tab dedup. Needs Dstar.Utility.StreamRegistry. |
check_connection/1 | {:ok, conn} / {:error, conn} — is the client still there? |
read_signals/1 | Read the request's Datastar signals as a map. |
patch_signals/2,3 | Patch signals on the client. |
remove_signals/2,3 | Remove signals by dot-notated path (or list of paths). |
nudge/2,3 | Tell tabs a collection changed, so each reloads with its own filters. |
patch_elements/3 | Patch DOM elements. Needs a :selector, or ids on the HTML roots. |
remove_elements/2,3 | Remove DOM elements by selector. |
append_elements/3,4 | Append HTML as the last child of a container. |
upsert_elements/2,3 | Morph the element whose id matches the HTML root. |
execute_script/2,3 | Run JavaScript on the client. |
redirect/2,3 | Navigate the client to a URL. |
console_log/2,3 | Log to the browser console. |
post/1,2,3get/1,2,3put/1,2,3patch/1,2,3delete/1,2,3 | Build @post(...)-style action expressions for Datastar attributes. Arity 1 is the dynamic form (Dstar.post("increment")), which resolves the module client-side. |
Real-time Streaming
With Dstar.Page, declare subscriptions in handle_connect/2 and implement handle_info/2 — the library owns the loop (see Quick Start). The hand-rolled loop below remains fully supported for plain controllers:
defmodule MyAppWeb.TickerController do
use MyAppWeb, :controller
def stream(conn, _params) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "ticker")
conn = Dstar.start(conn)
loop(conn)
end
defp loop(conn) do
receive do
{:tick, count} ->
# Optional: check connection health
case Dstar.check_connection(conn) do
{:ok, conn} ->
conn = Dstar.patch_signals(conn, %{tick: count})
loop(conn)
{:error, _conn} ->
# Client disconnected, clean up
Phoenix.PubSub.unsubscribe(MyApp.PubSub, "ticker")
:ok
end
end
end
end
Template:
Use @post with retryMaxCount: Infinity — Datastar handles reconnection
automatically. Add data-on:online__window to reconnect when the browser
comes back online (laptop lid, WiFi drop, etc.):
<div data-signals:tick="0"
data-init="@post('/ticker/stream', {retryMaxCount: Infinity})"
data-on:online__window="@post('/ticker/stream', {retryMaxCount: Infinity})">
<span data-text="$tick"></span>
</div>
No keepalive loop needed on the server. Datastar's built-in retry handles
dropped connections, and online__window re-establishes the stream when the
network returns.
The library provides the SSE plumbing. Your app provides the PubSub topic and the business logic.
Live Collections
Keeping a list current in every open tab is the most common streaming payload, and the obvious approach is a trap. The stream opened at page load and SSE is one-way, so it can never learn a tab's current filter, sort or page — blindly appending or morphing rows produces silently wrong UIs in every tab that isn't showing a plain feed.
The default is the nudge: push only "this data changed", and let each tab re-run its own load action, which carries that tab's signals.
# Default — correct for filtered/sorted/paginated views
def handle_info({:posts_changed, _}, conn), do: nudge(conn, "posts")
# Fast path — plain, unfiltered feed only
def handle_info({:post_created, post}, conn),
do: append_elements(conn, post_row(%{post: post}), "#posts")
<div id="posts" {on_nudge("posts", event("reload"))}>
Full pattern, DOM-id discipline and the consistency window: Live collections.
Stream Deduplication (Optional)
With full-page navigation, SSE stream processes don't learn the client disconnected until they try to write — which only happens on the next PubSub broadcast or keepalive tick. In the meantime, zombie processes hold subscriptions, run wasted DB queries on every broadcast, and on HTTP/1.1 can exhaust the browser's 6-connection-per-origin limit.
Dstar.Utility.StreamRegistry fixes this. It tracks one stream process
per user+tab. When a new stream opens from the same tab, the previous
one is killed instantly — zero-delay cleanup, no wasted work.
This is the one process in Dstar. It's opt-in: if you don't need it, the library stays zero-process. If you do, you add one child to your existing supervision tree.
With
Dstar.Page, just define astream_key/1callback —Dstar.Page.Plugcallsstart_stream/2for you. The manualstart/start_streamswap in step 3 below applies to hand-rolled controller loops.
1. Add to your supervision tree
# lib/my_app/application.ex
children = [
Dstar.Utility.StreamRegistry,
# ...
]
2. Add a tabId signal to 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 — each tab gets its own UUID that persists
across navigations but is unique per tab. Multiple tabs work independently.
Why
tab-idand nottabId? HTML lowercases attribute names, sodata-signals:tabIdarrives astabidand creates a signal namedtabid— which never matches thetabIdthe registry reads, leaving dedup silently disabled. Datastar camelizes on hyphens, sotab-idis what producestabId. This applies to every multi-worddata-signals:*/data-bind:*attribute.
Why not
_tabId? Datastar treats_-prefixed signals as client-only and never sends them to the server. The signal needs to reach the backend, so it must not have a_prefix.
3. Replace Dstar.start(conn) in stream controllers
- conn = Dstar.start(conn)
+ conn = Dstar.start_stream(conn, scope.user.id)
The second argument is any term that identifies the user or session
(e.g., user.id, {user.id, workspace.id}). The registry keys on
{scope_key, tab_id} so different users and different tabs never collide.
If no tabId signal is present in the request, start_stream/2 falls
back to Dstar.start/1 — so existing streams keep working while you
roll out the client-side signal.
What it does
| Scenario | Before | After |
|---|---|---|
| User clicks 5 pages in 3s (same tab) | 5 zombie processes doing wasted PubSub work | 1 process per tab, always |
| 3 tabs open | 3 streams (fine) | 3 streams (unchanged) |
| 100 users rapid nav | Spikes of zombies doing wasted DB queries | Max 100 processes, zero wasted work |
Deduplication vs. auto-reconnect
A replaced stream that ends cleanly — the ordinary HTTP/1.1 takeover,
where the loop halts and the response terminates properly — does not
reconnect: the Datastar client only reconnects after a clean end when you
set retry: "always" (the default is "auto").
When the takeover ends the stream as a transport error instead, the two features fight: tab A reconnects, replacing tab B, which reconnects, forever. That happens on HTTP/2 (the stream process is killed outright rather than halting) and on the registry's kill escalation.
retryMaxCount cannot stop that loop. It counts consecutive failures to
connect, and the client resets it — and the backoff interval — on every
200, so a loop that connects successfully each pass never accumulates a
budget. Only 0 breaks the cycle:
<div data-init={connect(opts: "{retryMaxCount: 0}")}>
Note this also disables reconnection after ordinary network blips, so apply it to the deduplicated stream, not indiscriminately.
SSE Connection Limits & HTTP/2
Browsers allow only 6 concurrent HTTP/1.1 connections per domain. Each SSE stream holds one connection open. With rapid navigation, zombie streams (server hasn't noticed the client left yet) plus the new page's stream can exhaust the pool — silently stalling all requests to that domain: fetches, asset loads, even page navigation. The page appears to hang with no error.
HTTP/2 fixes this. It multiplexes ~100 streams over a single TCP connection, so SSE streams no longer compete with other requests. Bandit (Phoenix's default adapter) auto-negotiates HTTP/2 over TLS — no extra config beyond enabling HTTPS.
Enable HTTPS in dev
The fast path is the built-in task (mkcert required, brew install mkcert nss on macOS):
mix dstar.https
It adds a my-app.test entry to /etc/hosts and generates a
browser-trusted certificate via mkcert's local CA — no certificate
warnings, and tools that reject self-signed certs keep working. It asks
before touching anything (--dry-run to preview, mix help dstar.https
for all options), then prints the config/dev.exs snippet to apply.
Steps 3–4 and 6 below still apply.
Manual setup (no mkcert)
- Generate a self-signed certificate:
mix phx.gen.cert
If mix phx.gen.cert fails (missing :public_key on some OTP versions), use openssl:
mkdir -p priv/cert
openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 \
-subj "/CN=localhost" \
-keyout priv/cert/selfsigned_key.pem \
-out priv/cert/selfsigned.pem
- Switch
http:tohttps:inconfig/dev.exs:
config :my_app, MyAppWeb.Endpoint,
https: [
ip: {127, 0, 0, 1},
port: 4000,
cipher_suite: :strong,
keyfile: "priv/cert/selfsigned_key.pem",
certfile: "priv/cert/selfsigned.pem"
],
url: [host: "localhost", scheme: "https"],
# ...
If
config/runtime.exssetshttp: [port: ...]for dev, change it tohttps:too.Add
priv/cert/to.gitignore— each developer generates their own.Open
https://localhost:4000and accept the self-signed cert warning once.Tidewave users: switching to HTTPS means Tidewave's MCP endpoint (plain HTTP) is no longer auto-discovered. Re-add it explicitly:
claude mcp add tidewave --transport http http://localhost:4000/tidewave/mcp -s local
Alternative: terminate TLS at a local proxy (Caddy)
mix dstar.https puts HTTPS inside your app. A local reverse proxy puts
it in front instead — your app keeps serving plain HTTP on one port, and
the proxy handles TLS and HTTP/2. The connection limit is browser-side,
so fixing the browser↔proxy hop is enough.
Prefer this when you run several projects locally, or want zero TLS config in the app.
Install and trust Caddy's local CA (once, for all projects):
brew install caddysudo caddy trustCreate a
Caddyfile(e.g./opt/homebrew/etc/Caddyfile):my-app.localhost {reverse_proxy localhost:4000}Run it:
brew services start caddyTell Phoenix its public URL in
config/dev.exs(keep thehttp:key):url: [host: "my-app.localhost", port: 443, scheme: "https"],
Open https://my-app.localhost. No /etc/hosts edits — *.localhost
always resolves to loopback. No per-project certs — every future project
is one more 3-line block. Caddy auto-detects text/event-stream and
streams SSE unbuffered. And since the app itself stays plain HTTP,
Tidewave's MCP endpoint keeps working with no reconfiguration.
Verify HTTP/2 is active
Open DevTools → Network tab → right-click column headers → enable
Protocol. All requests should show h2.
Recommendation
Use Stream Deduplication (previous section) and HTTP/2 together. Dedup kills zombie processes server-side so they stop doing wasted DB queries. HTTP/2 prevents client-side connection exhaustion so the browser never stalls. Either one helps on its own; both together eliminate the problem entirely.
Without Dispatch
You can skip both the page model and the dispatch plug entirely and use plain controller actions:
# router.ex
post "/counter/increment", CounterController, :increment
# controller
def increment(conn, _params) do
signals = Dstar.read_signals(conn)
count = (signals["count"] || 0) + 1
conn
|> Dstar.start()
|> Dstar.patch_signals(%{count: count})
end
<button data-on:click="@post('/counter/increment')">+1</button>
Dispatch gives you convention and a single route. Plain controllers give you full routing control. Both use the same Dstar functions underneath.
CSRF Protection Setup
Phoenix expects the CSRF token in the _csrf_token body param — and
Datastar can't deliver that: its _-prefixed signal keys are
front-end-only and never sent to the backend. So we do a little shimming:
carry the token as a non-prefixed signal, and one plug copies it into
_csrf_token before Plug.CSRFProtection runs.
The signal pattern (pages, components, and helper routes alike)
- Add the plug to your browser pipeline, before
:protect_from_forgery:
plug Dstar.Plugs.RenameCsrfParam
plug :protect_from_forgery
- Expose the token as a non-prefixed signal in your root layout:
<body data-signals:csrf={"'#{get_csrf_token()}'"}>
Because csrf is not _-prefixed, Datastar includes it in every request.
How it travels depends on the HTTP method:
- POST / PUT / PATCH — in the JSON request body, as a top-level param.
- GET / DELETE — in the
datastarURL query parameter (?datastar={...}), since these methods carry no body.
The plug copies the token into body_params["_csrf_token"] from either
channel, where Plug.CSRFProtection looks. This one setup covers page
event() POSTs, stream connect() POSTs, component events, and the verb
helpers — including Dstar.delete/2,3, which :protect_from_forgery does
check.
A note on GET/DELETE and the token in URLs
The token riding on every request is the point of this setup, but on
GET/DELETE that means a per-session credential ends up in the URL query
string, which flows into server/proxy access logs and same-origin
Referer headers. Validation is unaffected — Plug.CSRFProtection still
compares the token cryptographically against the session-derived value —
but if that exposure matters to you:
- Scrub query strings (or at least the
datastarparam) from access/proxy logs. - Set an explicit
Referrer-Policyheader (e.g.same-originorno-referrer). - For maximum hygiene, transport the token as an
x-csrf-tokenheader via a small custom Datastar action —Plug.CSRFProtectionaccepts that header directly, and it never touches a URL.
Or: skip CSRF for SSE-only routes
Pipe Datastar-only routes through a pipeline without :protect_from_forgery
(the classic dispatch-route setup). Simpler, but then those endpoints rely on
your session/auth checks alone.
Lower-level Modules
The Dstar module delegates to these. Use them directly when you need more control.
| Module | Functions |
|---|---|
Dstar.Page | behaviour + use macro: mount/2, render/1, handle_event/3, handle_connect/2, handle_info/2, stream_key/1, handle_disconnect/1 |
Dstar.Page.Plug | request driver: handles page, event, and stream actions |
Dstar.Component | shared UI with colocated event handlers |
Dstar.Router | dstar/2 (page routes), dstar_components/2 (dispatch route) |
Dstar.Test | sse_events/1, patched_signals/1, assert_patched_signals/2, assert_patched_element/2 |
Dstar.SSE | start/1, check_connection/1, send_event/3,4, send_event!/3,4, format_event/2 |
Dstar.Signals | read/1, patch/2,3, patch_raw/2,3, nudge/2,3, remove_signals/2,3, format_patch/1,2, format_remove/1,2 |
Dstar.Elements | patch/2,3, remove/2,3, append/3,4, upsert/2,3, format_patch/1,2, format_remove/1,2 |
Dstar.Actions | post/1,2,3, get/1,2,3, put/1,2,3, patch/1,2,3, delete/1,2,3, on_nudge/2, encode_module/1, decode_module/1 |
Dstar.Scripts | execute/2,3, redirect/2,3, console_log/2,3 |
Dstar.Plugs.Dispatch | Standard Plug for dynamic event routing |
Dstar.Plugs.RenameCsrfParam | Standard Plug for CSRF param compatibility |
Dstar.Utility.StreamRegistry | Opt-in per-tab stream deduplication (see Stream Deduplication) |
Dependencies
Just two:
License
MIT