Phoenix.Socket.Transport - custom websocket transports and the interop between phoenix and cowboy

I had a question for Phoenix maintainers about custom websocket transports, and the interop between phoenix and cowboy. It’s possible to implement a websocket extension via Phoenix.Socket.Transport , but certain aspects are undocumented, possibly because of expectations in the cowboy_websocket.erl implementation.

The problem is (hopefully) clearly demonstrated in this codebase: GitHub - geometerio/absinthe_graphql_ws: Add graphql-ws websocket transport for Absinthe

Some simple things came out of my iteration on this library. Firstly, I needed to implement server-side keepalive via a ping on an interval. The Phoenix.Socket.Transport only declares the :text or :binary opcodes in its documentation, but I found that if I returned other opcodes like :ping or :pong , they’re passed through as-is through cowboy, and the keepalive works. I was wondering if this might be just an oversight on the typespecs, or if this was intentionally left out.

The other big thing was custom websocket errors. Websockets reserve 4000-4999 close codes for applications, and the graphql-ws protocol uses custom close codes in this range to specify errors in usage.

I traced through cowboy_websocket.erl to understand what happens when a custom phoenix socket transport returns {:stop, reason, socket} from a callback, and discovered that there does not seem to be a public API to return a custom close code with text.

More specifically, it looks like I should be able to return {:stop, {:remote, 4400, "some content"}, socket} , but cowboy does not respect the binary content: cowboy/cowboy_websocket.erl at db0d6f8d254f2cc01bd458dc41969e0b96991cc3 · ninenines/cowboy · GitHub.

websocket_send_close(State, Reason) ->
	_ = case Reason of
                # ... list of

		{remote, Code, _} ->
			transport_send(State, fin, frame({close, Code, <<>>}, State))
	end,
	ok.

I could be wrong about whether Phoenix would pass this through to cowboy, but if it doesn’t I’m sure it could be changed to do so.

In our code, we sidestep this by implementing a custom close mechanism: absinthe_graphql_ws/transport.ex at 7d622da914fd2eefdc14466c6e0d733184696a3b · geometerio/absinthe_graphql_ws · GitHub

  defp close(code, message, socket) do
    queue_exit()
    {:reply, :ok, {:close, code, message}, socket}
  end

It seems to me that with minimal changes to cowboy, this could all be handled through callback responses, which could then be documented in Phoenix.Socket.Transport .Specifically, cowboy should respect any binary content that is passed to it with a custom close code. Phoenix might just “do the right thing” as-is, and it could just be clearly documented and added to the typespecs. That :remote tuple on :stop is not the most understandable thing to add to the API of Phoenix.Socket.Transport, so if Phoenix/cowboy accept changes I think that it might make sense to provide a type that declares clear intent, and then document it clearly.

I’m happy to contribute pull requests, but thought that this would be the place to start.

2 Likes

Tagging @benwilson512 @chrismccord if you have time to think about this and weigh in, I’d appreciate it.

Turns out I misread the callbacks in cowboy_websocket. The code I referenced above is executed based on events received from clients. Callbacks from the server handle {:close, code, message} properly.

I’ve issued the following pull request to update Phoenix.Socket.Transport documentation:

2 Likes