Scripting

Sometimes, it's really nice to safely play and experiment in your local development environment.

In this tutorial we'll cover how to use the synchronous await_* family of functions to write scripts for interacting with Phoenix.Channels in an IEx session or in a mix run script.exs script. In this tutorial, unlike others, we simply walk through a script from top to bottom, as opposed to modifying an existing client.

For the sake of ease of development and for you, the reader, this repository runs a phoenix server with a socket endpoint on localhost:4000 in dev-mode.

To prepare for this tutorial, clone the NFIBrokerage/slipstream repository, install the necessary Elixir and Erlang versions with asdf install (see more about asdf). Then start an IEx server with MIX_ENV=dev iex -S mix. The .iex.exs script in the root of this repository will start the phoenix endpoint on localhost:4000 as the IEx session begins.

$ iex -S mix
Erlang/OTP 23 [erts-11.1] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe]

Interactive Elixir (1.11.2) - press Ctrl+C to exit (type h() ENTER for help)
20:13:38.963 [info] Running SlipstreamWeb.Endpoint with cowboy 2.8.0 at 0.0.0.0:4000 (http)
20:13:38.967 [info] Access SlipstreamWeb.Endpoint at http://localhost:4000
iex(1)>

Tutorial

To begin, let's import some functions from Slipstream that we'll use to establish and interact with our connection.

iex> import Slipstream
iex> import Slipstream.Socket

The Slipstream module defines most of the functions we'll use to open and interact with a connection while Slipstream.Socket provides helper functions to interact with our socket (a Slipstream.Socket.t/0).

Let's connect!

iex> socket = connect!(uri: "ws://localhost:4000/socket/websocket") |> await_connect!
#Slipstream.Socket<assigns: %{}, ...>

We connect to the websocket endpoint for SlipstreamWeb.Endpoint. The endpoint has a socket/3 definition like so:

socket("/socket", SlipstreamWeb.UserSocket,
  websocket: true,
  longpoll: false
)

Meaning that we can find the websocket endpoint at /socket/websocket. Note also that we've changed the scheme of the URI from http to ws.

Upon connection we should also see a logger message from the SlipstreamWeb.Endpoint saying that a client has connected to the socket.

20:20:59.874 [info] CONNECTED TO SlipstreamWeb.UserSocket in 60µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"vsn" => "2.0.0"}

But why do we need await_connect!/1 in this scenario? The connect/2, join/3, leave/2, etc. functions are asynchronous requests to connect, join, and leave, respectively. The await_* functions synchronously block until the responses to those requests are received.

Now that we're connected, let's join a topic! SlipstreamWeb.UserSocket defines a channel on the "rooms:lobby" topic like so

channel("rooms:lobby", SlipstreamWeb.InteractiveChannel)

So lets connect to that:

iex> topic = "rooms:lobby"
iex> socket = join(socket, topic, %{"fizz" => "buzz"}) |> await_join!(topic)
#Slipstream.Socket<assigns: %{}, ...>

And we should see a message from the endpoint about the join:

20:38:35.208 [info] JOINED rooms:lobby in 16µs
  Parameters: %{"fizz" => "buzz"}

And now let's push a message to a Phoenix.Channel.handle_in/3 written like so

# just swallows the request
def handle_in("quicksand", _params, socket) do
  {:noreply, socket}
end
iex> push!(socket, topic, "quicksand", %{"a" => "b"})
"2"

Why no await_push function for push!/4? And what's with the "2" as the return? push!/4 (and push/5) are a bit different from the other functions. Pushes are completely asynchronous as the Phoenix.Channel does not have to reply to any push. The "2" that got returned can be used to find replies, however.

Let's push to a Phoenix.Channel.handle_in/3 clause that does reply

# responds to the request
def handle_in("ping", _params, socket) do
  {:reply, {:ok, %{"pong" => "pong"}}, socket}
end
iex> push!(socket, topic, "ping", %{}) |> await_reply!()
{:ok, %{"ping" => "pong"}}

Now we're using await_reply!/1, a function that synchronously waits for a push to receive a reply. But how does it know which push it is waiting for? The "2" returned in the above push!/4 example is what is called in the implementation of Slipstream a "ref." Refs are passed between the client and server to signify which message a reply is referencing. E.g. if a join request sends a ref of "1", the Phoenix.Channel will write a reply to that ref telling the client whether or not the join has been accepted. await_reply!/1 uses the ref returned from push!/4 to find the reply to that push.

Now we've pushed a message and seen a reply to a push, but what about receiving a message that isn't a reply?

# responds, but not with a reply
# just an async send
def handle_in("push to me", _params, socket) do
  push(socket, "foo", %{"bar" => "baz"})

  {:noreply, socket}
end

This Phoenix.Channel.handle_in/3 clause will trigger a Phoenix.Channel.push/3 to our client. In a module-based client, we would typically handle this with Slipstream.handle_message/4, but how can we await these messages in a script?

Let push a message to this handle_in

iex> push!(socket, topic, "push to me", %{})
iex> flush
{:__slipstream_event__,
 %Slipstream.Events.MessageReceived{
   event: "foo",
   payload: %{"bar" => "baz"},
   topic: "rooms:lobby"
 }}

We can see that the IEx process receives a message when we flush/0. We can await and decode this message into a more digestible format with the Slipstream.await_message!/4 macro. This macro takes patterns as its arguments for the topic, event, and payload, so we can selectively match messages with match patterns.

iex> push!(socket, topic, "push to me", %{})
iex> await_message!(^topic, "foo", %{"bar" => "baz"})
{"rooms:lobby", "foo", %{"bar" => "baz"}}

But do we need to know the exact message we're awaiting ahead of time? No, we can match all incoming messages like so

iex> push!(socket, topic, "push to me", %{})
iex> await_message!(_, _, _)
{"rooms:lobby", "foo", %{"bar" => "baz"}}

Finally, let's leave the topic and disconnect, cleaning up our connection cleanly.

iex> socket = leave(socket, topic) |> await_leave!(topic)
#Slipstream.Socket<assigns: %{}, ...>
iex> socket |> disconnect() |> await_disconnect!
#Slipstream.Socket<assigns: %{}, ...>