DatastarPlug
Stateless Server-Sent Events (SSE) helpers for Datastar 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 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.
See it in action on the live demo.
Installation
Add :datastar_plug to your dependencies in mix.exs:
def deps do
[
{:datastar_plug, "~> 0.2.0"}
]
endThen run:
mix deps.get
No additional configuration is required. The package has two runtime
dependencies: plug and
jason, both of which are already present
in virtually every Phoenix application.
Compatibility
This package is built for Datastar RC.8+. If you're using an earlier version, some functions or options may not work as expected.
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.:namespaceoption onpatch_fragment/3— patch SVG or MathML fragments.:use_view_transitionoption onpatch_fragment/3— animate patches via the browser's View Transitions API.:only_if_missingoption onpatch_signals/3— set default signal values without overwriting existing ones.:auto_removeoption onexecute_script/3— automatically remove the injected<script>tag after execution.:event_idand:retry_durationoptions on every SSE function — emit the standard SSEid:andretry:fields for client-side replay support.
Quick Start
Phoenix controller
defmodule MyAppWeb.ItemController do
use MyAppWeb, :controller
alias Datastar
alias MyApp.Items
# GET /items/:id/edit — triggered by a Datastar `data-init` attribute
def edit(conn, %{"id" => id} = params) do
signals = Datastar.parse_signals(params)
item = Items.get!(id)
form_html = Phoenix.View.render_to_string(MyAppWeb.ItemView, "edit_form.html", item: item)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(form_html, selector: "#item-form")
|> Datastar.patch_signals(%{editMode: true, itemId: id})
|> Datastar.close_sse()
end
# PUT /items/:id — save changes and update the display
def update(conn, %{"id" => id} = params) do
signals = Datastar.parse_signals(params)
item_attrs = Map.take(signals, ["title", "description"])
{:ok, item} = Items.update(id, item_attrs)
display_html = Phoenix.View.render_to_string(MyAppWeb.ItemView, "display.html", item: item)
conn
|> Datastar.init_sse()
|> Datastar.patch_fragment(display_html, selector: "#item-display")
|> Datastar.patch_signals(%{editMode: false})
|> Datastar.close_sse()
end
end
Plain Plug.Router
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
endLong-running SSE streams with connection checking
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
endRemoving signals
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:
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()
endReading 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:
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()
endFunction Reference
| Function | Description |
|---|---|
init_sse/1 | Open a chunked SSE response. Call first. |
patch_fragment/3 |
Patch HTML into the DOM (datastar-patch-elements). |
remove_fragment/3 | Remove a DOM element by CSS selector. |
patch_signals/3 |
Merge values into the client signal store (datastar-patch-signals). |
remove_signals/3 | Remove one or more signals by dot-notated path. |
execute_script/3 |
Execute JavaScript on the client (appends a <script> tag). |
redirect_to/3 |
Redirect the browser via window.location.href. |
check_connection/1 | Verify the SSE connection is still alive. |
close_sse/1 | No-op pipeline terminator for readability. |
parse_signals/1 | Decode Datastar signals from GET or POST params. |
SSE Protocol Overview
Each function emits one or more 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 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. UsePhoenix.HTML.html_escape/1or 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 isJason.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
- Fork the repository.
-
Create a feature branch:
git checkout -b my-feature. - Make your changes and add tests.
-
Run the full quality suite:
mix test.ci - Open a pull request.
Bug reports and feature requests are welcome via GitHub Issues.
License
DatastarPlug is released under the MIT License.