Current section

Files

Jump to
req_server_sent_events lib req_server_sent_events.ex
Raw

lib/req_server_sent_events.ex

defmodule ReqServerSentEvents do
@moduledoc """
Req plugin for Server-Sent Events (SSE).
Attach to any `%Req.Request{}` via `attach/1`. The plugin intercepts
Req's three streaming hooks and transparently decodes raw SSE byte chunks
into `%ReqServerSentEvents.Frame{}` structs.
## Usage
# into: fun — frames delivered as {:sse_event, %Frame{}} arguments
url
|> Req.new(into: fn {:sse_event, frame}, {req, resp} ->
IO.inspect(frame)
{:cont, {req, resp}}
end)
|> ReqServerSentEvents.attach()
|> Req.get!()
# into: :self — frames sent as messages to the calling process
task = Task.async(fn ->
url
|> Req.new(into: :self)
|> ReqServerSentEvents.attach()
|> Req.get!()
end)
resp = Task.await(task)
sse_ref = ReqServerSentEvents.ref(resp)
receive do
{^sse_ref, {:sse_event, frame}} -> IO.inspect(frame)
{^sse_ref, :sse_done} -> :done
end
# into: collectable — frames collected into any Collectable
{:ok, resp} =
url
|> Req.new(into: [])
|> ReqServerSentEvents.attach()
|> Req.get()
frames = resp.body # [%ReqServerSentEvents.Frame{}, ...]
## Telemetry
The plugin emits the following `:telemetry` events:
* `[:req_server_sent_events, :stream, :start]` — emitted on the first
decoded chunk of an `into: fun` or `into: :self` request.
Measurements: `%{system_time, monotonic_time}`. Metadata: `%{request: req}`.
* `[:req_server_sent_events, :frame, :decoded]` — emitted once per
decoded frame in any mode.
Measurements: `%{bytes: byte_size(raw)}`. Metadata: `%{frame: frame}`.
* `[:req_server_sent_events, :stream, :stop]` — emitted in a response
step when an `into: fun` or `into: :self` stream ends. Only fires if
`:start` fired (i.e. at least one chunk was processed).
Measurements: `%{monotonic_time, duration}`. Metadata: `%{request: req, response: resp}`.
`:start` and `:stop` are not emitted for `into: collectable` requests —
the wrapper has no access to the response. For stream-level timing in that
mode, use Req's own `[:req, :request, :start | :stop]` events.
"""
@doc """
Attach the SSE decoder to a `%Req.Request{}`.
Rewrites `req.into` to decode raw SSE byte chunks into
`%ReqServerSentEvents.Frame{}` structs before they reach the caller.
When `into: :self` is used, also registers a response step that sends
a `{ref, :sse_done}` sentinel once the stream closes.
## Options
* `:max_frame_size` — maximum number of bytes allowed to accumulate in
the pending-frame buffer between delimiters. If the buffer grows past
this limit without seeing a `"\\n\\n"`, a
`ReqServerSentEvents.FrameTooLargeError` is raised. Defaults to `nil`
(unbounded).
"""
@spec attach(Req.Request.t(), keyword()) :: Req.Request.t()
def attach(req, opts \\ [])
def attach(%Req.Request{} = req, opts) do
req
|> put_max_frame_size(Keyword.get(opts, :max_frame_size))
|> sse_rewrite()
end
defp put_max_frame_size(req, nil), do: req
defp put_max_frame_size(req, n) when is_integer(n) and n > 0 do
Req.Request.put_private(req, :sse_max_frame_size, n)
end
@doc """
Return the SSE ref for a `into: :self` request.
Accepts either the final `%Req.Request{}` or `%Req.Response{}` — Req's
high-level functions (`Req.get!/2` etc.) return only the response, while
`Req.request/2` returns `{request, response}`.
"""
@spec ref(Req.Request.t() | Req.Response.t()) :: reference() | nil
def ref(%Req.Request{} = req), do: req.private[:sse_ref]
def ref(%Req.Response{} = resp), do: resp.private[:sse_ref]
defp sse_rewrite(%Req.Request{into: nil} = req), do: req
defp sse_rewrite(%Req.Request{into: :self} = req),
do: req |> wrap_self() |> add_telemetry_stop()
defp sse_rewrite(%Req.Request{into: f} = req) when is_function(f, 2),
do: req |> wrap_fun(f) |> add_telemetry_stop()
defp sse_rewrite(%Req.Request{into: c} = req), do: wrap_collectable(req, c)
defp add_telemetry_stop(req) do
Req.Request.append_response_steps(req, sse_telemetry_stop: &emit_stop_if_started/1)
end
defp wrap_fun(%Req.Request{} = req, user_fun) do
max_size = req.private[:sse_max_frame_size]
wrapped = fn {:data, chunk}, {req, resp} ->
resp = emit_start_if_needed(req, resp)
{frames, leftover} =
ReqServerSentEvents.Internal.decode_chunk(resp.private[:sse_buf] || "", chunk, max_size)
resp = put_in(resp.private[:sse_buf], leftover)
Enum.reduce_while(frames, {:cont, {req, resp}}, &reduce_frame(&1, &2, user_fun))
end
%{req | into: wrapped}
end
defp reduce_frame(frame, {:cont, {req, resp}}, user_fun) do
case user_fun.({:sse_event, frame}, {req, resp}) do
{:cont, acc} -> {:cont, {:cont, acc}}
{:halt, acc} -> {:halt, {:halt, acc}}
end
end
defp wrap_self(%Req.Request{} = req) do
caller = self()
sse_ref = make_ref()
max_size = req.private[:sse_max_frame_size]
wrapped = fn {:data, chunk}, {req, resp} ->
resp = emit_start_if_needed(req, resp)
{frames, leftover} =
ReqServerSentEvents.Internal.decode_chunk(resp.private[:sse_buf] || "", chunk, max_size)
resp = put_in(resp.private[:sse_buf], leftover)
Enum.each(frames, &send(caller, {sse_ref, {:sse_event, &1}}))
{:cont, {req, resp}}
end
send_done = fn {req, resp} ->
send(caller, {sse_ref, :sse_done})
{req, put_in(resp.private[:sse_ref], sse_ref)}
end
req = Req.Request.put_private(req, :sse_ref, sse_ref)
req = %{req | into: wrapped}
Req.Request.append_response_steps(req, sse_done: send_done)
end
defp wrap_collectable(%Req.Request{} = req, collectable) do
wrapper = %ReqServerSentEvents.CollectableWrapper{
inner: collectable,
max_size: req.private[:sse_max_frame_size]
}
%{req | into: wrapper}
end
# ---------------------------------------------------------------------------
# Telemetry
# ---------------------------------------------------------------------------
defp emit_start_if_needed(req, resp) do
if resp.private[:sse_started_at] do
resp
else
monotonic = System.monotonic_time()
:telemetry.execute(
[:req_server_sent_events, :stream, :start],
%{system_time: System.system_time(), monotonic_time: monotonic},
%{request: req}
)
put_in(resp.private[:sse_started_at], monotonic)
end
end
defp emit_stop_if_started({req, resp}) do
case resp.private[:sse_started_at] do
nil ->
{req, resp}
started ->
stop = System.monotonic_time()
:telemetry.execute(
[:req_server_sent_events, :stream, :stop],
%{monotonic_time: stop, duration: stop - started},
%{request: req, response: resp}
)
{req, resp}
end
end
end