Not really, it just required some knowledge how to write “custom” Plug handler to handle sockets. However in the end that was not that hard and in total is less than 300 lines while most of them are copied from the Phoenix Cowboy handler:
defmodule MyAppWeb.Endpoint.Handler do
@moduledoc false
if Code.ensure_loaded?(:cowboy_websocket) and
function_exported?(:cowboy_websocket, :behaviour_info, 1) do
@behaviour :cowboy_websocket
end
@connection Plug.Cowboy.Conn
@already_sent {:plug_conn, :sent}
# Note we keep the websocket state as [handler | state]
# to avoid conflicts with {endpoint, opts}.
def init(req, {endpoint, prefix, opts}) do
conn = @connection.conn(req)
try do
path_info = strip_prefix(prefix, conn.path_info)
conn = %{conn | path_info: path_info}
case endpoint.__handler__(conn, opts) do
{:websocket, conn, handler, opts} ->
case Phoenix.Transports.WebSocket.connect(
conn,
endpoint,
handler,
opts
) do
{:ok, %{adapter: {@connection, req}}, state} ->
cowboy_opts =
opts
|> Enum.flat_map(fn
{:timeout, timeout} -> [idle_timeout: timeout]
{:compress, _} = opt -> [opt]
{:max_frame_size, _} = opt -> [opt]
_other -> []
end)
|> Map.new()
{:cowboy_websocket, req, [handler | state], cowboy_opts}
{:error, %{adapter: {@connection, req}}} ->
{:ok, req, {handler, opts}}
end
{:plug, conn, handler, opts} ->
%{adapter: {@connection, req}} =
case MyAppWeb.Endpoint.call(conn, opts) do
%Plug.Conn{halted: true} = conn ->
conn
conn ->
conn
|> handler.call(opts)
|> maybe_send(handler)
end
{:ok, req, {handler, prefix, opts}}
end
catch
:error, value ->
stack = System.stacktrace()
exception = Exception.normalize(:error, value, stack)
exit({{exception, stack}, {endpoint, :call, [conn, opts]}})
:throw, value ->
stack = System.stacktrace()
exit({{{:nocatch, value}, stack}, {endpoint, :call, [conn, opts]}})
:exit, value ->
exit({value, {endpoint, :call, [conn, opts]}})
after
receive do
@already_sent -> :ok
after
0 -> :ok
end
end
end
defp strip_prefix([x | xs], [x | ys]), do: strip_prefix(xs, ys)
defp strip_prefix([], ys), do: ys
defp maybe_send(%Plug.Conn{state: :unset}, _plug),
do: raise(Plug.Conn.NotSentError)
defp maybe_send(%Plug.Conn{state: :set} = conn, _plug),
do: Plug.Conn.send_resp(conn)
defp maybe_send(%Plug.Conn{} = conn, _plug), do: conn
defp maybe_send(other, plug) do
raise "Cowboy2 adapter expected #{inspect(plug)} to return Plug.Conn but got: " <>
inspect(other)
end
## Websocket callbacks
def websocket_init([handler | state]) do
{:ok, state} = handler.init(state)
{:ok, [handler | state]}
end
def websocket_handle({opcode, payload}, [handler | state])
when opcode in [:text, :binary] do
handle_reply(handler, handler.handle_in({payload, opcode: opcode}, state))
end
def websocket_handle(_other, handler_state) do
{:ok, handler_state}
end
def websocket_info(message, [handler | state]) do
handle_reply(handler, handler.handle_info(message, state))
end
def terminate(_reason, _req, {_handler, _prefix, _state}) do
:ok
end
def terminate({:error, :closed}, _req, [handler | state]) do
handler.terminate(:closed, state)
end
def terminate({:remote, :closed}, _req, [handler | state]) do
handler.terminate(:closed, state)
end
def terminate({:remote, code, _}, _req, [handler | state])
when code in 1000..1003 or code in 1005..1011 or code == 1015 do
handler.terminate(:closed, state)
end
def terminate(:remote, _req, [handler | state]) do
handler.terminate(:closed, state)
end
def terminate(reason, _req, [handler | state]) do
handler.terminate(reason, state)
end
defp handle_reply(handler, {:ok, state}), do: {:ok, [handler | state]}
defp handle_reply(handler, {:push, data, state}),
do: {:reply, data, [handler | state]}
defp handle_reply(handler, {:reply, _status, data, state}),
do: {:reply, data, [handler | state]}
defp handle_reply(handler, {:stop, _reason, state}),
do: {:stop, [handler | state]}
end
And then “root” endpoint:
defmodule MyAppWeb.Endpoint do
use Plug.Builder
require Logger
@endpoints [
MyAppRest.Endpoint,
MyAppUi.Endpoint
]
def child_spec({scheme, options}) do
dispatches =
@endpoints
|> gen_dispatches()
|> check_dispatches()
options =
options
|> Keyword.put(:cipher_suite, :strong)
|> Keyword.put_new(:dispatch, _: dispatches)
|> Keyword.put_new(:keyfile, System.get_env("SSL_KEY"))
|> Keyword.put_new(:certfile, System.get_env("SSL_CERT"))
|> Keyword.put_new_lazy(:port, fn -> port(scheme) end)
spec =
Plug.Cowboy.child_spec(
scheme: scheme,
plug: __MODULE__,
options: options
)
update_in(spec, [:start], &{__MODULE__, :start_link, [scheme, &1]})
end
def start_link(scheme, {m, f, [ref | _] = a}) do
case apply(m, f, a) do
{:ok, pid} ->
:logger.info(&info/1, {scheme, __MODULE__, ref})
{:ok, pid}
{:error, {:shutdown, {_, _, {{_, {:error, :eaddrinuse}}, _}}}} = error ->
Logger.error(
info({scheme, __MODULE__, ref}) <> " failed, port already in use"
)
error
{:error, _} = error ->
error
end
end
defp gen_dispatches(endpoints) do
for endpoint <- endpoints do
url = endpoint.config(:url, [path: "/"]) |> Keyword.fetch!(:path)
prefix =
case Path.split(url) do
["/" | rest] -> rest
rest -> rest
end
path =
case url do
"/" -> :_
other -> Path.join(["/", other, "[...]"])
end
{path, MyAppWeb.Endpoint.Handler, {endpoint, prefix, endpoint.init([])}}
end
end
defp check_dispatches(dispatches) do
entries =
dispatches
|> Enum.map(&elem(&1, 0))
|> Enum.sort()
:ok = find_duplicate(entries)
dispatches
end
defp find_duplicate([a, a | _]), do: raise "Duplicated prefix #{inspect(a)}"
defp find_duplicate([_ | rest]), do: find_duplicate(rest)
defp find_duplicate([]), do: :ok
defp info({scheme, endpoint, ref}) do
server = "cowboy #{Application.spec(:cowboy, :vsn)}"
"Running #{inspect(endpoint)} with #{server} at #{uri(scheme, ref)}"
end
defp uri(scheme, ref) do
{host, port} = :ranch.get_addr(ref)
%URI{
scheme: to_string(scheme),
host: List.to_string(:inet.ntoa(host)),
port: port
}
end
defp port(scheme), do: String.to_integer(System.get_env("PORT_#{scheme}"))
plug(
MyAppWeb.Plugs.Health,
applications: [:my_app, :my_app_web, :my_app_ui, :my_app_rest]
)
plug(Plug.Telemetry.ServerTiming)
plug(MyAppWeb.Plugs.Trace)
plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
# In memory of:
# - Terry Pratchett (default)
# - Joe Armstrong
plug(MyAppWeb.Plugs.Clacks, names: ["Joe Armstrong"])
plug(MyAppWeb.Plugs.Measure)
end
Which also uses some custom plugs before it even reaches “main” handlers:
- heath checks that responds with JSON containing application versions
- custom tracing for OpenCensus (soon to be done via Telemetry and OpenTelemetry)
Server-Timing
- Clacks subsystem
- OpenCensus metrics (which I need to replace with Telemetry handlers)