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/2`. 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
Req.get!(url, into: fn {:sse_event, frame}, {req, resp} ->
IO.inspect(frame)
{:cont, {req, resp}}
end)
|> ReqServerSentEvents.attach()
# into: :self — frames sent as messages to the calling process
task = Task.async(fn -> Req.get!(url, into: :self) |> ReqServerSentEvents.attach() 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} = Req.get(url, into: []) |> ReqServerSentEvents.attach()
frames = resp.body # [%ReqServerSentEvents.Frame{}, ...]
"""
@doc """
Attach the SSE decoder to a `%Req.Request{}`.
Registers two Req steps:
- A request step (`sse_rewrite`) that rewrites the `into:` field to
decode SSE frames before delivering them to the user's handler.
- A response step (`sse_done`) that sends a `{ref, :sse_done}` sentinel
when `into: :self` is used (the `into: fun` callback has no `:done` signal).
"""
@spec attach(Req.Request.t(), keyword()) :: Req.Request.t()
def attach(%Req.Request{} = req, _opts \\ []) do
req
|> sse_rewrite()
|> Req.Request.append_response_steps(sse_done: &send_sse_done/1)
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]
# ---------------------------------------------------------------------------
# Request step — rewrite req.into before the HTTP adapter runs
# ---------------------------------------------------------------------------
defp sse_rewrite(%Req.Request{into: nil} = req), do: req
defp sse_rewrite(%Req.Request{into: :self} = req), do: wrap_self(req)
defp sse_rewrite(%Req.Request{into: f} = req) when is_function(f, 2), do: wrap_fun(req, f)
defp sse_rewrite(%Req.Request{into: c} = req), do: wrap_collectable(req, c)
# ---------------------------------------------------------------------------
# Response step — send :sse_done sentinel for the :self path
# ---------------------------------------------------------------------------
defp send_sse_done({req, resp}) do
with caller when not is_nil(caller) <- req.private[:sse_caller],
sse_ref when not is_nil(sse_ref) <- req.private[:sse_ref] do
send(caller, {sse_ref, :sse_done})
end
{req, resp}
end
# ---------------------------------------------------------------------------
# into: fun — buffer lives in resp.private[:sse_buf]
# ---------------------------------------------------------------------------
defp wrap_fun(%Req.Request{} = req, user_fun) do
wrapped = fn {:data, chunk}, {req, resp} ->
buf = (resp.private[:sse_buf] || "") <> chunk
{frames, leftover} = ReqServerSentEvents.Frame.split(buf)
resp = put_in(resp.private[:sse_buf], leftover)
Enum.reduce_while(frames, {:cont, {req, resp}}, fn raw, {:cont, {req, resp}} ->
frame = ReqServerSentEvents.Frame.parse(raw)
case user_fun.({:sse_event, frame}, {req, resp}) do
{:cont, acc} -> {:cont, {:cont, acc}}
{:halt, acc} -> {:halt, {:halt, acc}}
end
end)
end
%{req | into: wrapped}
end
# ---------------------------------------------------------------------------
# into: :self — rewrite to into: fun that sends messages
# ---------------------------------------------------------------------------
defp wrap_self(%Req.Request{} = req) do
caller = self()
sse_ref = make_ref()
wrapped = fn {:data, chunk}, {req, resp} ->
buf = (resp.private[:sse_buf] || "") <> chunk
{frames, leftover} = ReqServerSentEvents.Frame.split(buf)
resp =
resp
|> put_in([Access.key(:private), :sse_buf], leftover)
|> put_in([Access.key(:private), :sse_ref], sse_ref)
Enum.each(frames, fn raw ->
send(caller, {sse_ref, {:sse_event, ReqServerSentEvents.Frame.parse(raw)}})
end)
{:cont, {req, resp}}
end
req
|> Req.Request.put_private(:sse_ref, sse_ref)
|> Req.Request.put_private(:sse_caller, caller)
|> Map.replace!(:into, wrapped)
end
# ---------------------------------------------------------------------------
# into: collectable — wrap in CollectableWrapper
# ---------------------------------------------------------------------------
defp wrap_collectable(%Req.Request{} = req, collectable) do
%{req | into: %ReqServerSentEvents.CollectableWrapper{inner: collectable}}
end
end