Current section

Files

Jump to
ssevents src ssevents@event.erl
Raw

src/ssevents@event.erl

-module(ssevents@event).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/ssevents/event.gleam").
-export([comment_text_of/1, comment_item_of/1, is_event/1, is_comment/1, event_of_item/1, comment_text_of_item/1, comment/1, comment_item/1, event/2, id/2, event_checked/2, id_checked/2, comment_checked/1, retry/2, retry_clamp/2, new/1, from_parts/4, message/1, named/2, named_checked/2, data/2, name_of/1, data_of/1, id_of/1, retry_of/1, event_item/1]).
-export_type([event/0, comment/0, item/0, event_error/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Event and item domain values.\n"
"\n"
" `Event` and `Comment` are opaque so the package can evolve their\n"
" representation without a breaking change. Construct via `new`,\n"
" `from_parts`, and the builder helpers (for `Event`) or `comment`\n"
" (for `Comment`). `Item` stays transparent so callers and helper\n"
" modules can pattern match on whether a stream element is an event\n"
" or a comment.\n"
).
-opaque event() :: {event,
gleam@option:option(binary()),
binary(),
gleam@option:option(binary()),
gleam@option:option(integer())}.
-opaque comment() :: {comment, binary()}.
-type item() :: {event_item, event()} | {comment_item, comment()}.
-type event_error() :: {name_contains_control_bytes, binary()} |
{id_contains_control_bytes, binary()} |
{comment_contains_control_bytes, binary()}.
-file("src/ssevents/event.gleam", 83).
?DOC(" Extract the sanitised comment text.\n").
-spec comment_text_of(comment()) -> binary().
comment_text_of(C) ->
erlang:element(2, C).
-file("src/ssevents/event.gleam", 97).
?DOC(
" Wrap an already-validated `Comment` as a stream item. Companion\n"
" to `comment_checked/1` so callers can keep the typed-error\n"
" pipeline `text -> Result(Comment, EventError) -> Result(Item, _)`\n"
" without reaching into `Item`'s constructors. (#81)\n"
).
-spec comment_item_of(comment()) -> item().
comment_item_of(C) ->
{comment_item, C}.
-file("src/ssevents/event.gleam", 109).
?DOC(
" Issue #77: Item-level accessors. The `Item` variants `EventItem` /\n"
" `CommentItem` are not visible through the top-level `ssevents`\n"
" module (a Gleam type alias does not re-export its constructors), so\n"
" callers that decode an SSE stream and want to pattern-match on the\n"
" result would otherwise need to reach into `ssevents/event` directly.\n"
" These helpers let `ssevents`-only callers walk decoded items without\n"
" the second import.\n"
" `True` when the item is an event (carries an SSE `Event` payload).\n"
).
-spec is_event(item()) -> boolean().
is_event(Item) ->
case Item of
{event_item, _} ->
true;
{comment_item, _} ->
false
end.
-file("src/ssevents/event.gleam", 117).
?DOC(" `True` when the item is a `:`-prefixed comment line.\n").
-spec is_comment(item()) -> boolean().
is_comment(Item) ->
case Item of
{comment_item, _} ->
true;
{event_item, _} ->
false
end.
-file("src/ssevents/event.gleam", 127).
?DOC(
" Return the event payload when the item is an event, `None`\n"
" otherwise. Use with `option.then` / `case` for stream processing\n"
" that ignores comments.\n"
).
-spec event_of_item(item()) -> gleam@option:option(event()).
event_of_item(Item) ->
case Item of
{event_item, Ev} ->
{some, Ev};
{comment_item, _} ->
none
end.
-file("src/ssevents/event.gleam", 136).
?DOC(
" Return the comment text when the item is a comment, `None`\n"
" otherwise. Mirrors `event_of_item/1` for the comment side.\n"
).
-spec comment_text_of_item(item()) -> gleam@option:option(binary()).
comment_text_of_item(Item) ->
case Item of
{comment_item, C} ->
{some, comment_text_of(C)};
{event_item, _} ->
none
end.
-file("src/ssevents/event.gleam", 295).
-spec has_forbidden_byte(binary()) -> boolean().
has_forbidden_byte(Value) ->
(gleam_stdlib:contains_string(Value, <<"\r"/utf8>>) orelse gleam_stdlib:contains_string(
Value,
<<"\n"/utf8>>
))
orelse gleam_stdlib:contains_string(Value, <<"\x{0000}"/utf8>>).
-file("src/ssevents/event.gleam", 315).
?DOC(
" Strip CR (U+000D), LF (U+000A), and NUL (U+0000) from `value`.\n"
"\n"
" CR / LF inside an `event` or `id` value cannot survive the SSE wire\n"
" format — both are line terminators per WHATWG SSE §9.2.5, so a\n"
" literal CR / LF inside the value would split the field across two\n"
" lines on encode and the decoder would parse the post-LF tail as an\n"
" unrelated unknown field. NUL inside `id` is ignored by the decoder\n"
" per §9.2.6, breaking round-trip.\n"
"\n"
" Stripping silently at construction time is the same posture\n"
" `multipartkit/form.add_field` takes for the analogous header-injection\n"
" risk: it keeps `from_parts`, `event/2`, and `id/2` infallible while\n"
" guaranteeing that `decode(encode(x))` round-trips for any caller-built\n"
" `Event`.\n"
).
-spec sanitize_field_value(binary()) -> binary().
sanitize_field_value(Value) ->
_pipe = Value,
_pipe@1 = gleam@string:replace(_pipe, <<"\r\n"/utf8>>, <<""/utf8>>),
_pipe@2 = gleam@string:replace(_pipe@1, <<"\r"/utf8>>, <<""/utf8>>),
_pipe@3 = gleam@string:replace(_pipe@2, <<"\n"/utf8>>, <<""/utf8>>),
gleam@string:replace(_pipe@3, <<"\x{0000}"/utf8>>, <<""/utf8>>).
-file("src/ssevents/event.gleam", 78).
?DOC(
" Build a `Comment` from a text payload. CR (U+000D), LF (U+000A),\n"
" and NUL (U+0000) are stripped at construction so the result\n"
" round-trips through `encode → decode` without fanning out into\n"
" multiple comments. Matches the `sanitize_field_value` posture\n"
" already used for `event_name` and `id`.\n"
"\n"
" The silent strip is data loss the caller cannot observe. Reach\n"
" for `comment_checked/1` instead when comment text comes from\n"
" user-typed or upstream input and a typed error is preferable to\n"
" a silently-truncated comment. (#81)\n"
).
-spec comment(binary()) -> comment().
comment(Text) ->
{comment, sanitize_field_value(Text)}.
-file("src/ssevents/event.gleam", 89).
?DOC(
" Wrap a `Comment` as a stream item. Convenience for the common\n"
" `CommentItem(comment(text))` two-step.\n"
).
-spec comment_item(binary()) -> item().
comment_item(Text) ->
{comment_item, comment(Text)}.
-file("src/ssevents/event.gleam", 199).
?DOC(
" Set the SSE `event:` field name on an event. CR / LF / NUL bytes\n"
" are silently stripped to keep the wire spec-compliant — so\n"
" `named(\"\\n\", _)` produces an event with `name = \"\"`. The strip\n"
" is data loss the caller cannot observe; reach for\n"
" `event_checked/2` when the name comes from user-typed or\n"
" upstream input and a typed error is preferable to silent data\n"
" loss. (#81)\n"
).
-spec event(event(), binary()) -> event().
event(Event, Name) ->
{event,
{some, sanitize_field_value(Name)},
erlang:element(3, Event),
erlang:element(4, Event),
erlang:element(5, Event)}.
-file("src/ssevents/event.gleam", 215).
?DOC(
" Set the SSE `id:` Last-Event-ID on an event. CR / LF / NUL bytes\n"
" are silently stripped — so `id(_, \"ab\\u{0000}cd\")` produces an\n"
" event with `id = \"abcd\"`, a *different valid id*, which can\n"
" silently match the wrong subscription channel on reconnect.\n"
" Reach for `id_checked/2` when the id comes from user-typed or\n"
" upstream input and a typed error is preferable to silent\n"
" authorization-relevant identifier mutation. (#81)\n"
).
-spec id(event(), binary()) -> event().
id(Event, Id) ->
{event,
erlang:element(2, Event),
erlang:element(3, Event),
{some, sanitize_field_value(Id)},
erlang:element(5, Event)}.
-file("src/ssevents/event.gleam", 234).
?DOC(
" Strict counterpart of `event/2`: rejects names containing CR /\n"
" LF / NUL bytes with `Error(NameContainsControlBytes(value:))`.\n"
"\n"
" The non-strict `event/2` silently strips these bytes (so\n"
" `named(\"\\n\", _)` produces a part with `name = \"\"`). For callers\n"
" passing user-typed or upstream data into the event name and want\n"
" to surface bad inputs as a typed error rather than silent data\n"
" loss, use this variant. The `value` payload carries the\n"
" caller's original input so the error renders as\n"
" \"event name `foo\\\\nbar` contains forbidden control bytes\". (#81)\n"
).
-spec event_checked(event(), binary()) -> {ok, event()} | {error, event_error()}.
event_checked(Source_event, Name) ->
case has_forbidden_byte(Name) of
true ->
{error, {name_contains_control_bytes, Name}};
false ->
{ok, event(Source_event, Name)}
end.
-file("src/ssevents/event.gleam", 261).
-spec id_internal(event(), binary()) -> event().
id_internal(Event, Id_value) ->
{event,
erlang:element(2, Event),
erlang:element(3, Event),
{some, sanitize_field_value(Id_value)},
erlang:element(5, Event)}.
-file("src/ssevents/event.gleam", 254).
?DOC(
" Strict counterpart of `id/2`: rejects ids containing CR / LF /\n"
" NUL bytes with `Error(IdContainsControlBytes(value:))`.\n"
"\n"
" The non-strict `id/2` silently strips these bytes. The strip on\n"
" the id is especially dangerous — `id(_, \"ab\\u{0000}cd\")`\n"
" produces an event with `id = \"abcd\"`, a *different valid id*,\n"
" which can silently match the wrong subscription channel on\n"
" reconnect (Last-Event-ID resume). The strict variant catches\n"
" this at the builder boundary so the wrong wire never gets\n"
" produced. (#81)\n"
).
-spec id_checked(event(), binary()) -> {ok, event()} | {error, event_error()}.
id_checked(Event, Id) ->
case has_forbidden_byte(Id) of
true ->
{error, {id_contains_control_bytes, Id}};
false ->
{ok, id_internal(Event, Id)}
end.
-file("src/ssevents/event.gleam", 288).
?DOC(
" Strict counterpart of `comment/1`: rejects comment text\n"
" containing CR / LF / NUL bytes with\n"
" `Error(CommentContainsControlBytes(value:))`.\n"
"\n"
" The non-strict `comment/1` silently strips these bytes.\n"
" WHATWG SSE §9.2.6 has no notion of a multi-line comment, so\n"
" embedded line breaks would fan out into multiple comments on\n"
" the wire; the strict variant surfaces this as an explicit\n"
" error rather than silently splitting the caller's intent. (#81)\n"
).
-spec comment_checked(binary()) -> {ok, comment()} | {error, event_error()}.
comment_checked(Text) ->
case has_forbidden_byte(Text) of
true ->
{error, {comment_contains_control_bytes, Text}};
false ->
{ok, comment(Text)}
end.
-file("src/ssevents/event.gleam", 323).
-spec option_sanitize(gleam@option:option(binary())) -> gleam@option:option(binary()).
option_sanitize(Opt) ->
case Opt of
none ->
none;
{some, Value} ->
{some, sanitize_field_value(Value)}
end.
-file("src/ssevents/event.gleam", 392).
?DOC(
" Drop retry values that the SSE wire format / decoder will not\n"
" round-trip back to `Some(n)` under the default `Limits`.\n"
"\n"
" WHATWG SSE §9.2.6 only recognises retry values whose textual form\n"
" is ASCII digits, so a negative value would be silently dropped on\n"
" decode. Values above `limit.default_max_retry_value` (24 hours in\n"
" milliseconds) hard-fail the default decoder. Coercing both to\n"
" `None` here matches the silent-sanitisation posture\n"
" `sanitize_field_value` takes for `event:` and `id:`, so\n"
" `decode(encode(event))` returns the same event for any caller-built\n"
" `Event`. (#60)\n"
).
-spec sanitize_retry(gleam@option:option(integer())) -> gleam@option:option(integer()).
sanitize_retry(Retry) ->
case Retry of
none ->
none;
{some, Ms} ->
case (Ms < 0) orelse (Ms > 86400000) of
true ->
none;
false ->
{some, Ms}
end
end.
-file("src/ssevents/event.gleam", 345).
?DOC(
" Set the SSE `retry:` reconnection time on an event.\n"
"\n"
" `milliseconds` must be `>= 0`. WHATWG SSE §9.2.6 only recognises a\n"
" retry value whose textual form \"consists of only ASCII digits\", so a\n"
" negative value would be either dropped on the wire (the leading `-`\n"
" breaks the digits-only check) or interpreted as `0` and trigger a\n"
" tight reconnect loop against the server. Either outcome is a\n"
" contract violation, so the builder panics on `ms < 0` with\n"
" `\"ssevents.retry: milliseconds must be >= 0 (got <n>); the SSE spec\n"
" mandates a non-negative reconnection time.\"`. Use `retry_clamp/2`\n"
" instead when the caller wants the lenient (clamp-to-`0`) behaviour.\n"
"\n"
" Values above `limit.default_max_retry_value` (24 h in ms) are still\n"
" silently dropped to `None`, matching `from_parts/4` and the\n"
" `decode(encode(_))` round-trip property pinned in #60.\n"
).
-spec retry(event(), integer()) -> event().
retry(Event, Milliseconds) ->
case Milliseconds < 0 of
true ->
erlang:error(#{gleam_error => panic,
message => (<<<<"ssevents.retry: milliseconds must be >= 0 (got "/utf8,
(erlang:integer_to_binary(Milliseconds))/binary>>/binary,
"); the SSE spec mandates a non-negative reconnection time."/utf8>>),
file => <<?FILEPATH/utf8>>,
module => <<"ssevents/event"/utf8>>,
function => <<"retry"/utf8>>,
line => 348});
false ->
{event,
erlang:element(2, Event),
erlang:element(3, Event),
erlang:element(4, Event),
sanitize_retry({some, Milliseconds})}
end.
-file("src/ssevents/event.gleam", 368).
?DOC(
" Like `retry/2`, but clamps `milliseconds < 0` to `0` instead of\n"
" panicking. Use this when the caller wants the lenient posture\n"
" (e.g. when forwarding a value computed from possibly-noisy input).\n"
" All other behaviour matches `retry/2`, including the `> max_retry`\n"
" silent drop to `None` for round-trip with the default decoder.\n"
).
-spec retry_clamp(event(), integer()) -> event().
retry_clamp(Event, Milliseconds) ->
Clamped = case Milliseconds < 0 of
true ->
0;
false ->
Milliseconds
end,
{event,
erlang:element(2, Event),
erlang:element(3, Event),
erlang:element(4, Event),
sanitize_retry({some, Clamped})}.
-file("src/ssevents/event.gleam", 435).
-spec map_data_grapheme(binary()) -> list(binary()).
map_data_grapheme(Grapheme) ->
case Grapheme of
<<"\r"/utf8>> ->
[];
<<"\x{0000}"/utf8>> ->
[];
<<"\r\n"/utf8>> ->
[<<"\n"/utf8>>];
Other ->
[Other]
end.
-file("src/ssevents/event.gleam", 428).
?DOC(
" Strip CR (U+000D) and NUL (U+0000) from a `data` value, and\n"
" convert standalone CRLF graphemes to LF.\n"
"\n"
" WHATWG SSE §9.2.6 normalises CR / CRLF / LF to LF on the wire side\n"
" and silently drops NUL, so neither sequence can survive\n"
" `decode(encode(x))` verbatim inside `data`. Strip / normalise both\n"
" at construction so the in-memory representation already matches\n"
" what the wire would carry. LF is preserved — `data` may\n"
" legitimately contain logical newlines, and the encoder splits on\n"
" LF to emit multi-line `data:` blocks; the decoder rejoins those\n"
" lines with LF, so `\\n` round-trips cleanly.\n"
"\n"
" The implementation maps over Unicode graphemes (`\\r\\n` is a single\n"
" grapheme per UAX #29). A `string.replace`-based pass cannot strip\n"
" the `\\r` half of a CRLF pair on the JavaScript target because the\n"
" CRLF grapheme is opaque to substring search.\n"
).
-spec sanitize_data_value(binary()) -> binary().
sanitize_data_value(Value) ->
_pipe = Value,
_pipe@1 = gleam@string:to_graphemes(_pipe),
_pipe@2 = gleam@list:flat_map(_pipe@1, fun map_data_grapheme/1),
gleam@string:join(_pipe@2, <<""/utf8>>).
-file("src/ssevents/event.gleam", 143).
-spec new(binary()) -> event().
new(Data) ->
{event, none, sanitize_data_value(Data), none, none}.
-file("src/ssevents/event.gleam", 147).
-spec from_parts(
gleam@option:option(binary()),
binary(),
gleam@option:option(binary()),
gleam@option:option(integer())
) -> event().
from_parts(Event_name, Data, Id, Retry) ->
case Retry of
{some, Ms} when Ms < 0 ->
erlang:error(#{gleam_error => panic,
message => (<<<<"ssevents.from_parts: retry milliseconds must be >= 0 (got "/utf8,
(erlang:integer_to_binary(Ms))/binary>>/binary,
"); the SSE spec mandates a non-negative reconnection time. Use retry_clamp via the builder if a lenient posture is wanted."/utf8>>),
file => <<?FILEPATH/utf8>>,
module => <<"ssevents/event"/utf8>>,
function => <<"from_parts"/utf8>>,
line => 165});
_ ->
nil
end,
{event,
option_sanitize(Event_name),
sanitize_data_value(Data),
option_sanitize(Id),
sanitize_retry(Retry)}.
-file("src/ssevents/event.gleam", 180).
-spec message(binary()) -> event().
message(Data) ->
new(Data).
-file("src/ssevents/event.gleam", 188).
?DOC(
" Build an event with both `name` and `data`. CR / LF / NUL bytes\n"
" in `name` are silently stripped — see the warning on `event/2`.\n"
" Reach for `named_checked/2` when the bad-input case must be\n"
" surfaced as a typed error. (#81)\n"
).
-spec named(binary(), binary()) -> event().
named(Name, Data) ->
_pipe = new(Data),
event(_pipe, Name).
-file("src/ssevents/event.gleam", 275).
?DOC(
" Strict counterpart of `named/2`: rejects names containing CR /\n"
" LF / NUL bytes with `Error(NameContainsControlBytes(value:))`.\n"
"\n"
" Convenience for the common `new |> event_checked` pipeline that\n"
" also constructs a fresh `Event`. (#81)\n"
).
-spec named_checked(binary(), binary()) -> {ok, event()} |
{error, event_error()}.
named_checked(Name, Data) ->
event_checked(new(Data), Name).
-file("src/ssevents/event.gleam", 403).
-spec data(event(), binary()) -> event().
data(Event, Data) ->
{event,
erlang:element(2, Event),
sanitize_data_value(Data),
erlang:element(4, Event),
erlang:element(5, Event)}.
-file("src/ssevents/event.gleam", 444).
-spec name_of(event()) -> gleam@option:option(binary()).
name_of(Event) ->
erlang:element(2, Event).
-file("src/ssevents/event.gleam", 448).
-spec data_of(event()) -> binary().
data_of(Event) ->
erlang:element(3, Event).
-file("src/ssevents/event.gleam", 452).
-spec id_of(event()) -> gleam@option:option(binary()).
id_of(Event) ->
erlang:element(4, Event).
-file("src/ssevents/event.gleam", 456).
-spec retry_of(event()) -> gleam@option:option(integer()).
retry_of(Event) ->
erlang:element(5, Event).
-file("src/ssevents/event.gleam", 460).
-spec event_item(event()) -> item().
event_item(Event) ->
{event_item, Event}.