Current section

Files

Jump to
dstar usage-rules.md
Raw

usage-rules.md

# Dstar Usage Rules
## What Dstar Is
Dstar is a **minimalist SSE library** (~700 lines) providing pure functions over `Plug.Conn` to format and send Server-Sent Events for Datastar client-side framework. Two deps: `plug` and `jason`.
**Not:** LiveView, PhoenixDatastar, a framework, or a state management system. No processes, GenServers, supervision trees, behaviours, or macros.
## 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
**Approach 1: Header-based (recommended)**
```heex
<body data-signals:_csrf-token={"'#{get_csrf_token()}'"}>
```
The `_` prefix makes it client-only; sent as `x-csrf-token` header. Dstar's verb helpers (`post/2,3`, `get/2,3`, `put/2,3`, `patch/2,3`, `delete/2,3`) auto-include this.
**Approach 2: Form-compat** (for mixed SSE + regular form routes)
```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()}'"}>
```
The plug safely no-ops when the param isn't present, so it's fine to use globally.
## 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})">
```
## 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"}`