# DatastarPlug
[](https://hex.pm/packages/datastar_plug)
[](https://hexdocs.pm/datastar_plug)
[](https://github.com/rskinnerc/datastar_plug/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
Stateless [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
helpers for [Datastar](https://data-star.dev) in any **Plug** or **Phoenix** application.
`DatastarPlug` gives you a small set of composable, pipeline-friendly functions
that write Datastar-compatible SSE events to a chunked `Plug.Conn` response.
The [Datastar JavaScript client](https://data-star.dev) running in the browser
receives these events and applies DOM patches, signal updates, script
executions, and redirects — all without a full-page reload and without any
WebSocket or long-polling infrastructure.
---
## Installation
Add `:datastar_plug` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:datastar_plug, "~> 0.2.0"}
]
end
```
Then run:
```shell
mix deps.get
```
No additional configuration is required. The package has two runtime
dependencies: [`plug`](https://hex.pm/packages/plug) and
[`jason`](https://hex.pm/packages/jason), both of which are already present
in virtually every Phoenix application.
---
## What's New in v0.2.0
- **`check_connection/1`** — detect client disconnects in long-running streams.
- **`remove_signals/3`** — remove client signals by dot-notated path, with
correct merging of shared path prefixes.
- **`:namespace` option** on `patch_fragment/3` — patch SVG or MathML
fragments.
- **`:use_view_transition` option** on `patch_fragment/3` — animate patches
via the browser's View Transitions API.
- **`:only_if_missing` option** on `patch_signals/3` — set default signal
values without overwriting existing ones.
- **`:auto_remove` option** on `execute_script/3` — automatically remove the
injected `<script>` tag after execution.
- **`:event_id` and `:retry_duration` options** on every SSE function — emit
the standard SSE `id:` and `retry:` fields for client-side replay support.
---
## Quick Start
### Phoenix controller
```elixir
defmodule MyAppWeb.ItemController do
use MyAppWeb, :controller
alias Datastar
alias MyApp.Items
# GET /items/:id/refresh — triggered by a Datastar `data-on-load` attribute
def refresh(conn, params) do
signals = Datastar.parse_signals(params)
item = Items.get!(signals["itemId"])
html = Phoenix.View.render_to_string(MyAppWeb.ItemView, "card.html", item: item)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(html)
|> Datastar.patch_signals(%{itemLoaded: true})
|> Datastar.close_sse()
end
# DELETE /items/:id — delete and remove the card from the DOM
def delete(conn, %{"id" => id}) do
Items.delete!(id)
conn
|> Datastar.init_sse()
|> Datastar.remove_fragment("#item-#{id}")
|> Datastar.patch_signals(%{count: Items.count()})
|> Datastar.close_sse()
end
end
```
### Plain `Plug.Router`
```elixir
defmodule MyApp.Router do
use Plug.Router
plug :match
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug :dispatch
get "/updates" do
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(~s(<div id="status">All systems go</div>))
|> Datastar.patch_signals(%{ready: true})
|> Datastar.close_sse()
end
end
```
### Long-running SSE streams with connection checking
```elixir
def stream(conn, _params) do
conn = Datastar.init_sse(conn)
stream_items(conn, MyApp.Items.all())
end
defp stream_items(conn, []), do: conn
defp stream_items(conn, [item | rest]) do
case Datastar.check_connection(conn) do
{:ok, conn} ->
conn
|> Datastar.patch_fragment(render_item(item))
|> stream_items(rest)
{:error, _conn} ->
# Client disconnected — stop streaming silently
conn
end
end
```
### Removing signals
```elixir
conn
|> Datastar.init_sse()
|> Datastar.remove_signals(["user.name", "user.email"])
|> Datastar.close_sse()
```
### Reading signals from GET requests
Datastar serialises the entire client signal store as a JSON string in the
`?datastar=` query parameter on GET requests. Use `parse_signals/1` to decode
it:
```elixir
def search(conn, params) do
# params == %{"datastar" => "{\"query\":\"elixir\"}"}
signals = Datastar.parse_signals(params)
query = Map.get(signals, "query", "")
results_html = render_results(query)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(results_html)
|> Datastar.close_sse()
end
```
### Reading signals from POST / PUT / DELETE requests
For mutating requests Datastar sends signals as the JSON request body. The
body parser decodes it, so `params` is already the signal map:
```elixir
def create(conn, params) do
# params == %{"title" => "Buy milk", "done" => false}
signals = Datastar.parse_signals(params)
title = Map.get(signals, "title", "")
{:ok, item} = MyApp.Items.create(%{title: title})
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(render_item(item), selector: "#list", merge_mode: "append")
|> Datastar.patch_signals(%{newTitle: ""})
|> Datastar.close_sse()
end
```
---
## Function Reference
| Function | Description |
|----------|-------------|
| [`init_sse/1`](https://hexdocs.pm/datastar_plug/Datastar.html#init_sse/1) | Open a chunked SSE response. **Call first.** |
| [`patch_fragment/3`](https://hexdocs.pm/datastar_plug/Datastar.html#patch_fragment/3) | Patch HTML into the DOM (`datastar-patch-elements`). |
| [`remove_fragment/3`](https://hexdocs.pm/datastar_plug/Datastar.html#remove_fragment/3) | Remove a DOM element by CSS selector. |
| [`patch_signals/3`](https://hexdocs.pm/datastar_plug/Datastar.html#patch_signals/3) | Merge values into the client signal store (`datastar-patch-signals`). |
| [`remove_signals/3`](https://hexdocs.pm/datastar_plug/Datastar.html#remove_signals/3) | Remove one or more signals by dot-notated path. |
| [`execute_script/3`](https://hexdocs.pm/datastar_plug/Datastar.html#execute_script/3) | Execute JavaScript on the client (appends a `<script>` tag). |
| [`redirect_to/3`](https://hexdocs.pm/datastar_plug/Datastar.html#redirect_to/3) | Redirect the browser via `window.location.href`. |
| [`check_connection/1`](https://hexdocs.pm/datastar_plug/Datastar.html#check_connection/1) | Verify the SSE connection is still alive. |
| [`close_sse/1`](https://hexdocs.pm/datastar_plug/Datastar.html#close_sse/1) | No-op pipeline terminator for readability. |
| [`parse_signals/1`](https://hexdocs.pm/datastar_plug/Datastar.html#parse_signals/1) | Decode Datastar signals from GET or POST params. |
---
## SSE Protocol Overview
Each function emits one or more
[SSE events](https://data-star.dev/reference/sse_events) in the following
wire format:
```
event: <event-type>\n
data: <key> <value>\n
[data: <key2> <value2>\n]
\n
```
The blank line (`\n\n`) terminates the event. Multi-line HTML values (from
`patch_fragment/3`) are split into one `data: elements` line per source line.
### `datastar-patch-elements`
Used by `patch_fragment/3`, `execute_script/3`, `remove_fragment/3`.
```
event: datastar-patch-elements
data: selector #my-div
data: mode inner
data: elements <p>Hello, world!</p>
```
### `datastar-patch-signals`
Used by `patch_signals/3`.
```
event: datastar-patch-signals
data: signals {"count":42,"loading":false}
```
---
## Merge Modes
The `:merge_mode` option of `patch_fragment/3` controls how incoming HTML is
merged into the DOM:
| Mode | Behaviour |
|------|-----------|
| `"outer"` | **(Default)** Morphs the element in place. Without a `:selector`, matches top-level elements by `id` and morphs each one. |
| `"inner"` | Replaces inner HTML of the target element using morphing. |
| `"replace"` | Replaces the target element with `replaceWith` (no morphing diff). |
| `"prepend"` | Inserts before the first child of the target. |
| `"append"` | Inserts after the last child of the target. |
| `"before"` | Inserts immediately before the target element. |
| `"after"` | Inserts immediately after the target element. |
| `"remove"` | Removes the target (use `remove_fragment/3` instead). |
---
## Security
See the [Datastar security reference](https://data-star.dev/reference/security)
for the full specification. Key points for this library:
- **`patch_fragment/3`** — HTML is written verbatim to the SSE stream. If any
part of the HTML originates from user input, **sanitise it first** to prevent
XSS. Use `Phoenix.HTML.html_escape/1` or a dedicated HTML sanitiser.
- **`execute_script/3`** — Executes arbitrary JavaScript on the client. Only
pass **server-controlled** strings. Never interpolate user input into the
script.
- **`redirect_to/3`** — The URL is `Jason.encode!/1`-encoded before embedding,
preventing injection via single-quotes, backslashes, or `</script>` in the
URL string.
- **`parse_signals/1`** — Signal data originates from the browser and must be
treated as **untrusted user input**. Validate and sanitise all values before
using them in queries, HTML rendering, or downstream business logic.
---
## Contributing
1. Fork the repository.
2. Create a feature branch: `git checkout -b my-feature`.
3. Make your changes and add tests.
4. Run the full quality suite:
```shell
mix test.ci
```
5. Open a pull request.
Bug reports and feature requests are welcome via
[GitHub Issues](https://github.com/rskinnerc/datastar_plug/issues).
---
## License
DatastarPlug is released under the [MIT License](LICENSE).