Current section

Files

Jump to
urbit_ex lib types channel.ex
Raw

lib/types/channel.ex

defmodule UrbitEx.Channel do
@moduledoc """
GenServer module to open Eyre channels.
Defines an UrbitEx.Channel struct to keep track of the channel state.
Elixir processes can subscribe to channels to consume the events their propagate, either raw Eyre events or Eyre events parsed by UrbitEx.Reducer
"""
alias UrbitEx.{Session, API}
@derive Jason.Encoder
use GenServer
defstruct name: :main,
path: "/~/channel/1624121301252-9e6659",
parent: :pid,
last_action: 0,
last_sse: 0,
last_ack: 0,
recent_events: [],
subscriptions: [],
truncated_event: "",
consumers: [],
raw_consumers: [],
status: :init,
autoack: false,
last_event: Time.utc_now(),
keep_state: true
@doc """
Fetches Channel state. Takes a pid or atom name of the channel to fetch.
Returns a Channel struct, to be used in UrbitEx functions involving PUT requests.
"""
def read(pid \\ :main), do: GenServer.call(pid, :get)
@doc """
Subscribes to a channel in order to receive the SSE events (after parsing by the UrbitEx default reducer) it publishes.
Takes a pid or atom name of the channel to subscribe to, and the pid of the subscriber process.
"""
def consume_feed(channel_pid, consoomer_pid), do:
GenServer.cast(channel_pid, {:consume, consoomer_pid})
@doc """
Deletes the subscription to a channel. No events will be received after calling this function.
Takes a pid or atom name of the channel to subscribe from, and the pid of the subscriber process.
"""
def wean(channel_pid, consoomer_pid), do:
GenServer.cast(channel_pid, {:wean, consoomer_pid})
@doc """
Subscribes to a raw Eyre SSE pipeline in order to receive the raw SSE events it publishes.
Takes a pid or atom name of the channel to subscribe to, and the pid of the subscriber process.
"""
def consume_raw(channel_pid, consoomer_pid), do:
GenServer.cast(channel_pid, {:consume_raw, consoomer_pid})
@doc """
Deletes the subscription to raw events.
Takes a pid or atom name of the channel to unsubscribe from, and the pid of the subscriber process.
"""
def wean_raw(channel_pid, consoomer_pid), do:
GenServer.cast(channel_pid, {:wean_raw, consoomer_pid})
# client
def start_link(options \\ [], name \\ :main) when is_list(options) do
GenServer.start_link(__MODULE__, new(options), name: name)
end
def start(options \\ [], name \\ :main) when is_list(options) do
GenServer.start(__MODULE__, new(options), name: name)
end
def connect(pid \\ :main), do: GenServer.call(pid, :connect)
defp new(opts \\ []) do
path =
"/~/channel/#{System.os_time(:millisecond)}-#{:crypto.strong_rand_bytes(3) |> Base.encode16(case: :lower)}"
struct(__MODULE__, Keyword.put(opts, :path, path))
end
@spec add_event(atom | pid | {atom, any} | {:via, atom, any}, any) :: :ok
def add_event(pid \\ :main, event) do
GenServer.cast(pid, {:save_event, event})
end
def save_action(pid \\ :main, id) do
GenServer.cast(pid, {:save_state, :last_action, id})
end
def save_ack(pid \\ :main, id) do
GenServer.cast(pid, {:save_state, :last_ack, id})
end
# todo save the id!!
def save_subscription(pid \\ :main, subscription) do
GenServer.cast(pid, {:subscribe, subscription})
end
def check_stream() do
Process.send_after(self(), :timer, 30_000)
end
def check_ack() do
:timer.send_interval(1_000, :ack)
end
## server
@impl true
def init(channel) do
check_stream()
check_ack()
{:ok, channel}
end
@impl true
def handle_call(:get, _from, channel), do: {:reply, channel, channel}
@impl true
def handle_call(:connect, _from, channel) do
session = Session.read(channel.parent)
new_channel =
with :ok <- API.open_channel(session, channel),
{:ok, _ok } <- API.start_sse(session, channel) do
send(channel.parent, {:channel_added, channel.name, self()})
%{channel | status: :open}
else
{:error, _} -> %{channel | status: :error}
end
{:reply, :ok, new_channel}
end
## handle SSE
@impl true
def handle_info(%HTTPoison.AsyncStatus{}, channel) do
{:noreply, channel}
end
@impl true
# ignore keep-alive messages
def handle_info(%{chunk: "\n"}, channel) do
{:noreply, %{channel | last_event: Time.utc_now()}}
end
@impl true
def handle_info(%{chunk: ":\n"}, channel) do
{:noreply, %{channel | last_event: Time.utc_now()}}
end
@impl true
def handle_info(%{chunk: ""}, channel) do
{:noreply, channel}
end
@impl true
def handle_info(%{chunk: data}, channel) do
parse_stream(data)
{:noreply, channel}
end
@impl true
def handle_info({:handle_valid_message, id, event}, channel) do
if channel.autoack, do: API.ack(UrbitEx.get(), channel, id)
broadcast(channel.raw_consumers, event)
if channel.keep_state, do: UrbitEx.Reducer.default_reducer(event)
{:noreply, %{channel | last_sse: id, last_event: Time.utc_now()}}
end
@impl true
def handle_info({:handle_truncated_message, id, message}, channel) do
new_channel = %{channel | last_sse: id, truncated_event: message}
{:noreply, new_channel}
end
@impl true
def handle_info({:stack_truncated_message, message}, channel) do
tm = channel.truncated_event <> message
case Jason.decode(tm) do
{:ok, json} ->
send(self(), {:handle_valid_message, channel.last_sse, json})
{:noreply, %{channel | truncated_event: ""}}
{:error, _r} ->
{:noreply, %{channel | truncated_event: tm}}
end
end
### private functions used by handle infos
defp parse_stream(event) do
rr = ~r(id:\s\d+\ndata:\s.+?}\n\n)
messages = String.split(event, rr, include_captures: true, trim: true)
for msg <- messages do
check_truncated(msg)
end
end
defp check_truncated(msg) do
r = ~r(^id:\s\d+\ndata:\s)
case String.split(msg, r, include_captures: true, trim: true) do
[event_id, data] ->
[id] = Regex.run(~r(\d+), event_id)
handle_seemingly_valid(id, data)
_ ->
handle_truncated(msg)
end
end
defp handle_seemingly_valid(id_string, data) do
id = String.to_integer(id_string)
case Jason.decode(data) do
{:ok, json} -> send(self(), {:handle_valid_message, id, json})
{:error, _r} -> handle_truncated(id, data)
end
end
# it has an id if it's the first piece of a long message
defp handle_truncated(id, string) do
send(self(), {:handle_truncated_message, id, string})
end
# when it's continuing the former
defp handle_truncated(string) do
send(self(), {:stack_truncated_message, string})
end
## handle parsed events
@impl true
def handle_info({:save, tuple}, channel) do
broadcast(channel.consumers, {:data_set, tuple})
send(channel.parent, {:save, tuple})
{:noreply, channel}
end
@impl true
def handle_info({:update, tuple}, channel) do
broadcast(channel.consumers, {:data_updated, tuple})
send(channel.parent, {:update, tuple})
{:noreply, channel}
end
@impl true
def handle_info({:add, tuple}, channel) do
broadcast(channel.consumers, {:data_added, tuple})
send(channel.parent, {:add, tuple})
{:noreply, channel}
end
@impl true
def handle_info({:add_or_update, tuple}, channel) do
broadcast(channel.consumers, {:data_added_or_updated, tuple})
send(channel.parent, {:add_or_update, tuple})
{:noreply, channel}
end
@impl true
def handle_info({:remove, tuple}, channel) do
broadcast(channel.consumers, {:data_removed, tuple})
send(channel.parent, {:remove, tuple})
{:noreply, channel}
end
@impl true
def handle_info({:send, event}, channel) do
broadcast(channel.consumers, event)
{:noreply, channel}
end
@impl true
def handle_info(:timer, channel) do
baseline = Time.utc_now()
diff = (Time.diff(baseline, channel.last_event))
if diff > 30 do
API.restart_sse(UrbitEx.get(channel.parent), channel)
end
check_stream()
{:noreply, channel}
end
def handle_info(:ack, channel) do
if channel.last_sse > channel.last_ack do
API.ack(UrbitEx.get(channel.parent), channel, channel.last_sse)
{:noreply, %{channel | last_ack: channel.last_sse}}
else
{:noreply, channel}
end
end
@impl true
def handle_info(_message, channel) do
{:noreply, channel}
end
## handle casts
@impl true
def handle_cast({:subscribe, subscription}, channel) do
channel = %{channel | subscriptions: [subscription | channel.subscriptions]}
{:noreply, channel}
end
@impl true
def handle_cast({:consume, pid}, channel) do
IO.inspect(pid, label: :consooming)
{:noreply, %{channel | consumers: [pid | channel.consumers]}}
end
@impl true
def handle_cast({:wean, pid}, channel) do
IO.inspect(pid, label: :stopped_consooming)
{:noreply, %{channel | consumers: List.delete(channel.consumers, pid)}}
end
@impl true
def handle_cast({:consume_raw, pid}, channel) do
IO.inspect(pid, label: :consooming_raw)
{:noreply, %{channel | raw_consumers: [pid | channel.raw_consumers]}}
end
@impl true
def handle_cast({:wean_raw, pid}, channel) do
IO.inspect(pid, label: :stopped_consooming_raw)
{:noreply, %{channel | raw_consumers: List.delete(channel.raw_consumers, pid)}}
end
@impl true
def handle_cast({:save_state, key, data}, channel) do
{:noreply, Map.put(channel, key, data)}
end
defp broadcast(consumers, message) do
consumers |> Enum.each(&send(&1, message))
end
end