├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── phoenix_tcp │ ├── adapters │ └── ranch.ex │ ├── ranch_handler.ex │ ├── ranch_server.ex │ ├── supervisor.ex │ └── transports │ └── tcp.ex ├── mix.exs ├── mix.lock └── test ├── phoenix └── integration │ └── tcp_test.exs ├── support └── ranch_tcp_client.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017, The phoenix_tcp Developers 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoenixTCP 2 | 3 | POC TCP Transport for the Phoenix Framework 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add phoenix_tcp to your list of dependencies in `mix.exs`: 10 | 11 | def deps do 12 | [{:phoenix_tcp, "~> 0.0.1"}] 13 | [{:phoenix_tcp, git: "git@github.com:sendence/phoenix_tcp.git", tag: "v0.0.1"}] 14 | end 15 | 16 | ## Setup 17 | 18 | 1. In your application file add the following as a child after your `endpoint`: 19 | `supervisor(PhoenixTCP.Supervisor, [:app_name, Chat.Endpoint])` 20 | 21 | 2. In your config.ex add to your endpoints config: 22 | ``` 23 | tcp_handler: PhoenixTCP.RanchHandler, 24 | tcp: [port: System.get_env("PHX_TCP_PORT") || 5001] 25 | ``` 26 | 27 | 3. In your socket(s) add the following: 28 | `transport :tcp, PhoenixTCP.Transports.TCP` 29 | 30 | ## Usage 31 | 32 | in order to send data to Phoenix over TCP using this transport, the data must be sent in binary format. The first 4 bytes will be used to determine the message size and the remaining bytes will be the message. 33 | Ex. using Elixir: 34 | ``` 35 | iex> opts = [:binary, active: false] 36 | [:binary, {:active, false}] 37 | iex> {:ok, socket} = :gen_tcp.connect('localhost', 5001, opts) 38 | {:ok, #Port<0.5652>} 39 | iex> path = "{\"path\": \"/socket/tcp\", \"params\": {}}" 40 | "{\"path\": \"/socket/tcp\", \"params\": {}}" 41 | iex> path_msg = << byte_size(path) :: size(32) >> <> path 42 | <<0, 0, 0, 37, 123, ... 125, 125>> 43 | iex> :tcp_send(socket, path_msg) 44 | ... 45 | ``` 46 | 47 | The server initially expects to receive the following message in json: 48 | `{"path": "/:path", "params": {}}` 49 | 50 | once a connection is established, the standard Phoenix join event is expected (a ref must be passed): 51 | `{"event": "phx_join", "topic": "topic-name", "payload": null, "ref": null}` 52 | 53 | and subsequent messages are sent after the join is established in the same structure: 54 | `{"event": ..., "topic": ..., "payload": ..., "ref": ...}` 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :phoenix_tcp, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:phoenix_tcp, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | config :logger, :console, format: "$message", 23 | colors: [enabled: false] 24 | 25 | config :phoenix, :filter_parameters, ["password", "secret"] 26 | 27 | # It is also possible to import configuration files, relative to this 28 | # directory. For example, you can emulate configuration per environment 29 | # by uncommenting the line below and defining dev.exs, test.exs and such. 30 | # Configuration from the imported file will override the ones defined 31 | # here (which is why it is important to import them last). 32 | # 33 | # import_config "#{Mix.env}.exs" 34 | -------------------------------------------------------------------------------- /lib/phoenix_tcp/adapters/ranch.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.Adapters.Ranch do 2 | 3 | def args(scheme, plug, _opts, ranch_options) do 4 | ranch_options 5 | |> Keyword.put_new(:ref, build_ref(plug, scheme)) 6 | |> to_args() 7 | end 8 | 9 | @doc """ 10 | Shutdowns the given reference. 11 | """ 12 | def shutdown(ref) do 13 | :ranch.stop_listener(ref) 14 | end 15 | 16 | @doc """ 17 | Returns a child spec to be supervised by your application. 18 | """ 19 | def child_spec(scheme, plug, opts, ranch_options \\ []) do 20 | [ref, nb_acceptors, tcp_server, trans_opts, proto_opts] = args(scheme, plug, opts, ranch_options) 21 | ranch_module = case scheme do 22 | :tcp -> :ranch_tcp 23 | # add ssl later? 24 | end 25 | :ranch.child_spec(ref, nb_acceptors, ranch_module, trans_opts, tcp_server, proto_opts) 26 | end 27 | 28 | @tcp_ranch_options [port: 5001] 29 | @protocol_options [] 30 | 31 | defp to_args(all_opts) do 32 | {initial_transport_options, opts} = Enum.partition(all_opts, &is_atom/1) 33 | opts = Keyword.delete(opts, :otp_app) 34 | {ref, opts} = Keyword.pop(opts, :ref) 35 | {handlers, opts} = Keyword.pop(opts, :handlers) 36 | {acceptors, opts} = Keyword.pop(opts, :acceptors, 100) 37 | {tcp_server, opts} = Keyword.pop(opts, :tcp_server, PhoenixTCP.RanchServer) 38 | {protocol_options, opts} = Keyword.pop(opts, :protocol_options, []) 39 | {extra_options, transport_options} = Keyword.split(opts, @protocol_options) 40 | protocol_options = [handlers: handlers] ++ protocol_options ++ extra_options 41 | [ref, acceptors, tcp_server, initial_transport_options ++ transport_options, protocol_options] 42 | end 43 | 44 | defp build_ref(plug, scheme) do 45 | Module.concat(plug, scheme |> to_string |> String.upcase) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/phoenix_tcp/ranch_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.RanchHandler do 2 | @behaviour Phoenix.Endpoint.Handler 3 | require Logger 4 | 5 | @doc """ 6 | Generates a childspec to be used in the supervision tree. 7 | """ 8 | def child_spec(scheme, endpoint, config) do 9 | handlers = 10 | for {path, socket} <- endpoint.__sockets__, 11 | {transport, {module, config}} <- socket.__transports__, 12 | # allow handlers to be configured at the transport level 13 | transport == :tcp, 14 | handler = config[:tcp_server] || default_for(module), 15 | into: %{}, 16 | do: {Path.join(path, Atom.to_string(transport)), 17 | # handler being the tcp protocol implementing module 18 | # module being the transport module 19 | # endpoint being the app specific endpoint 20 | # socket being the app specific socket 21 | {handler, module, {endpoint, socket, transport}}} 22 | config = Keyword.put_new(config, :handlers, handlers) 23 | 24 | {ref, mfa, type, timeout, kind, modules} = 25 | PhoenixTCP.Adapters.Ranch.child_spec(scheme, endpoint, [], config) 26 | 27 | # Rewrite MFA for proper error reporting 28 | mfa = {__MODULE__, :start_link, [scheme, endpoint, mfa]} 29 | {ref, mfa, type, timeout, kind, modules} 30 | end 31 | 32 | @doc """ 33 | Callback to start the TCP endpoint 34 | """ 35 | 36 | def start_link(scheme, endpoint, {m, f, [ref | _] = a}) do 37 | # ref is used by Ranch to identify its listeners 38 | case apply(m, f, a) do 39 | {:ok, pid} -> 40 | Logger.info info(scheme, endpoint, ref) 41 | {:ok, pid} 42 | {:error, {:shutdown, {_,_, {{_, {:error, :eaddrinuse}}, _}}}} = error -> 43 | Logger.error [info(scheme, endpoint, ref), " failed, port already in use"] 44 | error 45 | {:error, _} = error -> 46 | error 47 | end 48 | end 49 | 50 | def default_for(PhoenixTCP.Transports.TCP), do: PhoenixTCP.RanchServer 51 | 52 | defp info(scheme, endpoint, ref) do 53 | port = :ranch.get_port(ref) 54 | "Running #{inspect endpoint} with Ranch using #{scheme} on port #{port}" 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/phoenix_tcp/ranch_server.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.RanchServer do 2 | use GenServer 3 | require Logger 4 | 5 | @behaviour :ranch_protocol 6 | 7 | def start_link(ref, tcp_socket, tcp_transport, opts \\ []) do 8 | :proc_lib.start_link(__MODULE__, :init, [ref, tcp_socket, tcp_transport, opts]) 9 | end 10 | 11 | def init(ref, tcp_socket, tcp_transport, opts) do 12 | :ok = :proc_lib.init_ack({:ok, self()}) 13 | :ok = :ranch.accept_ack(ref) 14 | :ok = tcp_transport.setopts(tcp_socket, [:binary, active: :once, packet: 4]) 15 | state = %{ 16 | tcp_transport: tcp_transport, 17 | tcp_socket: tcp_socket, 18 | handlers: Keyword.fetch!(opts, :handlers), 19 | serializer: opts[:serializer] || Poison 20 | } 21 | :gen_server.enter_loop(__MODULE__, [], state) 22 | end 23 | 24 | def handle_info({:tcp, tcp_socket, data}, %{handlers: handlers, tcp_transport: tcp_transport, serializer: serializer} = state) do 25 | case serializer.decode(data) do 26 | {:ok, %{"path" => path, "params" => params}} -> 27 | case Map.get(handlers, path) do 28 | # handler is the server which handles the tcp messages 29 | # currently there is only one server, 30 | # module is the transport module 31 | # opts = {endpoint, socket, transport} 32 | # endpoint being the endpoint defined in the phx app 33 | # socket being the socket defined in the phx app 34 | # transport being the atom defining the transport 35 | {handler, module, opts} -> 36 | case module.init(params, opts) do 37 | {:ok, {module, {opts, timeout}}} -> 38 | state = %{ 39 | tcp_transport: tcp_transport, 40 | tcp_socket: tcp_socket, 41 | handler: handler, 42 | transport_module: module, 43 | transport_config: opts, 44 | timeout: timeout 45 | } 46 | :ok = tcp_transport.setopts(tcp_socket, [active: :once]) 47 | connected_msg = serializer.encode!(connected_json) 48 | tcp_transport.send(tcp_socket, connected_msg) 49 | {:noreply, state, timeout} 50 | {:error, error_msg} -> 51 | status_error_msg = serializer.encode!(%{"payload" => %{"status" => "error", "response" => error_msg}}) 52 | tcp_transport.send(tcp_socket, status_error_msg) 53 | :ok = tcp_transport.setopts(tcp_socket, [active: :once]) 54 | {:noreply, state} 55 | end 56 | nil -> 57 | error_msg = serializer.encode!(%{"payload" => %{"status" => "error", "response" => "no path matches"}}) 58 | tcp_transport.send(tcp_socket, error_msg) 59 | :ok = tcp_transport.setopts(tcp_socket, [active: :once]) 60 | {:noreply, state} 61 | end 62 | {:error, _error} -> 63 | error_msg = serializer.encode!(%{"payload" => %{"status" => "error", "response" => "Unable to decode data with #{serializer}"}}) 64 | tcp_transport.send(tcp_socket, error_msg) 65 | Logger.warn "Unable to decode data in #{__MODULE__}'s handle_info() using #{serializer}" 66 | :ok = tcp_transport.setopts(tcp_socket, [active: :once]) 67 | {:noreply, state} 68 | end 69 | end 70 | 71 | def handle_info({:tcp, _tcp_socket, payload}, 72 | %{transport_module: module, transport_config: config} = state) do 73 | handle_reply state, module.tcp_handle(payload, config) 74 | end 75 | 76 | def handle_info({:tcp_closed, _tcp_socket}, 77 | %{transport_module: module, transport_config: config} = state) do 78 | module.tcp_close(config) 79 | {:stop, :shutdown, state} 80 | end 81 | 82 | def handle_info({:tcp_closed, _tcp_socket}, state) do 83 | {:stop, :shutdown, state} 84 | end 85 | 86 | def handle_info(:timeout, %{transport_module: module, transport_config: config} = state) do 87 | module.tcp_close(config) 88 | {:stop, :shutdown, state} 89 | end 90 | 91 | def handle_info(msg, %{transport_module: module, transport_config: config} = state) do 92 | handle_reply state, module.tcp_info(msg, config) 93 | end 94 | 95 | def terminate(_reason, %{transport_module: module, transport_config: config}) do 96 | module.tcp_close(config) 97 | end 98 | 99 | def terminate(_reason, _state) do 100 | :ok 101 | end 102 | 103 | defp handle_reply(state, {:shutdown, new_config}) do 104 | new_state = Map.put(state, :transport_config, new_config) 105 | {:stop, :shutdown, new_state} 106 | end 107 | 108 | defp handle_reply(%{timeout: timeout, tcp_transport: transport, 109 | tcp_socket: socket} = state, {:ok, new_config}) do 110 | new_state = Map.put(state, :transport_config, new_config) 111 | :ok = transport.setopts(socket, [active: :once]) 112 | {:noreply, new_state, timeout} 113 | end 114 | 115 | defp handle_reply(%{timeout: timeout, tcp_transport: transport, tcp_socket: socket} = state, 116 | {:reply, {_encoding, encoded_payload}, new_config}) do 117 | transport.send(socket, encoded_payload) 118 | :ok = transport.setopts(socket, [active: :once]) 119 | new_state = Map.put(state, :transport_config, new_config) 120 | {:noreply, new_state, timeout} 121 | end 122 | 123 | defp connected_json do 124 | %{"payload" => %{"status" => "ok", 125 | "response" => "connected"}, 126 | "topic" => "", 127 | "event" => "", 128 | "ref" => nil} 129 | end 130 | 131 | end 132 | -------------------------------------------------------------------------------- /lib/phoenix_tcp/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.Supervisor do 2 | # the supervisor for the underlying handlers 3 | @moduledoc false 4 | 5 | use Supervisor 6 | require Logger 7 | 8 | def start_link(otp_app, endpoint, opts \\ []) do 9 | Supervisor.start_link(__MODULE__, {otp_app, endpoint}, opts) 10 | end 11 | 12 | def init({otp_app, endpoint}) do 13 | children = [] 14 | 15 | handler = endpoint.config(:tcp_handler) 16 | 17 | if config = endpoint.config(:tcp) do 18 | config = default(config, otp_app, 5001) 19 | children = [handler.child_spec(:tcp, endpoint, config) | children] 20 | end 21 | 22 | supervise(children, strategy: :one_for_one) 23 | end 24 | 25 | defp default(config, otp_app, port) do 26 | config = 27 | config 28 | |> Keyword.put_new(:otp_app, otp_app) 29 | |> Keyword.put_new(:port, port) 30 | 31 | Keyword.put(config, :port, to_port(config[:port])) 32 | end 33 | 34 | defp to_port(nil) do 35 | Logger.error "TCP Server will not start because :port in config is nil, please use a valid port number" 36 | exit(:shutdown) 37 | end 38 | defp to_port(binary) when is_binary(binary), do: String.to_integer(binary) 39 | defp to_port(integer) when is_integer(integer), do: integer 40 | defp to_port({:system, env_var}), do: to_port(System.get_env(env_var)) 41 | end -------------------------------------------------------------------------------- /lib/phoenix_tcp/transports/tcp.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.Transports.TCP do 2 | require IEx 3 | require Logger 4 | @moduledoc """ 5 | Socket transport for tcp clients. 6 | 7 | ## Configuration 8 | 9 | the tcp is configurable in your socket: 10 | transport :tcp, PhoenixTCP.Transports.TCP, 11 | timeout: :infinity, 12 | serializer: Phoenix.Transports.WebSocketSerializer, 13 | transport_log: false 14 | 15 | * `:timeout` - the timeout for keeping tcp connections 16 | open after it last received data, defaults to 60_000ms 17 | 18 | * `:transport_log` - if the transport layer itself should log, and, if so, the level 19 | 20 | * `:serializer` - the serializer for tcp messages 21 | 22 | * `:code_reloader` - optionally override the default `:code_reloader` value 23 | from the socket's endpoint 24 | 25 | ## Serializer 26 | 27 | By default, JSON encoding is used to broker messages to and from the clients. 28 | A custom serializer may be given as a module which implements the `encode!/1` 29 | and `decode!/2` functions defined by the `Phoenix.Transports.Serializer` 30 | behaviour. 31 | """ 32 | 33 | @behaviour Phoenix.Socket.Transport 34 | 35 | def default_config() do 36 | [serializer: Phoenix.Transports.WebSocketSerializer, 37 | timeout: 60_000, 38 | transport_log: false] 39 | end 40 | 41 | ## Callbacks 42 | 43 | alias Phoenix.Socket.Broadcast 44 | alias Phoenix.Socket.Transport 45 | 46 | @doc false 47 | def init(params, {endpoint, handler, transport}) do 48 | {_, opts} = handler.__transport__(transport) 49 | serializer = Keyword.fetch!(opts, :serializer) 50 | 51 | case Transport.connect(endpoint, handler, transport, __MODULE__, serializer, params) do 52 | {:ok, socket} -> 53 | {:ok, transport_state, timeout} = tcp_init({socket, opts}) 54 | {:ok, {__MODULE__, {transport_state, timeout}}} 55 | :error -> 56 | {:error, "error connecting to transport #{inspect __MODULE__}"} 57 | end 58 | end 59 | 60 | @doc false 61 | def tcp_init({socket, config}) do 62 | Process.flag(:trap_exit, true) 63 | serializer = Keyword.fetch!(config, :serializer) 64 | timeout = Keyword.fetch!(config, :timeout) 65 | 66 | if socket.id, do: socket.endpoint.subscribe(self, socket.id, link: true) 67 | 68 | {:ok, %{socket: socket, 69 | channels: HashDict.new, 70 | channels_inverse: HashDict.new, 71 | serializer: serializer}, timeout} 72 | end 73 | 74 | def tcp_handle(payload, state) do 75 | msg = state.serializer.decode!(payload, []) 76 | 77 | case Transport.dispatch(msg, state.channels, state.socket) do 78 | :noreply -> 79 | {:ok, state} 80 | {:reply, reply_msg} -> 81 | encode_reply(reply_msg, state) 82 | {:joined, channel_pid, reply_msg} -> 83 | encode_reply(reply_msg, put(state, msg.topic, channel_pid)) 84 | {:error, _reason, error_reply_msg} -> 85 | encode_reply(error_reply_msg, state) 86 | end 87 | end 88 | 89 | @doc false 90 | def tcp_info({:EXIT, channel_pid, reason}, state) do 91 | case HashDict.get(state.channels_inverse, channel_pid) do 92 | nil -> {:ok, state} 93 | topic -> 94 | new_state = delete(state, topic, channel_pid) 95 | encode_reply Transport.on_exit_message(topic, reason), new_state 96 | end 97 | end 98 | 99 | @doc false 100 | def tcp_info(%Broadcast{event: "disconnect"}, state) do 101 | {:shutdown, state} 102 | end 103 | 104 | def tcp_info({:socket_push, _, _encoded_payload} = msg, state) do 105 | format_reply(msg, state) 106 | end 107 | 108 | @doc false 109 | def tcp_close(state) do 110 | for {pid, _} <- state.channels_inverse do 111 | Phoenix.Channel.Server.close(pid) 112 | end 113 | end 114 | 115 | defp encode_reply(reply, state) do 116 | format_reply(state.serializer.encode!(reply), state) 117 | end 118 | 119 | defp format_reply({:socket_push, encoding, encoded_payload}, state) do 120 | {:reply, {encoding, encoded_payload}, state} 121 | end 122 | 123 | defp put(state, topic, channel_pid) do 124 | %{state | channels: HashDict.put(state.channels, topic, channel_pid), 125 | channels_inverse: HashDict.put(state.channels_inverse, channel_pid, topic)} 126 | end 127 | 128 | defp delete(state, topic, channel_pid) do 129 | %{state | channels: HashDict.delete(state.channels, topic), 130 | channels_inverse: HashDict.delete(state.channels_inverse, channel_pid)} 131 | end 132 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :phoenix_tcp, 6 | version: "0.0.1", 7 | elixir: "~> 1.2", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type "mix help compile.app" for more information 16 | def application do 17 | [applications: [:logger, :ranch, :phoenix, :poison]] 18 | end 19 | 20 | # Dependencies can be Hex packages: 21 | # 22 | # {:mydep, "~> 0.3.0"} 23 | # 24 | # Or git/path repositories: 25 | # 26 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 27 | # 28 | # Type "mix help deps" for more examples and options 29 | defp deps do 30 | [{:ranch, "~> 1.0", manager: :rebar}, 31 | {:poison, "~> 1.5"}, 32 | {:phoenix, "~> 1.1.4"}, 33 | {:exrm, "~> 1.0.0"}] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bbmustache": {:hex, :bbmustache, "1.0.4", "7ba94f971c5afd7b6617918a4bb74705e36cab36eb84b19b6a1b7ee06427aa38", [:rebar], []}, 2 | "cf": {:hex, :cf, "0.2.1", "69d0b1349fd4d7d4dc55b7f407d29d7a840bf9a1ef5af529f1ebe0ce153fc2ab", [:rebar3], []}, 3 | "erlware_commons": {:hex, :erlware_commons, "0.19.0", "7b43caf2c91950c5f60dc20451e3c3afba44d3d4f7f27bcdc52469285a5a3e70", [:rebar3], [{:cf, "0.2.1", [hex: :cf, optional: false]}]}, 4 | "exrm": {:hex, :exrm, "1.0.5", "53ecb20da2f4e5b4c82ea6776824fbc677c8d287bf20efc9fc29cacc2cca124f", [:mix], [{:relx, "~> 3.5", [hex: :relx, optional: false]}]}, 5 | "getopt": {:hex, :getopt, "0.8.2", "b17556db683000ba50370b16c0619df1337e7af7ecbf7d64fbf8d1d6bce3109b", [:rebar], []}, 6 | "phoenix": {:hex, :phoenix, "1.1.4", "65809fba92eb94377372a5fb5a561197654bb8406e773cc47ca1a031bbe58019", [:mix], [{:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 7 | "plug": {:hex, :plug, "1.1.4", "2eee0e85ad420db96e075b3191d3764d6fff61422b101dc5b02e9cce99cacfc7", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 8 | "poison": {:hex, :poison, "1.5.2", "560bdfb7449e3ddd23a096929fb9fc2122f709bcc758b2d5d5a5c7d0ea848910", [:mix], []}, 9 | "providers": {:hex, :providers, "1.6.0", "db0e2f9043ae60c0155205fcd238d68516331d0e5146155e33d1e79dc452964a", [:rebar3], [{:getopt, "0.8.2", [hex: :getopt, optional: false]}]}, 10 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 11 | "relx": {:hex, :relx, "3.19.0", "286dd5244b4786f56aac75d5c8e2d1fb4cfd306810d4ec8548f3ae1b3aadb8f7", [:rebar3], [{:providers, "1.6.0", [hex: :providers, optional: false]}, {:getopt, "0.8.2", [hex: :getopt, optional: false]}, {:erlware_commons, "0.19.0", [hex: :erlware_commons, optional: false]}, {:cf, "0.2.1", [hex: :cf, optional: false]}, {:bbmustache, "1.0.4", [hex: :bbmustache, optional: false]}]}} 12 | -------------------------------------------------------------------------------- /test/phoenix/integration/tcp_test.exs: -------------------------------------------------------------------------------- 1 | Code.require_file "../../support/ranch_tcp_client.exs", __DIR__ 2 | 3 | defmodule PhoenixTCP.Integration.RanchTCPTest do 4 | use ExUnit.Case 5 | import ExUnit.CaptureLog 6 | 7 | alias PhoenixTCP.RanchTCPClient 8 | alias Phoenix.Socket.Message 9 | alias __MODULE__.Endpoint 10 | 11 | @port 5801 12 | 13 | Application.put_env(:phoenix, Endpoint, [ 14 | tcp_handler: PhoenixTCP.RanchHandler, 15 | tcp: [port: @port], 16 | pubsub: [adapter: Phoenix.PubSub.PG2, name: __MODULE__] 17 | ]) 18 | 19 | defmodule RoomChannel do 20 | use Phoenix.Channel 21 | 22 | intercept ["new_msg"] 23 | 24 | def join(topic, message, socket) do 25 | Process.register(self, String.to_atom(topic)) 26 | send(self, {:after_join, message}) 27 | {:ok, socket} 28 | end 29 | 30 | def handle_info({:after_join, message}, socket) do 31 | broadcast socket, "user_entered", %{user: message["user"]} 32 | push socket, "joined", Map.merge(%{status: "connected"}, socket.assigns) 33 | {:noreply, socket} 34 | end 35 | 36 | def handle_in("new_msg", message, socket) do 37 | broadcast! socket, "new_msg", message 38 | {:noreply, socket} 39 | end 40 | 41 | def handle_in("boom", _message, _socket) do 42 | raise "boom" 43 | end 44 | 45 | def handle_out("new_msg", payload, socket) do 46 | push socket, "new_msg", Map.put(payload, "transport", inspect(socket.transport)) 47 | {:noreply, socket} 48 | end 49 | 50 | def terminate(_reason, socket) do 51 | push socket, "you_left", %{message: "bye!"} 52 | :ok 53 | end 54 | end 55 | 56 | defmodule UserSocket do 57 | use Phoenix.Socket 58 | 59 | channel "rooms:*", RoomChannel 60 | 61 | transport :tcp, PhoenixTCP.Transports.TCP 62 | 63 | def connect(%{"reject" => "true"}, _socket) do 64 | :error 65 | end 66 | 67 | def connect(params, socket) do 68 | Logger.disable(self()) 69 | {:ok, assign(socket, :user_id, params["user_id"])} 70 | end 71 | 72 | def id(socket) do 73 | if id = socket.assigns.user_id, do: "user_sockets:#{id}" 74 | end 75 | end 76 | 77 | defmodule LoggingSocket do 78 | use Phoenix.Socket 79 | 80 | channel "rooms:*", RoomChannel 81 | 82 | transport :tcp, PhoenixTCP.Transports.TCP 83 | 84 | def connect(%{"reject" => "true"}, _socket) do 85 | :error 86 | end 87 | 88 | def connect(params, socket) do 89 | {:ok, assign(socket, :user_id, params["user_id"])} 90 | end 91 | 92 | def id(socket) do 93 | if id = socket.assigns.user_id, do: "user_sockets:#{id}" 94 | end 95 | end 96 | 97 | defmodule Endpoint do 98 | use Phoenix.Endpoint, otp_app: :phoenix 99 | 100 | socket "/tcp", UserSocket 101 | socket "/tcp/admin", UserSocket 102 | socket "/tcp/logging", LoggingSocket 103 | end 104 | 105 | setup_all do 106 | capture_log fn -> Endpoint.start_link() end 107 | capture_log fn -> PhoenixTCP.Supervisor.start_link(:phoenix, Endpoint) end 108 | :ok 109 | end 110 | 111 | test "endpoint handles multiple mount segments" do 112 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/admin/tcp") 113 | RanchTCPClient.join(sock, "rooms:admin-lobby", %{}) 114 | assert_receive %Message{event: "phx_reply", 115 | payload: %{"response" => %{}, "status" => "ok"}, 116 | ref: "1", topic: "rooms:admin-lobby"} 117 | end 118 | 119 | test "join, leave, and event messages" do 120 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp") 121 | RanchTCPClient.join(sock, "rooms:lobby1", %{}) 122 | 123 | assert_receive %Message{event: "phx_reply", 124 | payload: %{"response" => %{}, "status" => "ok"}, 125 | ref: "1", topic: "rooms:lobby1"} 126 | assert_receive %Message{event: "joined", payload: %{"status" => "connected", 127 | "user_id" => nil}} 128 | assert_receive %Message{event: "user_entered", 129 | payload: %{"user" => nil}, 130 | ref: nil, topic: "rooms:lobby1"} 131 | 132 | channel_pid = Process.whereis(:"rooms:lobby1") 133 | assert channel_pid 134 | assert Process.alive?(channel_pid) 135 | 136 | RanchTCPClient.send_event(sock, "rooms:lobby1", "new_msg", %{body: "hi!"}) 137 | assert_receive %{event: "new_msg", payload: %{"transport" => "PhoenixTCP.Transports.TCP", "body" => "hi!"}} 138 | 139 | RanchTCPClient.leave(sock, "rooms:lobby1", %{}) 140 | assert_receive %Message{event: "you_left", payload: %{"message" => "bye!"}} 141 | assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}} 142 | assert_receive %Message{event: "phx_close", payload: %{}} 143 | refute Process.alive?(channel_pid) 144 | 145 | RanchTCPClient.send_event(sock, "rooms:lobby1", "new_msg", %{body: "Should ignore"}) 146 | refute_receive %Message{event: "new_msg"} 147 | assert_receive %Message{event: "phx_reply", payload: %{"response" => %{"reason" => "unmatched topic"}}} 148 | 149 | RanchTCPClient.send_event(sock, "rooms:lobby1", "new_msg", %{body: "Should ignore"}) 150 | refute_receive %Message{event: "new_msg"} 151 | end 152 | 153 | test "filter params on join" do 154 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/logging/tcp") 155 | log = capture_log fn -> 156 | RanchTCPClient.join(sock, "rooms:admin-lobby", %{"foo" => "bar", "password" => "shouldnotshow"}) 157 | assert_receive %Message{event: "phx_reply", 158 | payload: %{"response" => %{}, "status" => "ok"}, 159 | ref: "1", topic: "rooms:admin-lobby"} 160 | end 161 | assert log =~ "JOIN rooms:admin-lobby to PhoenixTCP.Integration.RanchTCPTest.RoomChannel\n Transport: PhoenixTCP.Transports.TCP\n Parameters: %{\"foo\" => \"bar\", \"password\" => \"shouldnotshow\"}Replied rooms:admin-lobby :ok" 162 | end 163 | 164 | test "sends phx_error if a channel server abnormally exits" do 165 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp") 166 | 167 | RanchTCPClient.join(sock, "rooms:lobby", %{}) 168 | assert_receive %Message{event: "phx_reply", ref: "1", payload: %{"response" => %{}, "status" => "ok"}} 169 | assert_receive %Message{event: "joined"} 170 | assert_receive %Message{event: "user_entered"} 171 | 172 | capture_log fn -> 173 | RanchTCPClient.send_event(sock, "rooms:lobby", "boom", %{}) 174 | assert_receive %Message{event: "phx_error", payload: %{}, topic: "rooms:lobby"} 175 | end 176 | end 177 | 178 | test "channels are terminated if transport normally exits" do 179 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp") 180 | 181 | RanchTCPClient.join(sock, "rooms:lobby2", %{}) 182 | assert_receive %Message{event: "phx_reply", ref: "1", payload: %{"response" => %{}, "status" => "ok"}} 183 | assert_receive %Message{event: "joined"} 184 | channel = Process.whereis(:"rooms:lobby2") 185 | assert channel 186 | Process.monitor(channel) 187 | RanchTCPClient.close(sock) 188 | assert_receive {:DOWN, _, :process, ^channel, {:shutdown, :closed}} 189 | end 190 | 191 | test "refuses websocket events that haven't joined" do 192 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp") 193 | 194 | RanchTCPClient.send_event(sock, "rooms:lobby", "new_msg", %{body: "hi!"}) 195 | refute_receive %Message{event: "new_msg"} 196 | assert_receive %Message{event: "phx_reply", payload: %{"response" => %{"reason" => "unmatched topic"}}} 197 | 198 | RanchTCPClient.send_event(sock, "rooms:lobby1", "new_msg", %{body: "Should ignore"}) 199 | refute_receive %Message{event: "new_msg"} 200 | end 201 | 202 | test "shuts down when receiving disconnect broadcasts on socket's id" do 203 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp", %{"user_id" => "1001"}) 204 | 205 | RanchTCPClient.join(sock, "rooms:tcpdisconnect1", %{}) 206 | assert_receive %Message{topic: "rooms:tcpdisconnect1", event: "phx_reply", 207 | ref: "1", payload: %{"response" => %{}, "status" => "ok"}} 208 | RanchTCPClient.join(sock, "rooms:tcpdisconnect2", %{}) 209 | assert_receive %Message{topic: "rooms:tcpdisconnect2", event: "phx_reply", 210 | ref: "2", payload: %{"response" => %{}, "status" => "ok"}} 211 | 212 | chan1 = Process.whereis(:"rooms:tcpdisconnect1") 213 | assert chan1 214 | chan2 = Process.whereis(:"rooms:tcpdisconnect2") 215 | assert chan2 216 | Process.monitor(sock) 217 | Process.monitor(chan1) 218 | Process.monitor(chan2) 219 | Endpoint.broadcast("user_sockets:1001", "disconnect", %{}) 220 | 221 | assert_receive {:DOWN, _, :process, ^sock, :normal} 222 | assert_receive {:DOWN, _, :process, ^chan1, {:shutdown, :closed}} 223 | assert_receive {:DOWN, _, :process, ^chan2, {:shutdown, :closed}} 224 | end 225 | 226 | test "duplicate join event logs and ignores messages" do 227 | {:ok, sock} = RanchTCPClient.start_link(self, "localhost", @port, "/tcp/tcp", %{"user_id" => "1001"}) 228 | RanchTCPClient.join(sock, "rooms:joiner", %{}) 229 | assert_receive %Message{topic: "rooms:joiner", event: "phx_reply", 230 | ref: "1", payload: %{"response" => %{}, "status" => "ok"}} 231 | 232 | log = capture_log fn -> 233 | RanchTCPClient.join(sock, "rooms:joiner", %{}) 234 | assert_receive %Message{topic: "rooms:joiner", event: "phx_reply", 235 | payload: %{"response" => %{"reason" => "already joined"}, 236 | "status" => "error"}} 237 | end 238 | assert log =~ "PhoenixTCP.Integration.RanchTCPTest.RoomChannel received join event with topic \"rooms:joiner\" but channel already joined" 239 | end 240 | 241 | end 242 | -------------------------------------------------------------------------------- /test/support/ranch_tcp_client.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixTCP.RanchTCPClient do 2 | use GenServer 3 | require Logger 4 | alias Poison, as: JSON 5 | 6 | @doc """ 7 | Starts the Ranch TCP server for the given path. Received Socket.Message's 8 | are forwarded to the sender pid 9 | """ 10 | def start_link(sender, host, port, path, params \\ %{}) do 11 | :proc_lib.start_link(__MODULE__, :init, [sender, host, port, path, params]) 12 | end 13 | 14 | def init(sender, host, port, path, params) do 15 | :ok = :proc_lib.init_ack({:ok, self}) 16 | opts = [:binary, active: false] 17 | connect_msg = %{"path" => path, "params" => params} 18 | {:ok, tcp_socket} = :ranch_tcp.connect(String.to_char_list(host), port, opts) 19 | :ok = :ranch_tcp.setopts(tcp_socket, [packet: 4]) 20 | :ok = :ranch_tcp.send(tcp_socket, json!(connect_msg)) 21 | :ok = :ranch_tcp.controlling_process(tcp_socket, self) 22 | state = %{tcp_transport: :ranch_tcp, tcp_socket: tcp_socket, sender: sender, ref: 0} 23 | :gen_server.enter_loop(__MODULE__, [], state) 24 | end 25 | 26 | @doc""" 27 | Closes the socket 28 | """ 29 | def close(socket) do 30 | send(socket, :close) 31 | end 32 | 33 | def handle_info({:tcp, _tcp_socket, msg}, %{tcp_transport: transport, 34 | tcp_socket: socket} = state) do 35 | send state.sender, Phoenix.Transports.WebSocketSerializer.decode!(msg, []) 36 | :ok = transport.setopts(socket, [active: :once]) 37 | {:noreply, state} 38 | end 39 | 40 | def handle_info({:send, msg}, %{tcp_transport: transport, 41 | tcp_socket: socket} = state) do 42 | msg = Map.put(msg, :ref, to_string(state.ref + 1)) 43 | :ok = transport.send(socket, json!(msg)) 44 | transport.setopts(socket, [active: :once]) 45 | {:noreply, put_in(state, [:ref], state.ref + 1)} 46 | end 47 | 48 | def handle_info({:tcp_closed, _tcp_socket}, state) do 49 | {:stop, :normal, state} 50 | end 51 | 52 | def handle_info(:close, state) do 53 | {:stop, :normal, state} 54 | end 55 | 56 | def terminate(_reason, _state) do 57 | :ok 58 | end 59 | 60 | @doc""" 61 | Sends an event to the TCP Server per the Message protocol 62 | """ 63 | def send_event(server_pid, topic, event, msg) do 64 | send server_pid, {:send, %{topic: topic, event: event, payload: msg}} 65 | end 66 | 67 | @doc""" 68 | Sends a heartbeat event 69 | """ 70 | def send_heartbeat(server_pid) do 71 | send_event(server_pid, "phoenix", "heartbeat", %{}) 72 | end 73 | 74 | @doc""" 75 | Sends join event to the TCP server per the Message protocol 76 | """ 77 | def join(server_pid, topic, msg) do 78 | send_event(server_pid, topic, "phx_join", msg) 79 | end 80 | 81 | @doc""" 82 | Sends leave event to the TCP server per the Message protocol 83 | """ 84 | def leave(server_pid, topic, msg) do 85 | send_event(server_pid, topic, "phx_leave", msg) 86 | end 87 | 88 | defp json!(map), do: JSON.encode!(map) 89 | end 90 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------