NFIBrokerage / slipstream

A slick WebSocket client for Phoenix Channels
https://hex.pm/packages/slipstream
Apache License 2.0
155 stars 18 forks source link

support replying to server pushes #5

Closed the-mikedavis closed 3 years ago

the-mikedavis commented 3 years ago

if a Phoenix.Channel can reply with a reply signature ({:reply, reply, socket}) or Phoenix.Channel.reply/2, surely we the client writers should be able to reply to server pushes?

the-mikedavis commented 3 years ago

While I don't have a quote from the official docs to back me up, I'm relatively sure that clients are not allowed to reply to messages. A quick survey of other client implementations only brings up a reply feature which defdelegate/2s to GenServer.reply/2 (see here and associated source code here)

It seems like this is an limitation on the Phoenix Channel communication protocol caused by the lack of ref in messages from the server. E.g. open up a websocket inspector (network tab in your browser's dev-tools) on http://phoenixchat.herokuapp.com/. I have added message numbers and directions to the packets, where > is a message from client (browser) to server and < vice versa.

1  > {"topic":"rooms:lobby","event":"phx_join","payload":{},"ref":1}
2  < {"topic":"rooms:lobby","event":"phx_reply","payload":{"status":"ok","response":{}},"ref":1}
3  < {"topic":"rooms:lobby","event":"join","payload":{"status":"connected"},"ref":null}
4  < {"topic":"rooms:lobby","event":"user:entered","payload":{"user":null},"ref":null}
5  < {"topic":"rooms:lobby","event":"new:msg","payload":{"user":"SYSTEM","body":"ping"},"ref":null}
6  < {"topic":"rooms:lobby","event":"new:msg","payload":{"user":"SYSTEM","body":"ping"},"ref":null}
7  > {"topic":"rooms:lobby","event":"new:msg","payload":{"user":"asdf","body":"asdf"},"ref":"2"}
8  < {"topic":"rooms:lobby","event":"phx_reply","payload":{"status":"ok","response":{"msg":"asdf"}},"ref":"2"}
9  < {"topic":"rooms:lobby","event":"new:msg","payload":{"user":"SYSTEM","body":"ping"},"ref":null}
10 < {"topic":"rooms:lobby","event":"new:msg","payload":{"user":"SYSTEM","body":"ping"},"ref":null}

Also see the source code.

Messages one and two are the join and successful join-reply messages. Joins requests have refs, of course, because the phoenix server needs to reply to those join requests. Message three is sent from a Phoenix.Channel.push/3, and message four is sent from a Phoenix.Channel.broadcast!/3. Notice how neither have refs (well.. they do, but they're null). This is notable because a message must have a ref to be replied-to. A message with an event of phx_reply must have a ref, and they're not sending us one, so we just can't reply.


But I want a reply from the client! Never fear, you can do a similar system to Slipstream's push+reply feature by simply sending a reference yourself in the higher-level (would be in the payload key of one of those above messages) payload of a push. The code might look like...

defmodule MyServerSideChannel do
  use MyAppWeb, :channel

  def join("foo", _params, socket) do
    {:ok, socket}
  end

  def handle_info(:send_and_receive, socket) do
    ref = "#{make_ref()}"

    push socket, "bar", %{ref: ref}

    {:noreply, assign(socket, :ref, ref)}
  end

  def handle_in("reply", %{"ref" => ref}, %{assigns: %{ref: ref}} = socket) do
    IO.inspect(ref, label: "I got a reply!")

    {:noreply, socket}
  end
end

and keep in mind that this is server-side code (a Phoenix.Channel). Replies to pushes are aleady implemented in the client-side (see t:Slipstream.push_reference/0). But to work with this server-side reply, a Slipstream client that would work could be:

defmodule MyClient do
  use Slipstream

  # .. connect and all that

  @impl Slipstream
  def handle_message("foo", "bar", %{"ref" => ref}, socket) do
    push(socket, "foo", "reply", %{ref: ref, fizz: "buzz"})

    {:noreply, socket}
  end
end

If Phoenix wanted to implement this, they could do it with a few changes:

But I suspect that they do not want to implement it, because of that breaking change, because you can already do this with the above work-around, and because it's not a very pretty workflow (synchronously waiting for things isn't a very Elixir thing to do).


This isn't a real issue/feature-request. Just wanted to jot down some of the notes in my head and write it out publicly so if anyone else has this question, they can follow my logic.