README.md

# DatastarPlug

[![Hex version](https://img.shields.io/hexpm/v/datastar_plug.svg)](https://hex.pm/packages/datastar_plug)
[![Hex docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/datastar_plug)
[![CI](https://github.com/rskinnerc/datastar_plug/actions/workflows/ci.yml/badge.svg)](https://github.com/rskinnerc/datastar_plug/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.1.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.

---

## 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
```

### 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`). |
| [`patch_signals/2`](https://hexdocs.pm/datastar_plug/Datastar.html#patch_signals/2) | Merge values into the client signal store (`datastar-patch-signals`). |
| [`execute_script/2`](https://hexdocs.pm/datastar_plug/Datastar.html#execute_script/2) | Execute JavaScript on the client (appends a `<script>` tag). |
| [`redirect_to/2`](https://hexdocs.pm/datastar_plug/Datastar.html#redirect_to/2) | Redirect the browser via `window.location.href`. |
| [`remove_fragment/2`](https://hexdocs.pm/datastar_plug/Datastar.html#remove_fragment/2) | Remove a DOM element by CSS selector. |
| [`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/2`, `remove_fragment/2`.

```
event: datastar-patch-elements
data: selector #my-div
data: mode inner
data: elements <p>Hello, world!</p>

```

### `datastar-patch-signals`

Used by `patch_signals/2`.

```
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 |
|------|-----------|
| `"morph"` | ID-based morphing diff **(default)**. Matches top-level elements by `id`. |
| `"inner"` | Replaces inner HTML of the target element. |
| `"outer"` | Replaces outer HTML (element itself included). |
| `"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/2` 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/2`** — Executes arbitrary JavaScript on the client. Only
  pass **server-controlled** strings. Never interpolate user input into the
  script.

- **`redirect_to/2`** — 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).