iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐈

Using mix phx.gen.socket for WebSocket Setup in Phoenix 1.6

に公開

In Phoenix version 1.6, the upcoming version of Phoenix, there have been changes regarding the WebSocket setup when starting a project, so I will document them here.

In conclusion, from Phoenix 1.6 onwards, the mix phx.new task no longer generates WebSocket-related settings and files, so it is necessary to run mix phx.gen.socket separately.

Background of this article

At the time of writing this article, efforts are underway toward the official release of Phoenix 1.6, as mentioned in Phoenix 1.6.0-rc.0 Released - Phoenix Blog. Changes for this version are summarized in phoenix/CHANGELOG.md at master · phoenixframework/phoenix.

In the list of changes for 1.6.0-rc.0 (2021-08-26), there is the following item, which is the subject of this post:

[mix phx.new] No longer generate a socket file by default, instead one can run mix phx.gen.socket

Creating a new Phoenix project

First, install the currently released RC version of Phoenix.

$ mix archive.install hex phx_new 1.6.0-rc.0

Then, create a project with the mix phx.new task as usual.

$ mix phx.new phx_16 --no-ecto

Preparing WebSockets

Up until the 1.5 series, various files related to WebSockets were created automatically, but from the 1.6 series onwards, you need to run the mix phx.gen.socket task.

Execute the mix phx.gen.socket task by passing the module name as an argument.

$ mix phx.gen.socket User
* creating lib/phx_16_web/channels/user_socket.ex
* creating assets/js/user_socket.js

Add the socket handler to your `lib/phx_16_web/endpoint.ex`, for example:

    socket "/socket", Phx16Web.UserSocket,
      websocket: true,
      longpoll: false

For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:

    import "./user_socket.js"

In versions up to 1.5, the files and configurations above were generated at the time of mix phx.new, including the UserSocket module. Now, they are generated by running the task mentioned above. However, the requirement to add the import for the JavaScript file handling WebSockets in assets/js/app.js remains the same.

Looking at the diff of the generated files, it looks like this:

diff --git a/assets/js/user_socket.js b/assets/js/user_socket.js
new file mode 100644
index 0000000..7a22631
--- /dev/null
+++ b/assets/js/user_socket.js
@@ -0,0 +1,64 @@
+// NOTE: The contents of this file will only be executed if
+// you uncomment its entry in "assets/js/app.js".
+
+// Bring in Phoenix channels client library:
+import {Socket} from "phoenix"
+
+// And connect to the path in "lib/phx_16_web/endpoint.ex". We pass the
+// token for authentication. Read below how it should be used.
+let socket = new Socket("/socket", {params: {token: window.userToken}})
+
+// When you connect, you'll often need to authenticate the client.
+// For example, imagine you have an authentication plug, `MyAuth`,
+// which authenticates the session and assigns a `:current_user`.
+// If the current user exists you can assign the user's token in
+// the connection for use in the layout.
+//
+// In your "lib/phx_16_web/router.ex":
+//
+//     pipeline :browser do
+//       ...
+//       plug MyAuth
+//       plug :put_user_token
+//     end
+//
+//     defp put_user_token(conn, _) do
+//       if current_user = conn.assigns[:current_user] do
+//         token = Phoenix.Token.sign(conn, "user socket", current_user.id)
+//         assign(conn, :user_token, token)
+//       else
+//         conn
+//       end
+//     end
+//
+// Now you need to pass this token to JavaScript. You can do so
+// inside a script tag in "lib/phx_16_web/templates/layout/app.html.heex":
+//
+//     <script>window.userToken = "<%= assigns[:user_token] %>";</script>
+//
+// You will need to verify the user token in the "connect/3" function
+// in "lib/phx_16_web/channels/user_socket.ex":
+//
+//     def connect(%{"token" => token}, socket, _connect_info) do
+//       # max_age: 1209600 is equivalent to two weeks in seconds
+//       case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
+//         {:ok, user_id} ->
+//           {:ok, assign(socket, :user, user_id)}
+//
+//         {:error, reason} ->
+//           :error
+//       end
+//     end
+//
+// Finally, connect to the socket:
+socket.connect()
+
+// Now that you are connected, you can join channels with a topic.
+// Let's assume you have a channel with a topic named `room` and the
+// subtopic is its id - in this case 42:
+let channel = socket.channel("room:42", {})
+channel.join()
+  .receive("ok", resp => { console.log("Joined successfully", resp) })
+  .receive("error", resp => { console.log("Unable to join", resp) })
+
+export default socket
diff --git a/lib/phx_16_web/channels/user_socket.ex b/lib/phx_16_web/channels/user_socket.ex
new file mode 100644
index 0000000..354337e
--- /dev/null
+++ b/lib/phx_16_web/channels/user_socket.ex
@@ -0,0 +1,51 @@
+defmodule Phx16Web.UserSocket do
+  use Phoenix.Socket
+
+  # A Socket handler
+  #
+  # It's possible to control the websocket connection and
+  # assign values that can be accessed by your channel topics.
+
+  ## Channels
+  # Uncomment the following line to define a "room:*" topic
+  # pointing to the `Phx16Web.RoomChannel`:
+  #
+  # channel "room:*", Phx16Web.RoomChannel
+  #
+  # To create a channel file, use the mix task:
+  #
+  #     mix phx.gen.channel Room
+  #
+  # See the [`Channels guide`](https://hexdocs.pm/phoenix/channels.html)
+  # for futher details.
+
+
+  # Socket params are passed from the client and can
+  # be used to verify and authenticate a user. After
+  # verification, you can put default assigns into
+  # the socket that will be set for all channels, ie
+  #
+  #     {:ok, assign(socket, :user_id, verified_user_id)}
+  #
+  # To deny connection, return `:error`.
+  #
+  # See `Phoenix.Token` documentation for examples in
+  # performing token verification on connect.
+  @impl true
+  def connect(_params, socket, _connect_info) do
+    {:ok, socket}
+  end
+
+  # Socket id's are topics that allow you to identify all sockets for a given user:
+  #
+  #     def id(socket), do: "user_socket:#{socket.assigns.user_id}"
+  #
+  # Would allow you to broadcast a "disconnect" event and terminate
+  # all active sockets and channels for a given user:
+  #
+  #     Elixir.Phx16Web.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+  #
+  # Returning `nil` makes this socket anonymous.
+  @impl true
+  def id(_socket), do: nil
+end

Trying out WebSockets

Adding configuration

First, carry out each of the instructions that appeared after running mix phx.gen.socket:

Add the socket handler to your `lib/phx_16_web/endpoint.ex`, for example:

    socket "/socket", Phx16Web.UserSocket,
      websocket: true,
      longpoll: false

For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:

    import "./user_socket.js"

The diff after the changes is as follows:

diff --git a/assets/js/app.js b/assets/js/app.js
index 9eabcff..0e163db 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -4,7 +4,7 @@ import "../css/app.css"
 
 // If you want to use Phoenix channels, run `mix help phx.gen.channel`
 // to get started and then uncomment the line below.
-// import "./user_socket.js"
+import "./user_socket.js"
 
 // You can include dependencies in two ways.
 //
diff --git a/lib/phx_16_web/endpoint.ex b/lib/phx_16_web/endpoint.ex
index 03ae366..966bd7c 100644
--- a/lib/phx_16_web/endpoint.ex
+++ b/lib/phx_16_web/endpoint.ex
@@ -10,7 +10,12 @@ defmodule Phx16Web.Endpoint do
     signing_salt: "tB4UeyNi"
   ]
 
-  socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
+  socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
+
+  socket("/socket", Phx16Web.UserSocket,
+    websocket: true,
+    longpoll: false
+  )
 
   # Serve at "/" the static files from "priv/static" directory.
   #
@@ -25,7 +30,7 @@ defmodule Phx16Web.Endpoint do
   # Code reloading can be explicitly enabled under the
   # :code_reloader configuration of your endpoint.
   if code_reloading? do
-    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+    socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
     plug Phoenix.LiveReloader
     plug Phoenix.CodeReloader
   end

Creating channels

Next, let's create a WebSocket channel.

The following comments are generated in lib/phx_16_web/channels/user_socket.ex:

  ## Channels
  # Uncomment the following line to define a "room:*" topic
  # pointing to the `Phx16Web.RoomChannel`:
  #
  # channel "room:*", Phx16Web.RoomChannel
  #
  # To create a channel file, use the mix task:
  #
  #     mix phx.gen.channel Room
  #
  # See the [`Channels guide`](https://hexdocs.pm/phoenix/channels.html)
  # for futher details.

So, uncomment the channel "room:*", Phx16Web.RoomChannel part and run mix phx.gen.channel Room.

$ mix phx.gen.channel Room
* creating lib/phx_16_web/channels/room_channel.ex
* creating test/phx_16_web/channels/room_channel_test.exs

Add the channel to your `lib/phx_16_web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", Phx16Web.RoomChannel

The handler mentioned at the end is already configured as described earlier, so we're good to go. The channel is generated in lib/phx_16_web/channels/room_channel.ex as follows:

diff --git a/lib/phx_16_web/channels/room_channel.ex b/lib/phx_16_web/channels/room_channel.ex
new file mode 100644
index 0000000..99427ce
--- /dev/null
+++ b/lib/phx_16_web/channels/room_channel.ex
@@ -0,0 +1,32 @@
+defmodule Phx16Web.RoomChannel do
+  use Phx16Web, :channel
+
+  @impl true
+  def join("room:lobby", payload, socket) do
+    if authorized?(payload) do
+      {:ok, socket}
+    else
+      {:error, %{reason: "unauthorized"}}
+    end
+  end
+
+  # Channels can be used in a request/response fashion
+  # by sending replies to requests from the client
+  @impl true
+  def handle_in("ping", payload, socket) do
+    {:reply, {:ok, payload}, socket}
+  end
+
+  # It is also common to receive messages from the client and
+  # broadcast to everyone in the current topic (room:lobby).
+  @impl true
+  def handle_in("shout", payload, socket) do
+    broadcast socket, "shout", payload
+    {:noreply, socket}
+  end
+
+  # Add authorization logic here as required.
+  defp authorized?(_payload) do
+    true
+  end
+end

Connecting with a WebSocket client

Now that everything is ready, let's connect to the WebSocket from the CLI. We'll use wscat.

First, start the WebSocket server.

$ iex -S mix phx.server

Then, launch wscat in another terminal.

$ wscat -c ws://localhost:4000/socket/websocket
Connected (press CTRL+C to quit)
>

First, let's try joining a topic.

> {"topic":"room:lobby","ref":1, "payload":{},"event":"phx_join"}
< {"event":"phx_reply","payload":{"response":{},"status":"ok"},"ref":1,"topic":"room:lobby"}

On the WebSocket server side, you will see a log like this:

[info] JOINED room:lobby in 32µs
  Parameters: %{}

Now, let's try sending a message. By the way, the handler for the ping event in the Room channel is implemented as follows:

def handle_in("ping", payload, socket) do
  {:reply, {:ok, payload}, socket}
end

Send the ping event with an appropriate payload as follows:

> {"topic":"room:lobby","ref":1, "payload":{"body": {"message": "Hello, WebSocket!"}},"event":"ping"}
< {"event":"phx_reply","payload":{"response":{"body":{"message":"Hello, WebSocket!"}},"status":"ok"},"ref":1,"topic":"room:lobby"}

It seems to be working correctly. On the WebSocket server side, you will see a log like this:

[debug] HANDLED ping INCOMING ON room:lobby (Phx16Web.RoomChannel) in 50µs
  Parameters: %{"body" => %{"message" => "Hello, WebSocket!"}}

Conclusion

From Phoenix 1.6, the mix phx.new task no longer generates WebSocket-related settings and files. However, we have confirmed that the same results as in the 1.5 series can be quickly achieved using mix phx.gen.socket.

Discussion