Last active
April 27, 2026 12:17
-
-
Save zigster64/363199ca74700ceb61430b19e650497e to your computer and use it in GitHub Desktop.
Erlang Datastar module
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -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