Current section

Files

Jump to
dstar usage-rules.md
Raw

usage-rules.md

# Dstar Usage Rules
## Pages first
Prefer `use Dstar.Page` + `dstar/2` for new pages. Use `Dstar.Component` +
`dstar_components/2` for shared UI with colocated event handlers. Reach for
the functional core (`Dstar.*` on a conn) in plain controllers or custom plugs.
### Canonical example — full counter page
```elixir
# router.ex
import Dstar.Router
dstar "/counter", MyAppWeb.CounterPage
```
```elixir
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
```
Key rules:
- One module per page — no separate controller, HTML module, or template file.
- `event("name")` generates the `@post(...)` expression client-side; no path
threading needed for path params.
- The library calls `Dstar.start/1` before `handle_event/3` — do not call it
again inside the handler.
- `dstar "/path", PageModule` is the allowlist. No separate allowlist entry needed.
## What Dstar Is
Dstar is a **minimalist SSE library** providing pure functions over `Plug.Conn` to format and send Server-Sent Events for Datastar client-side framework.
**Not:** LiveView, PhoenixDatastar, a framework, or a state management system. The **functional core** has no processes, GenServers, supervision trees, behaviours, or macros — two deps: `plug` and `jason`. The page layer (`Dstar.Page`, `Dstar.Component`, `Dstar.Router`) adds one behaviour, one plug, and two router macros on top, and is entirely opt-in.
## Core Pattern
**Read → Process → Start → Patch**
```elixir
def handle_event(conn, _params) do
signals = Dstar.read_signals(conn) # Read input
new_count = (signals["count"] || 0) + 1 # Process
conn
|> Dstar.start() # Open SSE connection
|> Dstar.patch_signals(%{count: new_count}) # Send updates
end
```
## Core API
All functions in `Dstar` module:
### Connection
- **`Dstar.start(conn)`** — Opens SSE connection (chunked, text/event-stream)
- **`Dstar.check_connection(conn)`** — Tests if SSE connection is still open. Returns `{:ok, conn}` if active, `{:error, conn}` if closed. Useful for detecting disconnections in streaming loops
### Signals
- **`Dstar.read_signals(conn)`** — Reads signals from request (GET: query, POST: body)
- **`Dstar.patch_signals(conn, signals, opts \\ [])`** — Sends datastar-patch-signals event
- Opts: `:only_if_missing`, `:event_id`, `:retry`
### DOM Manipulation
- **`Dstar.patch_elements(conn, html, opts)`** — Sends datastar-patch-elements event
- Opts: `:selector` (required), `:mode` (`:outer`/`:inner`/`:append`/`:prepend`/`:before`/`:after`/`:replace`/`:remove`), `:use_view_transitions`
- **`Dstar.remove_elements(conn, selector, opts \\ [])`** — Removes elements
### Scripts & Actions
- **`Dstar.execute_script(conn, script, opts \\ [])`** — Executes JS on client
- Opts: `:auto_remove`, `:attributes` (map of script tag attributes, e.g. `%{type: "module"}`)
- **`Dstar.redirect(conn, url, opts \\ [])`** — Client-side redirect
- **`Dstar.console_log(conn, message, opts \\ [])`** — Browser console output
- Opts: `:level` (`:log`/`:warn`/`:error`/`:info`/`:debug`)
### HTTP Verb Helpers
- **`Dstar.post(module, event_name)`** — Generates `@post("/ds/:module/:event", {...})` for attributes
- **`Dstar.post(module, event_name, opts)`** — With options (`:prefix` for URL prefix)
- **`Dstar.post(event_name, opts)`** — Dynamic module variant (reads `$_dstar_module` signal from client)
- All HTTP verbs available: `Dstar.get/2,3`, `Dstar.put/2,3`, `Dstar.patch/2,3`, `Dstar.delete/2,3`
## CSRF Setup
Datastar has **no built-in CSRF support** — it does not read Phoenix's
`<meta name="csrf-token">` tag and never sets an `x-csrf-token` header.
The token must travel as a signal.
```elixir
# In your router pipeline, BEFORE :protect_from_forgery:
plug Dstar.Plugs.RenameCsrfParam
plug :protect_from_forgery
```
```heex
<body data-signals:csrf={"'#{get_csrf_token()}'"}>
```
Because `csrf` is not `_`-prefixed, Datastar includes it in every request body. The plug copies it to `_csrf_token`, where `Plug.CSRFProtection` looks. This one setup covers page events, stream connects, component events, and the verb helpers. The plug safely no-ops when the param isn't present, so it's fine to use globally.
Alternatively, pipe Datastar-only routes through a pipeline without `:protect_from_forgery` — simpler, but those endpoints then rely on your session/auth checks alone.
## Dynamic Dispatch
**Router:**
```elixir
post "/ds/:module/:event", Dstar.Plugs.Dispatch, modules: [MyApp.CounterHandler]
```
**Handler:**
```elixir
defmodule MyApp.CounterHandler do
def handle_event(conn, "increment", signals) do
count = (signals["count"] || 0) + 1
conn |> Dstar.start() |> Dstar.patch_signals(%{count: count})
end
end
```
Client: `data-on:click={Dstar.post(CounterHandler, "increment")}`
## Real-time Streaming Pattern
```elixir
def stream(conn, _params) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
conn = Dstar.start(conn)
loop(conn)
end
defp loop(conn) do
receive do
{:update, data} ->
conn = Dstar.patch_signals(conn, %{data: data})
loop(conn)
end
end
```
Client reconnection:
```heex
<div data-init="@post('/stream', {retryMaxCount: Infinity})"
data-on:online__window="@post('/stream', {retryMaxCount: Infinity})">
```
## Live Collections
Keeping a list current in every open tab? The stream is one-way and cannot
learn a tab's filter/sort/page after connect, so blindly appending or morphing
rows silently corrupts any non-plain view. **Default to the nudge:**
`nudge(conn, "posts")` bumps a signal, each tab re-runs its own load action
with its own signals. Row mutation (`append_elements`, `upsert_elements`,
`remove_elements`) is the fast path for plain feeds only. Full pattern:
`usage-rules/live-collections.md`.
## JavaScript Islands
Embedding a stateful, JS-managed-DOM component (rich-text editor, map, chart,
canvas) in a Datastar page? Make it a **custom element that is also a Datastar
form control**: `data-bind` a `value` property for state, patch the signal for
content, dispatch element-targeted `CustomEvent`s for commands, and add
`data-ignore-morph` so the morph leaves its subtree alone. Full pattern + gotchas:
`usage-rules/javascript-islands.md`.
## Anti-patterns
**❌ Don't:**
- Use GenServers to store per-connection state (stateless functions only)
- Keep server-side state between events (except PubSub for streaming)
- Wrap `conn` in custom structs
- Call `Dstar.start()` multiple times per response
- Use in same controller action as `render/3` or `json/2`
**✅ Do:**
- Keep handlers pure: `(conn, signals) -> conn`
- Store state in signals (client-side) or database
- Call `Dstar.start()` once, then patch multiple times if needed
- Chain patches: `conn |> patch_signals(...) |> patch_elements(...)`
## Signal Value Quoting
Datastar signal values are JavaScript expressions:
```heex
data-signals:count="0" <%!-- Number --%>
data-signals:name="''" <%!-- Empty string (JS quotes needed) --%>
data-signals:id={"'#{@id}'"} <%!-- Dynamic string (HEEx + JS quotes) --%>
data-signals:active="false" <%!-- Boolean --%>
data-signals:items="[]" <%!-- Array --%>
data-signals:errors="{}" <%!-- Object --%>
```
## Key Datastar Attributes
- `data-signals:name="value"` — Declare reactive signal (JS expression)
- `data-signals:_name="value"` — Client-only signal (not sent in body, used for headers)
- `data-text="$signalName"` — Text binding
- `data-show="$condition"` — Conditional visibility
- `data-on:click="..."` — Event handler
- `data-init="..."` — Run on mount
- `data-class:className="$condition"` — Conditional CSS class
- `data-model="signalName"` — Two-way input binding
## Dependencies
Only two: `{:plug, "~> 1.14"}` and `{:jason, "~> 1.4"}`