Skip to content

Instantly share code, notes, and snippets.

@zigster64
Last active April 27, 2026 12:17
Show Gist options
  • Select an option

  • Save zigster64/363199ca74700ceb61430b19e650497e to your computer and use it in GitHub Desktop.

Select an option

Save zigster64/363199ca74700ceb61430b19e650497e to your computer and use it in GitHub Desktop.
Erlang Datastar module
-module(datastar).
-export([stream_init/1, stream_event/2]).
-export([patch_elements/1, patch_elements/2]).
-export([patch_signals/1, patch_signals/2]).
-export([execute_script/1, execute_script/2]).
-export([read_signals/1]).
-ifdef(TEST).
-compile(export_all).
-compile(nowarn_export_all).
-endif.
%%% ===========================================================
%%% Formats Datastar v1.0.0 Server-Sent Events for Cowboy.
%%% Conforms to the Datastar SDK ADR specification:
%%% https://github.com/starfederation/datastar/blob/develop/sdk/ADR.md
%%%
%%% Event types:
%%% datastar-patch-elements — DOM mutations
%%% datastar-patch-signals — signal updates (RFC 7386 merge patch)
%%%
%%% Script execution is implemented as patch-elements with a
%%% <script> tag, mode=append, selector=body.
%%% ===========================================================
%% Default values per ADR — only non-default values are emitted.
-define(PATCH_ELEMENTS_DEFAULTS, #{
selector => nil,
mode => <<"outer">>,
use_view_transition => false,
namespace => nil,
event_id => nil,
retry_duration => nil
}).
-define(PATCH_SIGNALS_DEFAULTS, #{
only_if_missing => false,
event_id => nil,
retry_duration => nil
}).
-define(EXECUTE_SCRIPT_DEFAULTS, #{
auto_remove => true,
attributes => #{},
event_id => nil,
retry_duration => nil
}).
%% =============================================================================
%% REQUEST BODY → SIGNALS
%% =============================================================================
%% @doc Read Datastar signals from the request. Automatically picks
%% the right strategy based on HTTP method:
%% GET/DELETE — signals in the `datastar` query parameter
%% POST/PUT/PATCH — signals in the request body (consumes it)
%% Returns {Signals, Req}. For GET the Req is unchanged; for POST
%% it reflects the consumed body.
-spec read_signals(cowboy_req:req()) -> {map(), cowboy_req:req()}.
read_signals(Req) ->
case cowboy_req:method(Req) of
<<"GET">> -> {read_signals_qs(Req), Req};
<<"DELETE">> -> {read_signals_qs(Req), Req};
_ -> read_signals_body(Req)
end.
%% @doc Read signals from the request body (POST/PUT/PATCH).
-spec read_signals_body(cowboy_req:req()) -> {map(), cowboy_req:req()}.
read_signals_body(Req0) ->
{ok, RawBody, Req} = cowboy_req:read_body(Req0),
Signals = case RawBody of
<<>> -> #{};
_ -> try json:decode(RawBody) catch _:_ -> #{} end
end,
{Signals, Req}.
%% @doc Read signals from the `datastar` query parameter (GET).
-spec read_signals_qs(cowboy_req:req()) -> map().
read_signals_qs(Req) ->
case cowboy_req:match_qs([{datastar, [], <<>>}], Req) of
#{datastar := <<>>} -> #{};
#{datastar := Json} ->
try json:decode(Json) catch _:_ -> #{} end
end.
%% =============================================================================
%% COWBOY SSE STREAMING
%% =============================================================================
%% @doc Open a Cowboy SSE stream with text/event-stream headers.
-spec stream_init(cowboy_req:req()) -> cowboy_req:req().
stream_init(Req) ->
cowboy_req:stream_reply(200,
#{<<"content-type">> => <<"text/event-stream">>,
<<"cache-control">> => <<"no-cache">>,
<<"x-accel-buffering">> => <<"no">>},
Req).
%% @doc Send a pre-formatted Datastar event over an open stream.
-spec stream_event(sse_event(), cowboy_req:req()) -> ok.
stream_event(Event, Req) ->
cowboy_req:stream_body(Event, nofin, Req).
%% =============================================================================
%% PATCH ELEMENTS
%% =============================================================================
%% @doc Format a patch-elements event with default options.
-spec patch_elements(binary()) -> sse_event().
patch_elements(Html) when is_binary(Html) ->
patch_elements(Html, #{}).
%% @doc Format a patch-elements event with custom options.
%% Options: selector, mode, use_view_transition, namespace, event_id, retry_duration
-spec patch_elements(binary(), map()) -> sse_event().
patch_elements(Html, UserOpts) when is_binary(Html) ->
Opts = maps:merge(?PATCH_ELEMENTS_DEFAULTS, UserOpts),
Mode = maps:get(mode, Opts),
ElementsBlock = case {Html, Mode} of
{<<>>, <<"remove">>} -> [];
_ -> format_data_block(<<"elements">>, Html)
end,
iolist_to_binary([
<<"event: datastar-patch-elements\n">>,
format_id(maps:get(event_id, Opts)),
format_retry(maps:get(retry_duration, Opts)),
emit_if_not_default(<<"selector">>, maps:get(selector, Opts), nil),
emit_if_not_default(<<"mode">>, Mode, <<"outer">>),
emit_if_not_default(<<"useViewTransition">>, maps:get(use_view_transition, Opts), false),
emit_if_not_default(<<"namespace">>, maps:get(namespace, Opts), nil),
ElementsBlock,
<<"\n">>
]).
%% =============================================================================
%% PATCH SIGNALS
%% =============================================================================
%% @doc Format a patch-signals event with default options.
-spec patch_signals(binary()) -> sse_event().
patch_signals(Json) when is_binary(Json) ->
patch_signals(Json, #{}).
%% @doc Format a patch-signals event with custom options.
%% Options: only_if_missing, event_id, retry_duration
-spec patch_signals(binary(), map()) -> sse_event().
patch_signals(Json, UserOpts) when is_binary(Json) ->
Opts = maps:merge(?PATCH_SIGNALS_DEFAULTS, UserOpts),
iolist_to_binary([
<<"event: datastar-patch-signals\n">>,
format_id(maps:get(event_id, Opts)),
format_retry(maps:get(retry_duration, Opts)),
emit_if_not_default(<<"onlyIfMissing">>, maps:get(only_if_missing, Opts), false),
format_data_block(<<"signals">>, Json),
<<"\n">>
]).
%% =============================================================================
%% EXECUTE SCRIPT (via patch-elements with <script> tag)
%% =============================================================================
%% @doc Format an execute-script event with default options.
-spec execute_script(binary()) -> sse_event().
execute_script(Script) when is_binary(Script) ->
execute_script(Script, #{}).
%% @doc Format an execute-script event with custom options.
%% Options: auto_remove, attributes (map of attr=>value), event_id, retry_duration
-spec execute_script(binary(), map()) -> sse_event().
execute_script(Script, UserOpts) when is_binary(Script) ->
Opts = maps:merge(?EXECUTE_SCRIPT_DEFAULTS, UserOpts),
AutoRemove = maps:get(auto_remove, Opts),
Attributes = maps:get(attributes, Opts),
%% Build <script> tag with attributes
AttrBin = format_script_attrs(Attributes, AutoRemove),
ScriptHtml = <<"<script", AttrBin/binary, ">", Script/binary, "</script>">>,
patch_elements(ScriptHtml, #{
mode => <<"append">>,
selector => <<"body">>,
event_id => maps:get(event_id, Opts),
retry_duration => maps:get(retry_duration, Opts)
}).
%% =============================================================================
%% INTERNAL HELPERS
%% =============================================================================
%% @doc Splits content by newlines and prefixes each line with "data: <Type> ".
-spec format_data_block(binary(), binary()) -> iolist().
format_data_block(Type, Content) ->
Lines = binary:split(Content, <<"\n">>, [global]),
[[<<"data: ">>, Type, <<" ">>, Line, <<"\n">>] || Line <- Lines].
%% @doc Emit a "data: Key Value\n" line only if Value differs from Default.
-spec emit_if_not_default(binary(), term(), term()) -> binary().
emit_if_not_default(_, Value, Default) when Value =:= Default -> <<>>;
emit_if_not_default(_, nil, _) -> <<>>;
emit_if_not_default(Key, true, _) ->
<<"data: ", Key/binary, " true\n">>;
emit_if_not_default(Key, false, _) ->
<<"data: ", Key/binary, " false\n">>;
emit_if_not_default(Key, Value, _) when is_binary(Value) ->
<<"data: ", Key/binary, " ", Value/binary, "\n">>;
emit_if_not_default(Key, Value, _) when is_atom(Value) ->
V = atom_to_binary(Value, utf8),
<<"data: ", Key/binary, " ", V/binary, "\n">>;
emit_if_not_default(Key, Value, _) when is_integer(Value) ->
V = integer_to_binary(Value),
<<"data: ", Key/binary, " ", V/binary, "\n">>.
%% @doc Format the SSE "id:" line. Omitted if nil.
-spec format_id(binary() | nil) -> binary().
format_id(nil) -> <<>>;
format_id(Id) when is_binary(Id) -> <<"id: ", Id/binary, "\n">>.
%% @doc Format the SSE "retry:" line. Omitted if nil.
-spec format_retry(integer() | nil) -> binary().
format_retry(nil) -> <<>>;
format_retry(Ms) when is_integer(Ms) ->
V = integer_to_binary(Ms),
<<"retry: ", V/binary, "\n">>.
%% @doc Build HTML attributes string for a <script> tag.
-spec format_script_attrs(map(), boolean()) -> binary().
format_script_attrs(Attrs, AutoRemove) ->
%% Start with user-provided attributes
AttrList = maps:fold(fun(K, V, Acc) ->
KB = to_bin(K),
VB = to_bin(V),
[<<" ", KB/binary, "=\"", VB/binary, "\"">> | Acc]
end, [], Attrs),
%% Add auto-remove if enabled
AutoAttr = case AutoRemove of
true -> [<<" data-effect=\"el.remove()\"">>];
false -> []
end,
iolist_to_binary(AttrList ++ AutoAttr).
-spec to_bin(term()) -> binary().
to_bin(V) when is_binary(V) -> V;
to_bin(V) when is_atom(V) -> atom_to_binary(V, utf8);
to_bin(V) when is_integer(V) -> integer_to_binary(V);
to_bin(V) when is_list(V) -> list_to_binary(V).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment