Dstar - reactive Phoenix pages over plain HTTP and SSE, no websockets

Continuing from the PhoenixDatastar thread — that package is deprecated in favor of Dstar.


Dstar is a batteries-included Datastar toolkit for Elixir: SSE primitives, signal/element patching, event dispatch, and — since 0.1 — a unified page model. A Datastar page is one module and one router line, with LiveView-familiar ergonomics over plain HTTP + SSE:

defmodule MyAppWeb.CounterPage do
  use Dstar.Page

  def mount(conn, _params), do: assign(conn, count: 0)

  def handle_event(conn, "increment", signals) do
    patch_signals(conn, %{count: (signals["count"] || 0) + 1})
  end

  def render(assigns) do
    ~H"""
    <div data-signals:count={@count}>
      <h1 data-text="$count"></h1>
      <button data-on:click={event("increment")}>+1</button>
    </div>
    """
  end
end

Live updates are just as small — declare how to subscribe, and the library owns the receive loop (liveness checks, stray-message tolerance, cleanup on disconnect):

  # Same page module — push PubSub broadcasts to the browser:
  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

The HTML to initialize the stream and handle the updated signals:

<div data-init={connect()} data-on:online__window={connect()}>
  <span data-text="$tick"></span>
</div>

The router:

# router.ex
import Dstar.Router

scope "/", MyAppWeb do
  pipe_through :browser

  dstar "/counter", CounterPage
end

Also included:

  • mix dstar.https — one-command trusted HTTPS for local development (hosts entry + mkcert cert). Browsers cap HTTP/1.1 at 6 connections per domain and SSE streams eat them until the app silently stalls; HTTP/2 fixes it but needs TLS, and this removes the setup slog. Grew out of collaboration with @Neophen’s StarView
  • Per-tab stream deduplication, shared components with colocated handlers, router macros, and Dstar.Test for asserting on SSE patches.
  • A small functional core that needs only plug + jason — the page layer and its phoenix/phoenix_live_view deps are optional.

The page layer is in alpha (0.1.0-alpha.2) until it survives a full production-app migration; the functional core is the same battle-tested code from the 0.0.x line. All 0.0.x code runs unchanged.


Why Datastar?

Both LiveView and Datastar keep the source of truth on the server — the difference is what holds it. LiveView mirrors your state into a long-lived process per visitor over a websocket; that process holds memory, must re-join and rehydrate on every reconnect, and ties UI lifetime to socket lifetime. Datastar skips the middleman: the backend is the state, and it drives the browser directly by patching HTML and signals over SSE. A GET renders the page, events are ordinary POSTs, reconnection is the browser’s job, and every request is a regular Plug.Conn. Signals exist only for ephemeral UI bits (toggles, form inputs) — everything real stays in your domain. You send fat chunks of morphed, Brotli-compressed HTML rather than managing fine-grained diffs, and the wire format is simple enough to read with curl.

LiveView Datastar + Dstar
Transport WebSocket Plain HTTP + SSE
Server memory per visitor Long-lived process + assigns None between requests
Reconnects / deploys Socket re-join, state rehydration Browser retries SSE; nothing to rehydrate
Updates over the wire Fine-grained template diffs Fat-morphed HTML + compression (Brotli ~200:1 on streams)
Events Socket messages Regular POSTs (curl-able, CSRF like any form)
Programming model mount/handle_event/handle_info on a socket Same shape, on a Plug.Conn
Auth flow Plug pipeline + on_mount hooks (socket re-auths separately) Plug pipeline only — every request passes through it

Feedback very welcome — especially on the Dstar.Page API surface, and from anyone on Windows who tries dstar.https (currently prints manual instructions).

What’s next?

Currently Dstar pages come in two flavors: plain “DeadView” (render + events, no stream) and streaming (define handle_connect/2 and the library runs a long-lived SSE loop for pushing PubSub broadcasts). I’m exploring an explicit third tier: :stateful — streaming plus an opt-in per-tab GenServer that holds assigns decoupled from the connection. LiveView-style ergonomics, except the state survives reconnects and the SSE drop/retry dance by construction. Very much thinking out loud at this stage — feedback on the shape welcome.


Hex · Docs · Changelog

9 Likes

Recently stumbled onto Datastar and it’s cool to see people in the Elixir space playing with it.

To me, it feels like its model could solve two of my biggest gripes with Live View:

  1. Datastar is HTTP only, which means not having to worry about the gotchas of having to support the dual HTTP/Websocket path (especially around auth)
  2. Ability to have elements on the browser that are reactive to other browser-only state AND still have your stateful event loop on the server.
3 Likes