Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Websockets #17

Closed
wants to merge 2 commits into from

7 participants

@jeregrine
Collaborator

Example

websocket "/my_ws_route", WsHandler

TODO

Raw Websockets
  • Websocket Macro
  • Websocket Macro Example
  • Websocket Macro Test
  • Websocket Basic Tests
  • Update Docs
  • Integration Tests
@jeregrine
Collaborator

What I want to do next is to define a "WebsocketController" to abstract away the cowboy parts, similar to use GenServer.Behaviour that defines the basic callbacks for the handler and let the user override them.

I think I am going to start implementing SockJS and rely on something like https://github.com/sockjs/sockjs-erlang/tree/master/examples/multiplex to do the multiplexing. Should be super easy to setup in this PR.

I also need to add better documentation. I can also remove the static stuff, not required.

lib/phoenix/controller/channel.ex
((7 lines not shown))
+ Below is an example of an EcoServer websocket.
+ defmodule Router do
+ use Phoenix.Router
+ channel "echo", Channel
+ end
+
+ defmodule Websocket do
+ use Phoenix.Controller.Channel
+ def recieve(conn, data, state) do
+ conn.send(data)
+ end
+ end
+
+ There are 4 callbacks you can override to handle the common functions of a websocket.
+ init(conn, state)
+ recieve(conn, data, state)

typo in recieve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/phoenix/controller/channel.ex
@@ -0,0 +1,111 @@
+defmodule Phoenix.Controller.Channel do
+ @moduledoc """
+ This module is a convenience for setting up a basic sockjs websocket.
+
+ ## Example
+
+ Below is an example of an EcoServer websocket.
@knewter
knewter added a note

Typo in Eco (that's what we're doing right?) :smile:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/phoenix/controller/channel.ex
((59 lines not shown))
+ """
+ def info(conn, info, state) do
+ {:ok, state}
+ end
+
+ @doc false
+ def __service(conn, {:info, info}, state) do
+ info(conn, info, state)
+ end
+
+ @doc """
+ Callback to handle broadcasts from the other clients on this channel
+ Default implementation simply passes the data to the client.
+ """
+ def broadcast(conn, data, state) do
+ conn.send(data)
@knewter
knewter added a note

strange indentation in some of these methods fwiw...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/phoenix/controller/websocket.ex
@@ -0,0 +1,83 @@
+defmodule Phoenix.Controller.Websocket do
+ @moduledoc """
+ This module is a convenience for setting up a basic sockjs websocket.
+
+ ## Example
+
+ Below is an example of an EcoServer websocket.
@knewter
knewter added a note

Echo...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeregrine
Collaborator

Sorry for flaking out on this. We've been moving/painting both places and I've been exhausted as a result. I am going to split this into a websocket portion and a channel portion worry about channels later, as they add signifigant complexity.

Also might make sense to move much of this into an adapter specific plug? And then the channel implementation could just use it?

@chrismccord
Owner

No worries. I don't think we can leverage a Plug middleware in this case since it's not conn dependent, and we'll need to passing options directly to the Plug.Adapters.Cowboy.http adapter. I think something along the lines of your initial approach is the direction we'll need to go.

@jeregrine
Collaborator
lib/phoenix/controller/websocket.ex
((17 lines not shown))
+ end
+
+ There are 4 callbacks you can override to handle the common functions of a websocket.
+ start(conn, state)
+ receive(data, conn, state)
+ info(data, conn, state)
+ closed(data, conn, state)
+
+ Each function is an alias for the websocket handler functions, for more information on connections and
+ what you should return from these functions check out the websocket documentation.
+
+
+
+ """
+ defmacro __using__(state \\ []) do
+ quote do
@chrismccord Owner

We can import most of these funcs. The goal is to do as little code generation as possible.

@jeregrine Collaborator

I was basing it how jose did gen_server behavior https://github.com/elixir-lang/elixir/blob/master/lib/elixir/lib/gen_server/behaviour.ex

init, websocket_* need to be in the module definition or else cowboy doesn't see them. And the others are overrideable.

@chrismccord Owner

Ah, yes you're completely right. Carry on :+1:

@jeregrine Collaborator

Thanks for the feedback though! I'm fairly new to macros :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeregrine
Collaborator

Here is an example for everyone interested https://gist.github.com/jeregrine/af7ecb4c7d87767411a0

lib/phoenix/controller/websocket.ex
((6 lines not shown))
+
+ Below is an example of an EchoServer websocket.
+ defmodule Router do
+ use Phoenix.Router
+ websocket "echo", Websocket
+ end
+ defmodule Websocket do
+ use Phoenix.Controller.Websocket
+ def receive(conn, data, state) do
+ conn.send(data)
+ end
+ end
+
+ There are 4 callbacks you can override to handle the common functions of a websocket.
+ start(conn, state)
+ receive(data, conn, state)
@jeregrine Collaborator

I should probably rename this to something else as to not conflict with the "receive" keyword.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeregrine
Collaborator

I updated the docs heavily and changed receive => stream. Last step is to test everything.

My Approach is:

And from there I will need direction. I think I could test that the default implementations return what I say they do? I'm not completely sure HOW to integration test this without an external websocket client lib.

@jeregrine
Collaborator

Also thinking it might be nice to have functions like

MySocketController.send(process, "data") 

which basically wraps a send to handle info.

@knewter
lib/phoenix/router/router.ex
@@ -26,8 +28,12 @@ defmodule Phoenix.Router do
end
def start do
- IO.puts ">> Running #{__MODULE__} with Cowboy with #{inspect @options}"
- Plug.Adapters.Cowboy.http __MODULE__, [], @options
+ if dispatch = Phoenix.Router.setup_cowboy_dispatch(__MODULE__, @options, @dispatch_options) do
@chrismccord Owner

We need to abstract dispatch_options handling and move it out of the Router. Perhaps Phoenix.Adapters.Cowboy.merge_dispatch_options/3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/phoenix/router/router.ex
((5 lines not shown))
+ defmacro websocket(path, handler, options \\ []) do
@chrismccord Owner

Let's move this to Phoenix.Router.WebsocketMapper

The Router's __using__ block can just use Phoenix.Router.WebsocketMapper similar to the http mapper.

@jeregrine Collaborator
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jeregrine
Collaborator

@knewter could maybe co-opt something like this https://github.com/meh/elixir-socket

@jeregrine
Collaborator

@chrismccord I made those changes. next step is refactoring per that email. Should be a simple step.

@jeregrine
Collaborator

@chrismccord convenience functions added

@jeregrine
Collaborator

I refactored the helper functions also updated the original gist for those following along. https://gist.github.com/jeregrine/af7ecb4c7d87767411a0

@jeregrine
Collaborator

@chrismccord I did a straight brain dump on the protocol and the pros/cons of bullet vs websocket.js.

Making the ping/pong is easy enough and it only serves as to give the user and browser a sense of "hey pongs/frames are not coming back or coming back slowly maybe the server is disconnected?"
Example: I watch slack's ping/pings and they will send almost 4 pings before the "onClose" method is called on the websocket. Then slack implements a retry connection with a backoff with each new failure and allows users to click reconnect.

So I am leaning towards it websocket-js.

@jeregrine
Collaborator

@HashNuke had a good point and maybe we should build it around a system like Faye http://faye.jcoglan.com/

@jeregrine
Collaborator

Thats probably an entirely different project and a TON of work though. But we will probably be reinventing the wheel if we don't use something like it. :(

@patrickdet

have you guys decided how to proceed?

@chrismccord
Owner

I'm going to start prototyping out a websocket/channel implementation. We've discussed implementing parts of the faye protocol, but how far we head in that direction will largely depend on initial results as we're speccing things out.

@chrismccord
Owner

We should have a "raw" websocket handler in place soon for those just wanting the bare bones handlers, but our focus will be on a higher level system for managing connections, join/leave, and channel support.

@jeregrine
Collaborator
@tecnobrat

Dropping websocket support?

@HashNuke

@tecnobrat I think it's more like... coming soon (like in a few hours or days)

@jeregrine
Collaborator
@chrismccord
Owner

@tecnobrat No way! We're landing the websocket/channel layer in master today! Check out the recent discussions/feature branch
#70

@HashNuke

@tecnobrat See I told you :D
@chrismccord and @jeregrine Congrats ~! awesome work :+1:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
View
22 lib/phoenix/adapters/cowboy.ex
@@ -0,0 +1,22 @@
+defmodule Phoenix.Adapters.Cowboy do
+ def setup_options(module, options, dispatch_options) do
+ dispatch = Enum.concat [dispatch_options,
+ Dict.get(options, :dispatch, []),
+ [{:_, Plug.Adapters.Cowboy.Handler, { module, [] }}]]
+
+ Dict.put(options, :dispatch, [{:_, dispatch}])
+ end
+
+ defmacro __using__(_options) do
+ quote do
+ Module.register_attribute __MODULE__, :dispatch_options, accumulate: true,
+ persist: false
+ import unquote(__MODULE__)
+ end
+ end
+ defmacro cowboy_dispatch(path, handler, options \\ []) do
+ quote do
+ @dispatch_options {unquote(path), unquote(handler), unquote(options)}
+ end
+ end
+end
View
7 lib/phoenix/examples/controller.ex
@@ -57,3 +57,10 @@ defmodule Phoenix.Examples.Controllers.Files do
end
end
+defmodule Phoenix.Examples.Controllers.Echo do
+ use Phoenix.Websocket.RawHandler
+
+ def stream(data, socket, _state) do
+ reply socket, data
+ end
+end
View
2  lib/phoenix/examples/router.ex
@@ -11,5 +11,7 @@ defmodule Router do
resources "users", Users do
resources "comments", Comments
end
+
+ raw_websocket "/echo", Eco
end
end
View
25 lib/phoenix/router/router.ex
@@ -7,9 +7,12 @@ defmodule Phoenix.Router do
defmacro __using__(plug_adapter_options \\ []) do
quote do
use Phoenix.Router.Mapper
+ use Phoenix.Adapters.Cowboy
+ import Phoenix.Router.RawWebsocketMapper
+
+ import unquote(__MODULE__)
@before_compile unquote(__MODULE__)
use Plug.Builder
- import unquote(__MODULE__)
@options unquote(plug_adapter_options)
end
@@ -26,8 +29,10 @@ defmodule Phoenix.Router do
end
def start do
- IO.puts ">> Running #{__MODULE__} with Cowboy with #{inspect @options}"
- Plug.Adapters.Cowboy.http __MODULE__, [], @options
+ options = Phoenix.Adapters.Cowboy.setup_options(__MODULE__, @options, @dispatch_options)
+
+ IO.puts "Running #{__MODULE__} with Cowboy on port #{inspect options}"
+ Plug.Adapters.Cowboy.http __MODULE__, [], options
end
end
end
@@ -38,7 +43,6 @@ defmodule Phoenix.Router do
http_method = conn.method |> String.downcase |> binary_to_atom
split_path = Path.split_from_conn(conn)
-
request = Dispatcher.Request.new(conn: conn,
router: router,
http_method: http_method,
@@ -50,6 +54,17 @@ defmodule Phoenix.Router do
{:error, reason} -> Controller.error(conn, reason)
end
end
-end
+ def dispatch(conn, router, http_method, path) do
+ request = Dispatcher.Request.new(conn: conn,
+ router: router,
+ http_method: http_method,
+ path: path)
+ {:ok, pid} = Dispatcher.Client.start(request)
+ case Dispatcher.Client.dispatch(pid) do
+ {:ok, conn} -> {:ok, conn}
+ {:error, reason} -> Controller.error(conn, reason)
+ end
+ end
+end
View
7 lib/phoenix/router/websocket_mapper.ex
@@ -0,0 +1,7 @@
+defmodule Phoenix.Router.RawWebsocketMapper do
+ defmacro raw_websocket(path, handler, options \\ []) do
+ quote do
+ cowboy_dispatch unquote(path), unquote(handler), unquote(options)
+ end
+ end
+end
View
166 lib/phoenix/websocket/raw_websocket.ex
@@ -0,0 +1,166 @@
+defmodule Phoenix.Websocket.RawHandler do
+ @moduledoc """
+ This module is a convenience for setting up a basic cowboy websocket.
+
+ ## Example
+
+ Below is an example of an EchoServer websocket.
+ defmodule Router do
+ use Phoenix.Router
+ raw_websocket "echo", Websocket
+ end
+
+ defmodule Websocket do
+ use Phoenix.Websocket.RawHandler
+ def receive(conn, data, state) do
+ conn.send(data)
+ end
+ end
+
+ There are 4 callbacks you can override to handle the common functions of a websocket.
+ start(transport, req, opts)
+ stream(data, conn, state)
+ info(data, conn, state)
+ closed(data, conn, state)
+
+ Each function is an alias for the websocket handler functions, for more detailed information on
+ connections and what you should/could return from these functions check out the cowboy websocket documentation.
+ http://ninenines.eu/docs/en/cowboy/HEAD/manual/cowboy_websocket_handler/
+
+ Things to keep in mind:
+ * Connections will die quite often for any number of reasons. Keep your session and state data in another process.
+ * This is a raw websocket and it may make sense to look into more robust solutions such as [bullet](https://github.com/extend/bullet)
+
+ """
+ defmacro __using__(opts \\ []) do
+
+ transport = Dict.get(opts, :transport, :tcp)
+ unless transport in [:tcp, :ssl] do
+ raise "Websocket transport needs to be :tcp or :ssl. Please refer to websocket documentation."
+ end
+
+ quote location: :keep do
+ @behaviour :cowboy_websocket_handler
+
+ defrecord Socket, conn: nil, pid: nil
+
+ import unquote(__MODULE__)
+
+ @doc false
+ def init({unquote(transport), :http}, req, opts) do
+ {:upgrade, :protocol, :cowboy_websocket, req, opts}
+ end
+
+ @doc false
+ def websocket_init(transport, req, opts) do
+ case start(transport, Socket.new(conn: req, pid: self()), opts) do
+ {:ok, state} -> {:ok, req, state}
+ {:ok, state, timeout} -> {:ok, req, state, timeout}
+ _ -> {:ok, req, :undefined_state}
+ end
+ end
+
+ @doc """
+ Handles initalization of the websocket
+
+ Possible returns:
+ :ok
+ {:ok, state}
+ {:ok, state, timeout} # Timeout defines how long it waits for activity from the client. Default: infinity.
+ """
+ def start(_transport, req, _opts) do
+ {:ok, req, :undefined_state}
+ end
+
+ @doc """
+ Handles handles recieving data from the client, default implementation does nothing.
+ """
+ def stream(data, req, state) do
+ {:ok, req, state}
+ end
+
+ @doc false
+ def websocket_handle(data, req, state) do
+ stream(data, Socket.new(conn: req, pid: self()), state)
+ {:ok, req, state}
+ end
+
+ @doc """
+ Handles handles recieving messages from erlang processes. Default returns
+ {:ok, state}
+ Possible Returns are identical to stream, all replies gets send to the client.
+ """
+ def info(conn, info, state) do
+ {:ok, state}
+ end
+
+ @doc false
+ def websocket_info({:send, frame, state}, req, _state) do
+ {:reply, frame, req, state}
+ end
+ @doc false
+ def websocket_info(:shutdown, req, state) do
+ {:shutdown, req, state}
+ end
+ @doc false
+ def websocket_info(:hibernate, req, state) do
+ {:ok, req, state, :hibernate}
+ end
+
+ @doc false
+ def websocket_info(data, req, state) do
+ info(data, Socket.new(conn: req, pid: self()), state)
+ {:ok, req, state}
+ end
+
+ @doc """
+ This is called right before the websocket is about to be closed.
+ Reason is defined as:
+ {:normal, :shutdown | :timeout} # Called when erlang closes connection
+ {:remote, :closed} # Called if the client formally closes connection
+ {:remote, close_code(), binary()}
+ {:error, :badencoding | :badframe | :closed | atom()} # Called for many reasons: tab closed, connection dropped.
+ """
+ def closed(_reason, req, state) do
+ :ok
+ end
+
+ @doc false
+ def websocket_terminate(reason, req, state) do
+ closed(reason, Socket.new(conn: req), state)
+ end
+
+ defoverridable [start: 3, stream: 3, info: 3, closed: 3]
+ end
+ end
+
+ @doc """
+ Sends a reply to the socket. Follow the cowboy websocket frame syntax
+ Frame is defined as
+ :close | :ping | :pong
+ {:text | :binary | :close | :ping | :pong, iodata()}
+ {:close, close_code(), iodata()}
+ Options:
+ :state
+ :hibernate # (true | false) if you want to hibernate the connection
+ close_code: 1000..4999
+ """
+ def reply(socket, frame, state \\ []) do
+ send(socket.pid, {:send, frame, state})
+ end
+
+ @doc """
+ Terminates a connection.
+ """
+ def terminate(socket) do
+ send(socket.pid, :shutdown)
+ end
+
+ @doc """
+ Hibernates the socket.
+ """
+ def hibernate(socket) do
+ send(socket.pid, :hibernate)
+ end
+end
+
View
4 mix.lock
@@ -1,5 +1,5 @@
-[ "cowboy": {:git, "git://github.com/extend/cowboy.git", "0ec713fc4b185c3cd0f6b2e7ec2c5f198361bddd", []},
- "cowlib": {:git, "git://github.com/extend/cowlib.git", "63298e8e160031a70efff86a1acde7e7db1fcda6", [ref: "0.4.0"]},
+[ "cowboy": {:git, "git://github.com/extend/cowboy.git", "e7afe1f381f93f0c4df3680ff55dae111e864c9f", []},
+ "cowlib": {:git, "git://github.com/extend/cowlib.git", "0d4ece08a7cce90a07cf33d4edad29bc324c7d90", [ref: "0.5.1"]},
"ex_doc": {:git, "git://github.com/elixir-lang/ex_doc.git", "ee01fe3efd7b8af1dbaa00946bad5817d57b5875", []},
"inflex": {:git, "git://github.com/nurugger07/inflex.git", "b1c8b1d2647867b9171dbf3e6a3a4ab77ad0617e", []},
"mime": {:git, "git://github.com/dynamo/mime.git", "db84370c53a67a7e58a4d0cfb026f4edc64a9367", []},
View
24 test/phoenix/adapters/cowboy_test.exs
@@ -0,0 +1,24 @@
+defmodule Phoenix.Adapters.CowboyTest do
+ use ExUnit.Case, async: true
+ alias Phoenix.Adapters.Cowboy
+
+ test "only modifies dispatch option" do
+ options = ListDict.new port: 5000
+ assert Cowboy.setup_options(Pheonix, options, [])[:port] == 5000
+ end
+
+ test "add users dispatch directly to the options" do
+ options = Cowboy.setup_options(Phoenix, [dispatch: [{:hello}]], [])
+ assert {:hello} in options[:dispatch][:_]
+ end
+
+ test "add dispatch_options to the list" do
+ options = Cowboy.setup_options(Phoenix, [], [:my_dispatch])
+ assert :my_dispatch in options[:dispatch][:_]
+ end
+
+ test "adds default plug adapter and points to our module" do
+ options = Cowboy.setup_options(Phoenix, [], [])
+ assert options[:dispatch][:_] == [{:_, Plug.Adapters.Cowboy.Handler, {Phoenix, []}}]
+ end
+end
View
21 test/phoenix/websocket/raw_websocket_test.exs
@@ -0,0 +1,21 @@
+defmodule Phoenix.Controller.WebsocketTest do
+ use ExUnit.Case, async: true
+ import Phoenix.Websocket.RawHandler
+
+ defrecord Socket, conn: nil, pid: nil
+
+ test "verify correct return from terminate" do
+ terminate(Socket.new(pid: self()))
+ assert_received :shutdown
+ end
+
+ test "verify correct return from hibernate" do
+ hibernate(Socket.new(pid: self))
+ assert_received :hibernate
+ end
+
+ test "verify basic reply" do
+ reply(Socket.new(pid: self), {:text, "hello"})
+ assert_received {:send, {:text, "hello"}, []}
+ end
+end
Something went wrong with that request. Please try again.