Current section

Files

Jump to
req_server_sent_events lib req_server_sent_events frame.ex
Raw

lib/req_server_sent_events/frame.ex

defmodule ReqServerSentEvents.Frame do
@moduledoc """
Pure SSE frame parser. No IO, no processes, no external dependencies.
An SSE frame is a sequence of `field: value` lines terminated by a blank line (`\\n\\n`).
Recognised fields: `event`, `data`, `id`, `retry`. Lines starting with `:` are comments.
Multiple `data:` lines within one frame are concatenated with `"\\n"`.
"""
defstruct event: nil, data: nil, id: nil, retry: nil, comments: []
@type t :: %__MODULE__{
event: String.t() | nil,
data: String.t() | nil,
id: String.t() | nil,
retry: non_neg_integer() | nil,
comments: [String.t()]
}
@frame_delimiters ["\n\n", "\r\n\r\n", "\r\r"]
@doc """
Split a byte buffer on the SSE frame delimiter.
Recognises `"\\n\\n"`, `"\\r\\n\\r\\n"`, and `"\\r\\r"` as frame
delimiters per SSE spec §9.2.4. Returned frame strings retain their
original line endings; `parse/1` handles all three terminator forms.
A leading UTF-8 byte order mark (`"\\uFEFF"`) is stripped per the spec.
Returns `{complete_frames, leftover}` where `complete_frames` is a list of raw
frame strings (without the trailing delimiter) and `leftover` is the remaining
bytes that have not yet formed a complete frame.
## Examples
iex> ReqServerSentEvents.Frame.split("data: hello\\n\\n")
{["data: hello"], ""}
iex> ReqServerSentEvents.Frame.split("data: hello\\r\\n\\r\\n")
{["data: hello"], ""}
iex> ReqServerSentEvents.Frame.split("data: partial")
{[], "data: partial"}
"""
@spec split(binary()) :: {[binary()], binary()}
def split(buffer) when is_binary(buffer) do
buffer = String.trim_leading(buffer, <<0xEF, 0xBB, 0xBF>>)
# Fast path: LF-only streams (the common case) need only `\n\n`. Falling
# through to the multi-pattern match is ~6× slower per byte because the
# multi-pattern matcher uses Aho-Corasick where single-pattern uses
# Boyer-Moore. Probing for `\r` keeps spec compliance for CR/CRLF streams
# without paying that cost on LF.
pattern =
case :binary.match(buffer, "\r") do
:nomatch -> "\n\n"
_ -> @frame_delimiters
end
parts = :binary.split(buffer, pattern, [:global])
{complete, [leftover]} = Enum.split(parts, -1)
{Enum.reject(complete, &(&1 == "")), leftover}
end
@doc """
Parse one complete raw frame string (without the trailing `"\\n\\n"`) into a `%Frame{}`.
Frames with no `data:` field are returned as-is — the caller decides whether to
dispatch or discard them. Unknown field names are silently ignored per the SSE spec.
## Examples
iex> ReqServerSentEvents.Frame.parse("event: ping\\ndata: {}")
%ReqServerSentEvents.Frame{event: "ping", data: "{}"}
iex> ReqServerSentEvents.Frame.parse(": keepalive")
%ReqServerSentEvents.Frame{comments: ["keepalive"]}
"""
@spec parse(binary()) :: t()
def parse(raw) when is_binary(raw) do
frame =
raw
|> String.split(["\r\n", "\n", "\r"], trim: true)
|> Enum.reduce(%__MODULE__{}, &parse_line/2)
%{frame | comments: Enum.reverse(frame.comments)}
end
# Comment line — everything after the leading ":"
defp parse_line(":" <> rest, frame) do
comment = String.replace_prefix(rest, " ", "")
%{frame | comments: [comment | frame.comments]}
end
defp parse_line(line, frame) do
{field, value} =
case :binary.split(line, ":") do
[k, v] -> {k, String.replace_prefix(v, " ", "")}
[k] -> {k, ""}
end
apply_field(frame, field, value)
end
defp apply_field(frame, "event", value), do: %{frame | event: value}
# Spec §9.2.6: id values containing U+0000 NULL must be ignored.
defp apply_field(frame, "id", value) do
if String.contains?(value, <<0>>), do: frame, else: %{frame | id: value}
end
defp apply_field(frame, "data", value) do
case frame.data do
nil -> %{frame | data: value}
existing -> %{frame | data: existing <> "\n" <> value}
end
end
# Spec §9.2.6: retry must be a non-negative integer; otherwise ignore the field.
defp apply_field(frame, "retry", value) do
case Integer.parse(value) do
{ms, ""} when ms >= 0 -> %{frame | retry: ms}
_ -> frame
end
end
# Unknown fields silently ignored per spec
defp apply_field(frame, _field, _value), do: frame
end