Unreleased

0.3.0 — 2026-09-29

Upgrading from 0.2

Most apps need no code changes. Check these:

  • Action URL expressions changed shape (#25). Page event/2, connect/1, component and Dstar.Actions expressions are now built by one structured builder: literals are JSON-encoded and segments are percent-encoded. Tests that assert the literal old strings (for example "@post(location.pathname, {retryMaxCount: Infinity})") must be updated. phoenix_test_datastar needs 0.0.3 or later to resolve the new URLs.
  • prefix: is validated. Values that aren't a local absolute path raise ArgumentError. "" and "/" still mean no prefix.
  • Dstar.Scripts.redirect/3 rejects off-origin destinations unless you pass external: true or allow: [host].
  • Hand-rolled stream loops should not match {:EXIT, _, :replaced}: the registry's replacement signal is now generation-tagged. Move the loop to Dstar.Stream.run/2, or use start_stream/2,3 plus release/1.
  • Malformed, non-object or oversized signals now get a 400/413 before Page, Dispatch or keyed-stream handlers run.

Added

  • Dstar.Stream — the stream loop for plain controllers. open/2 starts SSE, optionally claiming a per-tab key: first, and returns {:ok, conn} or {:error, conn} with an ordinary 400/413/503 response (never SSE). run/2 owns the receive loop through :connect, :info, :replaced, :disconnect, and :idle_check options. It recognizes only its own generation-tagged takeover signal, releases the claim before :replaced/:disconnect and on any raise, skips {:plug_conn, :sent}, and leaves Bandit's HTTP/2 flow-control messages for Bandit, which a catch-all receive would swallow. Dstar.Page streams now run on it with unchanged behaviour. Dstar.start_stream/2,3 and StreamRegistry.release/1 remain supported for loops that own their receive.

Security

  • StreamRegistry claims and takeovers are now linearizable and fail closed (#32). The opt-in registry is one coordinator process that serializes claim generations. Concurrent contenders have defined ordered outcomes, every successful generation remains tracked through handover, and the final claimant is the sole active owner.

    start_stream/2,3 now calls Dstar.start/1 only after a keyed claim succeeds. Coordinator failure returns a halted, non-SSE 503 conn instead of silently starting an undeduplicated stream; missing/invalid tabId remains the intentional unkeyed rollout fallback. Dstar.Page skips handle_connect/2 and its loop on that failure.

    Replacements carry an opaque generation capability. Their internal exit reason is now tagged; Page preserves the legacy :replaced shape for application callbacks. Hand-rolled loops should use start_stream/2,3 and release/1 instead of consuming that internal message. Page ignores stale replacement messages on reused keep-alive processes and synchronously releases the exact claim before disconnect cleanup. Release and escalation are serialized, so escalation cannot kill a process after its release has returned; uncooperative trapping holders still face bounded kill escalation.

  • Signal fetching is now bounded, object-only, and conn-safe (#29). Dstar.Signals.fetch/2 (also Dstar.fetch_signals/2) returns the parsed signal map together with Plug's updated conn, accepts JSON objects only, distinguishes malformed/non-object input from missing signals, and bounds both raw-body read sizes plus GET/DELETE datastar JSON before decoding. The default limit is 1,000,000 bytes and is configurable per call; Page and Dispatch expose :max_signal_bytes.

    Page events/streams, component Dispatch, and StreamRegistry.start_stream/2 use the safe path. Malformed/non-object input receives 400 and oversized input receives 413 before SSE, handlers, or registry claims start. The map-only read_signals/1 convenience remains for already-fetched Phoenix/Plug params; plain Plug consumers must use fetch_signals/2 and thread its conn. Applications whose Plug.Parsers runs first must bound that parser's :length and :read_length too.

  • Pages can reject event and stream POSTs with a normal 401/403 before SSE starts (#28). Optional authorize/2 runs after signals are read and before Dstar.SSE.start/1 / start_stream/2. A halted or already-staged response is returned as ordinary HTTP — no chunked 200, no handle_event/3 / handle_connect/2, no stream_key/1 registration. Assigns set in authorize/2 are visible to later callbacks.

    mount/2 still authorizes only the GET. Router and component-module allowlists select code, not whether the current user may perform the event or touch a record id interpolated into the event name. Session-wide authentication stays in the surrounding pipe_through; authorize/2 is the page-local / resource-specific seam. Component handlers (which call start/1 themselves) must authorize before that call if they need a normal 401/403.

  • CSRF guidance is fail-safe, and RenameCsrfParam no longer lets a query _csrf_token hide a valid source token (#27). The plug decides whether _csrf_token is already present by inspecting body_params — the map Plug.CSRFProtection reads — not merged params. Precedence when writing: existing body _csrf_token, body csrf signal, csrf inside the datastar query param, then a last-resort top-level csrf param. The x-csrf-token header is left to Plug (body token first, then header). Docs no longer treat session/auth checks as a CSRF substitute; SameSite and login are defense-in-depth. GET/DELETE leakage advice now redacts the entire datastar query value and recommends Referrer-Policy: no-referrer or origin (same-origin still sends path/query on same-origin requests). Header transport is the strongest hygiene option.

  • Action builders no longer interpolate caller values into executable Datastar/JavaScript or URL structure (#25). Core Actions, Page, Component, and stream-connect expressions now share one structured builder. Literal event/module values are percent-encoded as one path segment and JavaScript string chunks are JSON-serialized; the default dynamic module signal is encoded under equivalent rules at runtime. Exact empty/./.. segments are rejected because browser URL parsing normalizes them.

  • prefix: now accepts only local absolute application paths beginning with one /; protocol-relative/cross-origin, dot-segment, backslash, query, fragment, control-containing, malformed-percent, and non-UTF-8 values raise ArgumentError. Component data-ds-base is checked against the same runtime safety boundary, and page pathnames cannot become protocol-relative targets.

  • Page/Component event :opts and Page connect :opts remain raw JavaScript for trusted developer-authored configuration only. They are not a data API and must never contain request, stored, slug, or user values.

  • Dstar.Scripts.redirect/3 destination policy. JSON-encoding the URL prevented JavaScript string breakout but not a dangerous destination: javascript: assigned to window.location.href is DOM XSS, and unrestricted absolute / protocol-relative URLs are an open redirect. Destinations are now validated against parsed URL components (not a prefix check) and rejected with ArgumentError before any SSE patch is emitted.

    Default: same-origin path-absolute URLs, plus query/fragment on the current path (/workspaces, ?x=1, #frag). Rejected: javascript:/data:/ vbscript: (including mixed-case and whitespace/control-obfuscated forms), protocol-relative URLs (//evil.example, including tab/LF/CR smuggled into a path so browsers parse /\n/evil as //evil), URLs with userinfo (https://trusted.example@evil.example/), and off-origin http/https. Off-origin http/https requires external: true or allow: ["host"]. execute_script/3 is unchanged (trusted-code API).

Fixed

  • prefix: "" is accepted again as "no prefix". The #25 validation rejected it, although it renders exactly the default /ds URL.
  • format_event/format_patch now produce exactly what send_event/patch send. Dstar.SSE builds every frame in one encoder. Before, Dstar.Signals.format_patch/2 and Dstar.Elements.format_patch/2 silently dropped :event_id and :retry, format_event(nil, …) emitted an empty event: line, and format_event(type, []) emitted a stray blank line. Dstar.SSE.format_event/3 now accepts the same options as send_event/4.
  • Dstar.Signals.patch_raw/3 sends multi-line JSON correctly. Each line now goes out as its own data: signals <line>, which the client reassembles. Before, continuation lines went out without the signals key, and the client dropped them.

Internal

  • Line-terminator handling for Datastar data fields lives in Dstar.SSE: elements and signals are split per line, and every other field (selector, mode, …) has its terminators stripped. Elements, Signals and Scripts now only declare their fields.

0.2.0 — 2026-08-05

Changed

  • Dstar.Scripts.console_log/3 raises on an unknown :level. It previously fell back to :log silently, which hid the typo instead of the message — notably level: :warning, Elixir's own Logger spelling, which is not a console method. Valid levels are unchanged (:log, :warn, :error, :info, :debug); anything else now raises ArgumentError, matching how :mode, :namespace and :verb are already validated elsewhere.

    This is a runtime-breaking change for callers passing an invalid level. If you have such a call inside a stream, the raise will end that stream.

  • Dstar.Elements.format_patch/2 validates :mode. It shared no code with Dstar.Elements.patch/3 and had drifted: an invalid mode that patch/3 rejects was accepted and written to the wire as data: mode <junk>. Both now build their data lines from one function, so format_patch/2 raises on the same input patch/3 does. This also means a mode passed as a string (mode: "outer") is now rejected — previously it produced valid output.

Fixed

  • Dstar.Plugs.RenameCsrfParam now extracts the token from the datastar query parameter. Datastar v1.0 sends signals in the JSON request body for POST/PUT/PATCH but in the datastar URL query parameter for GET/DELETE (those methods carry no body). The plug previously only looked for a top-level csrf param, so DELETE requests — which Plug.CSRFProtection does check — could not be validated and were rejected under :protect_from_forgery. The plug now decodes the datastar param and copies the token into body_params["_csrf_token"] from either channel. New option: :datastar_param (default "datastar").

  • CSRF docs corrected. README, usage rules, and the migration guide claimed Datastar includes the token in every request body; it is a body param on POST/PUT/PATCH and a datastar query parameter on GET/DELETE. Docs now describe both channels, note that the token rides in the URL on GET/DELETE (access logs, same-origin Referer headers), and list hardening options (log scrubbing, Referrer-Policy, x-csrf-token header transport).

  • Dstar docs. The moduledoc advertised @post(...) output carrying a {headers: ...} option, removed back when CSRF moved to the signal-based approach, and patch_elements/3 was documented as requiring a :selector when it also targets by element id. The README's module tables were missing nudge/2,3, append/3,4, upsert/2,3, format_remove/1,2, on_nudge/2 and the arity-1 dynamic verb forms.

Internal

  • Dstar.Elements and Dstar.Signals each built their SSE data lines from two inlined copies (patch and format_patch); both pairs now share one builder.
  • Dstar.Page.Helpers.event/2 and Dstar.Component's event/2 were the same function twice; both now call one shared builder in Dstar.Actions, and the verb allowlist has one definition instead of three.
  • Vendored deps/ (429 files) were tracked in git despite being gitignored; removed from the index.

0.1.6 — 2026-07-25

Documentation

  • Retracted: the connect() placement warning. 0.1.4 and 0.1.5 documented that a data-init={connect()} on the same element as the data-signals:* it needs fires before those signals are applied, so the stream POST omits them. That is false and the mechanism given for it does not exist.

    Isolated against the Datastar v1 bundle with a static page and an echo endpoint — no Elixir, no dstar in the loop. Signals and data-init sharing an element send correctly, including the exact multi-signal kebab-case shape the warning called broken. There is no signals-first pass in the client to support the claim: every plugin name is added to the restricted set as it registers, so that gate batches late-registered plugins rather than ordering them, and attributes apply in document order.

    The original report came from a measurement taken against stale tabs from a previous server instance with reconnect churn in flight.

    Placing connect() on a child element remains perfectly fine — it is simply not necessary.

0.1.5 — 2026-07-25

Security

  • SSE field injection via a line break in :selector. Dstar.SSE splits every data: value on line terminators so multi-line elements HTML frames correctly (the 0.1.1 fix). selector went through the same path, so a line break in it opened a second field inside the same event — and the Datastar client accumulates repeated fields rather than overwriting them, joining them with newlines. An injected elements line was therefore prepended to the real HTML and morphed into the DOM of every subscribed client:

    conn |> Dstar.patch_elements(html, selector: "#row-" <> untrusted)

    With untrusted carrying "1\nelements <img src=x onerror=…>", that is stored XSS; a plainer payload silently redirects the patch at an attacker's chosen selector (selector body > * on a remove deletes the page). Any selector built from a record id, slug, or other user-supplied value was a vector. 0.1.1 closed frame injection — escaping into a new event — but not field injection within an event.

    Line terminators are now stripped from :selector, where they are meaningless anyway. mode and namespace were never reachable (validated against fixed atom lists), and signals is JSON-escaped.

    Found while adversarially reviewing the ash_dstar Stream bridge, which derives a DOM id from a record's primary key — a string primary key containing a newline reaches remove_elements/2 as a selector.

0.1.4 — 2026-07-25

Fixed

  • Stream takeover did nothing on the default stack (#17). Bandit connection processes trap exits, so StreamRegistry's Process.exit(pid, :replaced) arrived as a message instead of killing the old stream: dedup silently did not happen, every navigation stalled 5 seconds, and the registry key stayed owned by a zombie. The library loop now halts on {:EXIT, _, :replaced}, replace_and_register/1 waits for the key to be released (killing only a holder that ignores the signal), the Registry.register/3 result is no longer discarded, and the handover is bounded at 1s instead of 5s.
  • Stream state outlived the stream (#18). The Dstar.Page.Plug receive loop did no teardown, so on HTTP/1.1 keep-alive — where the connection process survives the stream — registry entries and subscriptions leaked onto a process serving unrelated requests. Every exit path now unregisters from StreamRegistry and calls the new optional handle_disconnect/1.
  • tabId was registered unvalidated (#19). Any JSON value the client sent became a Registry key; "" collided every such tab onto one key. Now required to be a 1..64-byte non-blank binary, exposed as StreamRegistry.tab_id/1.

Documentation

  • data-signals:tabId never produced a tabId signal (#20). HTML lowercases attribute names and Datastar camelizes on hyphens, so the documented setup silently disabled dedup. Corrected to tab-id, with the general kebab-case rule documented for all keyed attributes.
  • retryMaxCount cannot bound a reconnect loop (#21). It counts consecutive connection failures and resets on every 200, so only retryMaxCount: 0 stops a cycle that reconnects successfully each pass. Scoped to streams ending as a transport error (HTTP/2 takeover, kill, crash) or with retry: "always" — a cleanly ended stream does not reconnect at all under the default retry: "auto".
  • The installation snippet pinned 0.1.0-alpha.2, four releases stale.

Added

0.1.3 — 2026-06-28

Security

  • <script> attribute injection in Dstar.execute_script/3. The :attributes option interpolated attribute names verbatim and emitted non-binary attribute values unescaped, so a developer routing untrusted data into a script-tag attribute name (or a non-string value) could break out of the <script …> tag and inject live markup. Attribute names are now validated against [A-Za-z0-9_:.-] (rejected otherwise), and all attribute values — including charlists, atoms, and other non-binaries — are HTML-escaped. Found via adversarial review of the 0.1.2 fix.

0.1.2 — 2026-06-28

Security

  • Incomplete <script> end-tag escaping in Dstar.Scripts. execute_script/2, console_log/2, and redirect/2 neutralized only an exact, lowercase </script> in the script body, so attacker-controlled content containing </SCRIPT>, </script >, </script/>, or </script followed by any terminator broke out of the appended <script> element (HTML closes script data case-insensitively), enabling stored XSS — e.g. logging a user-controlled value with console_log/2. Escaping now neutralizes every </script case-insensitively, while leaving developer JavaScript — including regex literals like /<script/ — byte-identical. Found via internal security audit.

0.1.1 — 2026-06-28

Security

  • SSE frame injection via carriage returns. Dstar.Elements framed element HTML and selectors into SSE data: lines by splitting only on \n, so a lone carriage return (\r) in attacker-controlled content survived into the wire stream. The browser's EventSource parser treats CR, LF, and CRLF as line terminators, so this let an attacker forge additional SSE events — e.g. a datastar-patch-signals event spoofing client signals, or a <script> DOM injection — affecting every viewer of the patched fragment. Dstar.SSE now splits every data: value on all line terminators, and strips line breaks from the single-valued event:, id:, and retry: fields (the latter reachable through the :event_id and :retry options on patch_elements, patch_signals, and the script helpers). Found via internal security audit.

0.1.0 — 2026-06-14

Fixed

  • Dstar.Page.Helpers.patch/4 injects :__changed__ so rendered components may call assign/3 (direct calls bypass the HEEx engine, which normally adds it).
  • Dstar.Plugs.Dispatch and Dstar.Page.Plug now log crashes from handle_event/handle_connect/handle_info before reraising — previously a stream callback crash was a silent dead stream.

Added

  • connect(include_search: true) appends location.search to the stream-connect URL for pages whose render depends on query params.

Documentation

  • Corrected the CSRF guidance across the usage rules, the use-dstar skill, and the Phoenix-Datastar migration guide to match the README and RenameCsrfParam correction: Datastar has no built-in CSRF support, so the token must travel as a non-prefixed csrf signal rather than the previously-documented (and nonexistent) x-csrf-token meta-tag path.
  • Modernized the datastar-attributes handler examples to the conn-based API (handle_event(conn, event, signals), Dstar.start/1, patch_signals, patch_elements(html, selector:, mode:)).

0.1.0-alpha.2 — 2026-06-11

Added

  • mix dstar.https — one-command trusted HTTPS for dev. Adds a my-app.test entry to /etc/hosts and generates a browser-trusted certificate via mkcert, enabling HTTP/2 so SSE streams don't exhaust the browser's 6-connection HTTP/1.1 limit. Tries cached sudo -n first, falls back to a GUI prompt on macOS (osascript) and interactive sudo on Linux; detects conflicting hosts entries before touching anything. Supports --host, --cert, --key, --ip, --dry-run, and --yes.

Fixed

  • Dstar.Page.Plug's stream loop no longer consumes Bandit's HTTP/2 flow-control messages. Over HTTP/2, Bandit runs each stream in its own process and delivers {:bandit, {:send_window_update, _}} (and friends) to that process's mailbox, consuming them by selective receive inside its send path. The library loop's catch-all receive stole those messages, logging them as unhandled page messages and — worse — discarding the window credit, which would stall the stream with a flow-control timeout once the send window drained. The loop now skips {:bandit, _} messages so Bandit finds them where it expects them.

  • Dstar.Page.Plug now loads the page module before probing its callbacks. function_exported?/3 returns false for modules the code server has not loaded yet, so under lazy code loading (dev and test on a fresh VM) the first GET to a page silently skipped mount/2 — typically crashing render/1 with a KeyError — and the first stream POST returned 404 despite a defined handle_connect/2. The plug now calls Code.ensure_loaded?/1 before function_exported?/3.

0.1.0-alpha.1

The unified page module release. A Datastar page is now one module and one router line. Alpha until the page layer has survived its proving ground (a full production-app migration); the functional core is the same battle-tested code as 0.0.x.

Added

  • Dstar.Page — use it for one-module pages: mount/2, render/1, handle_event/3, handle_connect/2, handle_info/2, stream_key/1.
  • Dstar.Page.Plug — drives all page requests; owns the SSE receive loop with idle checks and stray-message tolerance.
  • Dstar.Component — shared UI with colocated event handlers; event/2 targets the dispatch URL with a client-side data-ds-prefix base.
  • Dstar.Router — dstar/2 (page routes) and dstar_components/2 (dispatch route) macros.
  • Dstar.Page.Helpers — event/1,2, connect/0,1, patch/3,4.
  • Dstar.Page.Assigns — assign/assign_new/update working on both conns and component assigns.
  • Dstar.Test — sse_events/1, patched_signals/1, assert_patched_signals/2, assert_patched_element/2.
  • Optional deps: phoenix ~> 1.7, phoenix_live_view ~> 1.0. The functional core still needs only plug + jason.

Changed

  • Docs restructured around pages; the original API is now "the functional core". Nothing breaks: all 0.0.x code works unchanged.

0.0.10 — 2026-04-24

Fixed

  • Removed Connection: keep-alive header from SSE responses. The header is forbidden in HTTP/2 (RFC 9113 §8.2.2) and caused browsers and curl to reject the entire response body over HTTP/2 connections. It was also redundant in HTTP/1.1, where keep-alive is already the default.

0.0.9 — 2026-04-16

Fixed

  • README copy fixes. The install snippet now references ~> 0.0.9 (was stuck on ~> 0.0.7 in the v0.0.8 release). Removed two stale mentions of "CSRF headers" in the URL-generation feature list and the Quick Start — verb helpers stopped injecting CSRF headers in 0.0.8 when CSRF moved to the Phoenix meta tag. Removed a stray T after the License section.

0.0.8 — 2026-04-16

Consolidates the unreleased 0.0.7 work (CSRF rewrite, expanded usage rules) with Datastar v1.0 compatibility fixes. Users on 0.0.6 upgrading to 0.0.8 should read the Changed section — CSRF handling has been rewritten.

Changed

  • CSRF is no longer transported through a Datastar signal. Verb helpers (Dstar.post/2,3, get, put, patch, delete) no longer inject an {headers: {'x-csrf-token': $_csrfToken}} options object into generated expressions. Datastar reads the token from Phoenix's standard <meta name="csrf-token"> tag and sends it as an x-csrf-token header automatically. This decouples CSRF from Datastar's signal round-tripping and shortens every generated expression.

    Migration from 0.0.6: remove any data-signals:_csrf-token / $_csrfToken signal from your root layout. Keep the standard Phoenix <meta name="csrf-token" content={get_csrf_token()}> tag in <head>. If you use Datastar-driven form POSTs that go through Plug.CSRFProtection, expose the token as a non-prefixed csrf signal and keep Dstar.Plugs.RenameCsrfParam in your pipeline — that plug's role is now scoped to bridging form posts, not SSE.

  • Datastar version references bumped from 1.0.0-RC.8 to v1.0.0 in the README, doc/readme.md, and the datastar-attributes usage rule. v1.0's other changes (new data-bind __prop/__event modifiers, data-on __document modifier, morphing improvements, retryMaxWaitMs → retryMaxWait rename, Rocket JS API rewrite) are all client-side and need no Dstar changes.

Fixed

  • Dstar.Signals.read/1 now reads signals from query params for DELETE requests. Datastar v1.0 stopped sending a body on DELETE requests (#1144), so signals from Dstar.delete/2,3 actions arrived empty under the previous code path. read/1 now treats GET and DELETE the same — both read from the datastar query param.

Added

  • New usage-rules reference files ship with the package: error-handling.md, heex-rendering.md, loading-states.md, and a full datastar-attributes.md cheat sheet. Consumers of the usage_rules package will pick these up automatically.

  • HTTP/2 SSE connection limit docs added to the README, covering why the browser 6-connection-per-origin cap on HTTP/1.1 matters for long-lived Datastar streams and how HTTP/2 multiplexing removes it.

0.0.6 — 2026-03-21

Added

  • Dstar.Utility.StreamRegistry — Opt-in per-tab SSE stream deduplication. Tracks one stream process per user+tab using Elixir's Registry. When a new stream opens from the same tab, the previous process is killed instantly — no waiting for keepalive timeouts or PubSub broadcasts. Fixes zombie processes that hold subscriptions, run wasted DB queries, and exhaust the browser's 6-connection-per-origin limit on HTTP/1.1. Add it to your supervision tree, set a tabId signal in your root layout, and replace Dstar.start/1 with Dstar.start_stream/2. Falls back to Dstar.start/1 when no tabId is present. See the README's "Stream Deduplication" section for full setup.

  • Dstar.start_stream/2 — Convenience delegate to Dstar.Utility.StreamRegistry.start_stream/2.

0.0.5 — 2026-03-18

Added

  • Dstar.SSE.check_connection/1 — Checks if an SSE connection is still open by sending an SSE comment line. Returns {:ok, conn} if the connection is active, {:error, conn} if closed or not yet started. Useful for detecting disconnections in streaming loops. Also available via Dstar.check_connection/1.

  • Dstar.Signals.remove_signals/3 — Removes signals from the client by setting them to nil. Accepts a single dot-notated path string (e.g. "user.profile.theme") or a list of paths (e.g. ["user.name", "user.email"]). Paths with shared prefixes are deep-merged correctly: ["user.a", "user.b"] becomes %{"user" => %{"a" => nil, "b" => nil}}. Validates paths and raises on empty strings, leading/trailing/consecutive dots. Also available via Dstar.remove_signals/3 and Dstar.Signals.format_remove/2 for string formatting.

  • :namespace option for Dstar.Elements.patch/3 and Dstar.Elements.format_patch/2 — Specify element namespace: :html (default), :svg, or :mathml. When set to :svg or :mathml, emits a namespace data line in the SSE event. Default :html omits the line (backward compatible).

Changed

0.0.4 — 2026-03-15

Added

  • HTTP verb helpers — Dstar.post/2,3, Dstar.get/2,3, Dstar.put/2,3, Dstar.patch/2,3, Dstar.delete/2,3 generate @verb(...) expressions for Datastar attributes. Same API across all verbs.

Deprecated

  • Dstar.event/2,3 — use Dstar.post/2,3 (or the appropriate verb) instead. Still works, will be removed in a future version.

0.0.3 — 2026-03-15

Added

  • UsageRules integration — ships usage-rules.md, streaming sub-rule, and a pre-built use-dstar skill with API patterns reference. Consumers using the usage_rules package can pull these in automatically.

Changed

  • README rewrite — new Quick Start walks through routes → controller → event handler → template, showing patch_signals, patch_elements, execute_script, and console_log in one cohesive counter example. Dispatch is now the primary routing pattern; plain controller routes shown as an alternative in "Without Dispatch" section.

0.0.2 — 2026-03-15

Added

  • Migration guide from PhoenixDatastar to Dstar (docs/migrating-from-phoenix-datastar.md)

0.0.1 — 2025-03-12

Initial release after the grug-brain simplification. The library was gutted from 1,742 lines / 15 files down to ~735 lines / 6 files. Everything that reimplemented LiveView was deleted.

What's in the box

  • Dstar.SSE — Open SSE connections (start/1), send events (send_event/4, send_event!/4), format events as strings (format_event/2).

  • Dstar.Signals — Read Datastar signals from requests (read/1). Patch signals on the client via SSE (patch/3). Format signal patches as strings (format_patch/2).

  • Dstar.Elements — Patch DOM elements via SSE (patch/3) with selector, mode, and view transition support. Remove elements (remove/3). Format patches as strings (format_patch/2).

  • Dstar.Actions — Generate @post(...) expressions for Datastar attributes (event/1,2). Encode/decode Elixir module names to/from URL-safe strings (encode_module/1, decode_module/1).

  • Dstar.Plugs.Dispatch — Optional dynamic dispatch plug. Routes POST /ds/:module/:event to handler modules from an allowlist.

  • Dstar — Thin convenience module that delegates to the above.

What was removed

The following modules were deleted because they reimplemented LiveView's process-per-session model, which contradicts Datastar's client-holds-state architecture:

  • Dstar.Server — GenServer per session (284 lines)
  • Dstar.Socket — LiveView-style socket struct with event queues (315 lines)
  • Dstar.Plugs.Page — Custom HTML page renderer (147 lines)
  • Dstar.Plugs.Stream — Token-verified SSE streaming endpoint (80 lines)
  • Dstar.Scripts — Script execution via DOM patching (106 lines)
  • Dstar.Registry — Process registry for GenServers (28 lines)
  • Dstar.Token — Plug.Crypto token signing (48 lines)
  • Dstar.Application — OTP application that started the registry (15 lines)
  • Dstar.Helpers.JS — JS string escaping helper (13 lines)
  • Dstar behaviour and __using__ macro — mount/handle_event/render callback system (rewritten to thin delegation module)

Design

  • No processes. No supervision tree. No OTP application callback.
  • No behaviours. No macros. No use Dstar.
  • Two dependencies: plug and jason.
  • Designed to sit on top of deadview Phoenix. Use your controllers, templates, and layouts. The library just formats and sends SSE events.