···11-defmodule Drinkup.Firehose.Account do
11+defmodule Drinkup.Event.Account do
22 @moduledoc """
33 Struct for account events from the ATProto Firehose.
44 """
+1-1
lib/firehose/commit.ex
lib/event/commit.ex
···11-defmodule Drinkup.Firehose.Commit do
11+defmodule Drinkup.Event.Commit do
22 @moduledoc """
33 Struct for commit events from the ATProto Firehose.
44 """
+1-1
lib/firehose/identity.ex
lib/event/identity.ex
···11-defmodule Drinkup.Firehose.Identity do
11+defmodule Drinkup.Event.Identity do
22 @moduledoc """
33 Struct for identity events from the ATProto Firehose.
44 """
+1-1
lib/firehose/info.ex
lib/event/info.ex
···11-defmodule Drinkup.Firehose.Info do
11+defmodule Drinkup.Event.Info do
22 @moduledoc """
33 Struct for info events from the ATProto Firehose.
44 """
+1-1
lib/firehose/sync.ex
lib/event/sync.ex
···11-defmodule Drinkup.Firehose.Sync do
11+defmodule Drinkup.Event.Sync do
22 @moduledoc """
33 Struct for sync events from the ATProto Firehose.
44 """
+165
lib/socket.ex
···11+defmodule Drinkup.Socket do
22+ @moduledoc """
33+ gen_statem process for managing the websocket connection to an ATProto relay.
44+ """
55+66+ require Logger
77+ alias Drinkup.Event
88+99+ @behaviour :gen_statem
1010+ @default_host "https://bsky.network"
1111+ @timeout :timer.seconds(5)
1212+ # TODO: `flow` determines messages in buffer. Determine ideal value?
1313+ @flow 10
1414+1515+ @op_regular 1
1616+ @op_error -1
1717+1818+ defstruct [:host, :seq, :conn, :stream]
1919+2020+ @impl true
2121+ def callback_mode, do: [:state_functions, :state_enter]
2222+2323+ def start_link(opts \\ []) do
2424+ opts = Keyword.validate!(opts, host: @default_host)
2525+ host = Keyword.get(opts, :host)
2626+ cursor = Keyword.get(opts, :cursor)
2727+2828+ :gen_statem.start_link(__MODULE__, {host, cursor}, [])
2929+ end
3030+3131+ @impl true
3232+ def init({host, cursor}) do
3333+ data = %__MODULE__{host: host, seq: cursor}
3434+ {:ok, :disconnected, data, [{:next_event, :internal, :connect}]}
3535+ end
3636+3737+ def disconnected(:enter, _from, data) do
3838+ Logger.debug("Initial connection")
3939+ # TODO: differentiate between initial & reconnects, probably stuff to do with seq
4040+ {:next_state, :disconnected, data}
4141+ end
4242+4343+ def disconnected(:internal, :connect, data) do
4444+ {:next_state, :connecting_http, data}
4545+ end
4646+4747+ def connecting_http(:enter, _from, data) do
4848+ Logger.debug("Connecting to http")
4949+5050+ %{host: host, port: port} = URI.new!(data.host)
5151+5252+ {:ok, conn} =
5353+ :gun.open(:binary.bin_to_list(host), port, %{
5454+ retry: 0,
5555+ protocols: [:http],
5656+ connect_timeout: @timeout,
5757+ domain_lookup_timeout: @timeout,
5858+ tls_handshake_timeout: @timeout,
5959+ tls_opts: [
6060+ verify: :verify_peer,
6161+ cacerts: :certifi.cacerts(),
6262+ depth: 3,
6363+ customize_hostname_check: [
6464+ match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
6565+ ]
6666+ ]
6767+ })
6868+6969+ {:keep_state, %{data | conn: conn}, [{:state_timeout, @timeout, :connect_timeout}]}
7070+ end
7171+7272+ def connecting_http(:info, {:gun_up, _conn, :http}, data) do
7373+ {:next_state, :connecting_ws, data}
7474+ end
7575+7676+ def connecting_http(:state_timeout, :connect_timeout, _data) do
7777+ {:stop, :connect_http_timeout}
7878+ end
7979+8080+ def connecting_ws(:enter, _from, %{conn: conn, seq: seq} = data) do
8181+ Logger.debug("Upgrading connection to websocket")
8282+ path = "/xrpc/com.atproto.sync.subscribeRepos?" <> URI.encode_query(%{cursor: seq})
8383+ stream = :gun.ws_upgrade(conn, path, [], %{flow: @flow})
8484+ {:keep_state, %{data | stream: stream}, [{:state_timeout, @timeout, :upgrade_timeout}]}
8585+ end
8686+8787+ def connecting_ws(:info, {:gun_upgrade, _conn, _stream, ["websocket"], _headers}, data) do
8888+ {:next_state, :connected, data}
8989+ end
9090+9191+ def connecting_ws(:state_timeout, :upgrade_timeout, _data) do
9292+ {:stop, :connect_ws_timeout}
9393+ end
9494+9595+ def connected(:enter, _from, _data) do
9696+ Logger.debug("Connected to websocket")
9797+ :keep_state_and_data
9898+ end
9999+100100+ def connected(:info, {:gun_ws, conn, stream, {:binary, frame}}, data) do
101101+ # TODO: let clients specify a handler for raw* (*decoded) packets to support any atproto subscription
102102+ # Will also need support for JSON frames
103103+ with {:ok, header, next} <- CAR.DagCbor.decode(frame),
104104+ {:ok, payload, _} <- CAR.DagCbor.decode(next),
105105+ {%{"op" => @op_regular, "t" => type}, _} <- {header, payload},
106106+ true <- type == "#info" || Event.valid_seq?(data.seq, payload["seq"]),
107107+ data <- %{data | seq: payload["seq"] || data.seq},
108108+ message <-
109109+ Event.from(type, payload) do
110110+ :ok = :gun.update_flow(conn, stream, @flow)
111111+112112+ case message do
113113+ %Event.Commit{} = commit ->
114114+ IO.inspect(commit.ops, label: commit.repo)
115115+116116+ msg ->
117117+ IO.inspect(msg)
118118+ end
119119+120120+ {:keep_state, data}
121121+ else
122122+ false ->
123123+ Logger.error("Got out of sequence or invalid `seq` from Firehose")
124124+ {:keep_state, data}
125125+126126+ {%{"op" => @op_error, "t" => type}, payload} ->
127127+ Logger.error("Got error from Firehose: #{inspect({type, payload})}")
128128+ {:keep_state, data}
129129+130130+ {:error, reason} ->
131131+ Logger.warning("Failed to decode frame from Firehose: #{inspect(reason)}")
132132+ {:keep_state, data}
133133+ end
134134+ end
135135+136136+ def connected(:info, {:gun_ws, _conn, _stream, :close}, _data) do
137137+ Logger.info("Websocket closed, reason unknown")
138138+ {:keep_state_and_data, [{:next_event, :internal, :reconnect}]}
139139+ end
140140+141141+ def connected(:info, {:gun_ws, _conn, _stream, {:close, errno, reason}}, _data) do
142142+ Logger.info("Websocket closed, errno: #{errno}, reason: #{inspect(reason)}")
143143+ {:keep_state_and_data, [{:next_event, :internal, :reconnect}]}
144144+ end
145145+146146+ def connected(:info, {:gun_down, old_conn, _proto, _reason, _killed_streams}, %{conn: new_conn})
147147+ when old_conn != new_conn do
148148+ Logger.debug("Ignoring received :gun_down for a previous connection.")
149149+ :keep_state_and_data
150150+ end
151151+152152+ def connected(:info, {:gun_down, _conn, _proto, _reason, _killed_streams}, _data) do
153153+ Logger.info("Websocket connection killed. Attempting to reconnect")
154154+ {:keep_state_and_data, [{:next_event, :internal, :reconnect}]}
155155+ end
156156+157157+ def connected(:internal, :reconnect, %{conn: conn} = data) do
158158+ :ok = :gun.close(conn)
159159+ :ok = :gun.flush(conn)
160160+161161+ # TODO: reconnect backoff
162162+ {:next_state, :disconnected, %{data | conn: nil, stream: nil},
163163+ [{:next_event, :internal, :connect}]}
164164+ end
165165+end