phollyer / elm-phoenix-websocket / Phoenix

This module is a wrapper around the Socket, Channel and Presence modules. It handles all the low level stuff with a simple, but extensive API. It automates a few processes, and generally simplifies working with Phoenix WebSockets.

Once you have installed the package, and followed the simple setup instructions here, configuring this module is as simple as this:

import Phoenix
import Ports.Phoenix as Ports


-- Add the Phoenix Model to your Model

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }


-- Initialize the Phoenix Model

init : Model
init =
    { phoenix = Phoenix.init Ports.config
        ...
    }


-- Add a Phoenix Msg

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...


-- Handle Phoenix Msgs

update : Msg -> Model -> (Model Cmd Msg)
update msg model =
    case msg of
        PhoenixMsg subMsg ->
            let
                (phoenix, phoenixCmd, phoenixMsg) =
                    Phoenix.update subMsg model.phoenix
            in
            case phoenixMsg of
                ...

            _ ->
                ({ model | phoenix = phoenix }
                , Cmd.map PhoenixMsg phoenixCmd
                )

        _ ->
            (model, Cmd.none)


-- Subscribe to receive Phoenix Msgs

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.map PhoenixMsg <|
        Phoenix.subscriptions model.phoenix

Model


type Model

The model that carries the internal state.

This is an opaque type, so use the provided API to interact with it.

Initialising the Model


type alias PortConfig =
{ phoenixSend : { msg : String
, payload : Json.Encode.Value } -> Platform.Cmd.Cmd Msg
, socketReceiver : ({ msg : String
, payload : Json.Encode.Value } -> Msg) -> Platform.Sub.Sub Msg
, channelReceiver : ({ topic : String
, msg : String
, payload : Json.Encode.Value } -> Msg) -> Platform.Sub.Sub Msg
, presenceReceiver : ({ topic : String
, msg : String
, payload : Json.Encode.Value } -> Msg) -> Platform.Sub.Sub Msg 
}

A type alias representing the ports that are needed to communicate with JS.

This is for reference only, you won't need this if you copy this file into your src.

init : PortConfig -> Model

Initialize the Model by providing the ports that enable communication with JS.

The easiest way to provide the ports is to copy this file into your src, and then use its config function as follows:

import Phoenix
import Ports.Phoenix as Ports

init : Model
init =
    { phoenix = Phoenix.init Ports.config
        ...
    }

Update


type Msg

The Msg type that you pass into the update function.

This is an opaque type, for pattern matching see PhoenixMsg.


type SocketState
    = Connecting
    | Connected
    | Disconnecting
    | Disconnected ({ reason : Maybe String, code : Basics.Int, wasClean : Basics.Bool, type_ : String, isTrusted : Basics.Bool })


type SocketMessage
    = StateChange SocketState
    | SocketError String
    | ChannelMessage ({ topic : String, event : String, payload : Json.Encode.Value, joinRef : Maybe String, ref : Maybe String })
    | PresenceMessage ({ topic : String, event : String, payload : Json.Encode.Value })
    | Heartbeat ({ topic : String, event : String, payload : Json.Encode.Value, ref : String })


type alias Topic =
String

A type alias representing the Channel topic id, for example "topic:subTopic".


type alias Event =
String

A type alias representing an event that is sent to, or received from, a Channel.


type alias Payload =
Json.Encode.Value

A type alias representing data that is sent to, or received from, a Channel.


type alias OriginalPayload =
Json.Encode.Value

A type alias representing the original payload that was sent with a push.


type alias PushRef =
Maybe String

A type alias representing the ref set on a push.


type ChannelResponse
    = ChannelError Topic
    | ChannelClosed Topic
    | LeaveOk Topic
    | JoinOk Topic Payload
    | JoinError Topic Payload
    | JoinTimeout Topic OriginalPayload
    | PushOk Topic Event PushRef Payload
    | PushError Topic Event PushRef Payload
    | PushTimeout Topic Event PushRef OriginalPayload


type alias Presence =
{ id : String
, metas : List Json.Encode.Value
, user : Json.Encode.Value
, presence : Json.Encode.Value 
}

A type alias representing a Presence on a Channel.

-- MyAppWeb.MyChannel.ex

def handle_info(:after_join, socket) do
  {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
    online_at: System.os_time(:millisecond)
  })

  push(socket, "presence_state", Presence.list(socket))

  {:noreply, socket}
end

-- MyAppWeb.Presence.ex

defmodule MyAppWeb.Presence do
  use Phoenix.Presence,
    otp_app: :my_app,
    pubsub_server: MyApp.PubSub

  def fetch(_topic, presences) do
    query =
      from u in User,
      where: u.id in ^Map.keys(presences),
      select: {u.id, u}

    users = query |> Repo.all() |> Enum.into(%{})

    for {key, %{metas: metas}} <- presences, into: %{} do
      {key, %{metas: metas, user: users[key]}}
    end
  end
end


type alias PresenceDiff =
{ joins : List Presence
, leaves : List Presence 
}


type PresenceEvent
    = Join Topic Presence
    | Leave Topic Presence
    | State Topic (List Presence)
    | Diff Topic PresenceDiff


type InternalError
    = DecoderError String
    | InvalidMessage String

An InternalError should never happen, but if it does, it is because the JS is out of sync with this package.

If you ever receive this message, please raise an issue.


type PhoenixMsg
    = NoOp
    | SocketMessage SocketMessage
    | ChannelResponse ChannelResponse
    | ChannelEvent Topic Event Payload
    | PresenceEvent PresenceEvent
    | PushTimeoutsSent (List PushConfig)
    | InternalError InternalError

Pattern match on these in your update function.

To handle events that are pushed or broadcast from an Elixir Channel you should pattern match on ChannelEvent.

update : Msg -> Model -> ( Model, Platform.Cmd.Cmd Msg, PhoenixMsg )

import Phoenix

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        PhoenixMsg subMsg ->
            let
                (phoenix, phoenixCmd, phoenixMsg) =
                    Phoenix.update subMsg model.phoenix
            in
            case phoenixMsg of
                ...

            _ ->
                ( { model | phoenix = phoenix }
                , Cmd.map PhoenixMsg phoenixCmd
                )

        _ ->
            (model, Cmd.none)

updateWith : (Msg -> msg) -> { model | phoenix : Model } -> ( Model, Platform.Cmd.Cmd Msg, PhoenixMsg ) -> ( { model | phoenix : Model }, Platform.Cmd.Cmd msg, PhoenixMsg )

Helper function to use with update in order to:

import Phoenix

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        PhoenixMsg subMsg ->
            let
                (newModel, cmd, phoenixMsg) =
                  Phoenix.update subMsg model.phoenix
                        |> Phoenix.updateWith PhoenixMsg model
            in
            case phoenixMsg of
                ...

            _ ->
                (newModel, cmd)

        _ ->
            (model, Cmd.none)

Note: To use this function, Phoenix.Model needs to be stored on field phoenix on the Model.

map : (Model -> model -> model) -> model -> (Msg -> msg) -> ( Model, Platform.Cmd.Cmd Msg, PhoenixMsg ) -> ( model, Platform.Cmd.Cmd msg, PhoenixMsg )

Map the Phoenix Model and Cmd Msg that is generated by the update function onto your model and msg.

import Phoenix

type alias Model =
    { websocket : Phoenix.Model
        ...
    }

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...


update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        PhoenixMsg subMsg ->
            let
                (newModel, cmd, phoenixMsg) =
                    Phoenix.update subMsg model.websocket
                        |> Phoenix.map (\p m -> { m | websocket = p }) model PhoenixMsg
            in
            ...

mapMsg : (Msg -> msg) -> ( Model, Platform.Cmd.Cmd Msg, PhoenixMsg ) -> ( Model, Platform.Cmd.Cmd msg, PhoenixMsg )

Map your msg onto the Phoenix Cmd Msg that is generated by the update function. This is useful if you want to work with the Phoenix Model some more before updating your own Model.

import Phoenix

type alias Model =
    { websocket : Phoenix.Model
        ...
    }

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...


update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        PhoenixMsg subMsg ->
            let
                (phoenix, cmd, phoenixMsg) =
                    Phoenix.update subMsg model.websocket
                        |> Phoenix.mapMsg PhoenixMsg
            in
            ...

Pushing

When pushing an event to a Channel, opening the Socket, and joining the Channel is handled automatically. Pushes will be queued until the Channel has been joined, at which point, any queued pushes will be sent in a batch.

See Connecting to the Socket and Joining a Channel for more details on handling connecting and joining manually.

If the Socket is open and the Channel already joined, the push will be sent immediately.


type RetryStrategy
    = Drop
    | Every Basics.Int
    | Backoff (List Basics.Int) (Maybe Basics.Int)

The retry strategy to use if a push times out.


type alias PushConfig =
{ topic : String
, event : String
, payload : Json.Encode.Value
, timeout : Maybe Basics.Int
, retryStrategy : RetryStrategy
, ref : Maybe String 
}

A type alias representing the config for pushing a message to a Channel.

pushConfig : PushConfig

A helper function for creating a PushConfig.

import Phoenix exposing (pushConfig)

{ pushConfig
| topic = "topic:subTopic"
, event = "hello"
}

push : PushConfig -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Push a message to a Channel.

import Json.Encode as JE
import Phoenix exposing (pushConfig)

Phoenix.push
    { pushConfig
    | topic = "topic:subTopic"
    , event = "event1"
    , payload =
        JE.object
            [("foo", JE.string "bar")]
    }
    model.phoenix

Subscriptions

subscriptions : Model -> Platform.Sub.Sub Msg

Receive Msgs from the Socket, Channels and Phoenix Presence.

import Phoenix

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

type Msg
    = PhoenixMsg Phoenix.Msg
    | ...

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.map PhoenixMsg <|
        Phoenix.subscriptions model.phoenix

Connecting to the Socket

Connecting to the Socket is automatic on the first push to a Channel, and also when a join is attempted. However, if it is necessary to connect before hand, the connect function is available.

connect : Model -> ( Model, Platform.Cmd.Cmd Msg )

Connect to the Socket.

Setting connect options

addConnectOptions : List Socket.ConnectOption -> Model -> Model

Add some ConnectOptions to set on the Socket when it is created.

Note: This will overwrite any like for like ConnectOptions that have already been set.

import Phoenix
import Phoenix.Socket exposing (ConnectOption(..))
import Ports.Phoenix as Ports

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

init : Model
init =
    { phoenix =
        Phoenix.init Ports.config
            |> Phoenix.addConnectOptions
                [ Timeout 7000
                , HeartbeatIntervalMillis 2000
                ]
            |> Phoenix.addConnectOptions
                [ Timeout 5000 ]
        ...
    }

-- List ConnectOption == [ Timeout 5000, HeartbeatIntervalMillis 2000 ]

setConnectOptions : List Socket.ConnectOption -> Model -> Model

Provide the ConnectOptions to set on the Socket when it is created.

Note: This will replace all previously set ConnectOptions.

import Phoenix
import Phoenix.Socket exposing (ConnectOption(..))
import Ports.Phoenix as Ports

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

init : Model
init =
    { phoenix =
        Phoenix.init Ports.config
            |> Phoenix.addConnectOptions
                [ HeartbeatIntervalMillis 2000 ]
            |> Phoenix.setConnectOptions
                [ Timeout 7000 ]
            |> Phoenix.setConnectOptions
                [ Timeout 5000 ]
        ...
    }

-- List ConnectOption == [ Timeout 5000 ]

Sending data when connecting

setConnectParams : Json.Encode.Value -> Model -> Model

Provide some params to send to the connect function at the Elixir end.

import Json.Encode as JE
import Phoenix

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

init : Model
init =
    { phoenix =
        Phoenix.init Ports.config
            |> Phoenix.setConnectParams
                ( JE.object
                    [ ("username", JE.string "username")
                    , ("password", JE.string "password")
                    ]
                )
        ...
    }

Disconnecting from the Socket

disconnect : Maybe Basics.Int -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Disconnect the Socket, maybe providing a status code for the closure.

ConnectOptions and configs etc will remain to make it simpler to connect again. If you want to reset everything use the disconnectAndReset function instead.

disconnectAndReset : Maybe Basics.Int -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Disconnect the Socket, maybe providing a status code for the closure.

This will reset all of the internal model, so information relating to ConnectOptions and configs etc will also be reset.

The only information that will remain is the PortConfig.

Joining a Channel

Joining a Channel is automatic on the first push to the Channel. However, if it is necessary to join before hand, the join function is available.

join : Topic -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Join a Channel referenced by the Topic.

Connecting to the Socket is automatic if it has not already been done.

If the Socket is not open, the join will be queued, and the Socket will try to connect. Once the Socket is open, any queued joins will be attempted.

If the Socket is already open, the join will be attempted immediately.

joinAll : List Topic -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Join a List of Channels.

The joins will be batched together, the order in which the requests will reach their respective Channels at the Elixir end is undetermined.


type alias JoinConfig =
{ topic : String
, payload : Json.Encode.Value
, events : List String
, timeout : Maybe Basics.Int 
}

A type alias representing the optional config to use when joining a Channel.

If a JoinConfig is not set prior to joining a Channel, the defaults will be used.

joinConfig : JoinConfig

A helper function for creating a JoinConfig.

import Phoenix exposing (joinConfig)

{ joinConfig
| topic = "topic:subTopic"
, events = [ "event1", "event2" ]
}

setJoinConfig : JoinConfig -> Model -> Model

Set a JoinConfig to be used when joining a Channel.

import Phoenix exposing (joinConfig)
import Ports.Phoenix as Port

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

init : Model
init =
    { phoenix =
        Phoenix.init Port.config
            |> Phoenix.setJoinConfig
                { joinConfig
                | topic = "topic:subTopic"
                , events = [ "event1", "event2" ]
                }
        ...
    }

Leaving a Channel

leave : Topic -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Leave a Channel referenced by the Topic.

leaveAll : Model -> ( Model, Platform.Cmd.Cmd Msg )

Leave all joined Channels.


type alias LeaveConfig =
{ topic : Topic
, timeout : Maybe Basics.Int 
}

A type alias representing the optional config to use when leaving a Channel.

If a LeaveConfig is not set prior to leaving a Channel, the defaults will be used.

setLeaveConfig : LeaveConfig -> Model -> Model

Set a LeaveConfig to be used when leaving a Channel.

import Phoenix
import Ports.Phoenix as Port

type alias Model =
    { phoenix : Phoenix.Model
        ...
    }

init : Model
init =
    { phoenix =
        Phoenix.init Port.config
            |> Phoenix.setLeaveConfig
                { topic = "topic:subTopic"
                , timeout = Just 5000
                }
        ...
    }

Incoming Events

Setting up incoming events to receive on a Channel can be done when setting a JoinConfig, but if it is necessary to switch events on and off intermittently, then the following functions are available.

addEvent : Topic -> Event -> Model -> Platform.Cmd.Cmd Msg

Add the Event you want to receive from the Channel identified by Topic.

addEvents : Topic -> List Event -> Model -> Platform.Cmd.Cmd Msg

Add the Events you want to receive from the Channel identified by Topic.

dropEvent : Topic -> Event -> Model -> Platform.Cmd.Cmd Msg

Remove an Event you no longer want to receive from the Channel identified by Topic.

dropEvents : Topic -> List Event -> Model -> Platform.Cmd.Cmd Msg

Remove Events you no longer want to receive from the Channel identified by Topic.

Helpers

Socket Information

socketState : Model -> SocketState

The current state of the Socket.

socketStateToString : Model -> String

The current state of the Socket as a String.

isConnected : Model -> Basics.Bool

Whether the Socket is connected or not.

connectionState : Model -> String

The current connection state of the Socket as a String.

disconnectReason : Model -> Maybe String

The reason the Socket disconnected.

endPointURL : Model -> String

The endpoint URL for the Socket.

protocol : Model -> String

The protocol being used by the Socket.

Channels

queuedChannels : Model -> List String

Channels that are queued waiting to join.

channelQueued : Topic -> Model -> Basics.Bool

Determine if a Channel is in the queue to join.

joinedChannels : Model -> List String

Channels that have joined successfully.

channelJoined : Topic -> Model -> Basics.Bool

Determine if a Channel has joined successfully.

topicParts : Topic -> List String

Split a topic into it's component parts.

Pushes

allQueuedPushes : Model -> Dict Topic (List PushConfig)

Pushes that are queued and waiting for their Channel to join before being sent.

queuedPushes : Topic -> Model -> List PushConfig

Retrieve a list of pushes, by Topic, that are queued and waiting for their Channel to join before being sent.

pushQueued : (PushConfig -> Basics.Bool) -> Model -> Basics.Bool

Determine if a Push is in the queue to be sent when its' Channel joins.

pushQueued
    (\push -> push.ref == "custom ref")
    model.phoenix

dropQueuedPush : (PushConfig -> Basics.Bool) -> Model -> Model

Cancel a queued Push that is waiting for its' Channel to join.

dropQueuedPush
    (\push -> push.topic == "topic:subTopic")
    model.phoenix

pushInFlight : (PushConfig -> Basics.Bool) -> Model -> Basics.Bool

Determine if a Push has been sent and is on it's way to the Elixir Channel.

pushInFlight
    (\push -> push.event == "custom_event")
    model.phoenix

pushWaiting : (PushConfig -> Basics.Bool) -> Model -> Basics.Bool

Determine if a Push is waiting to be received by its' Channel.

This means the push could be in any off the following states:

This is useful if you just need to determine if a push is being actioned. Maybe you need to disable/hide a button until the PushOk message has been received?

pushWaiting
    (\push -> push.event == "custom_event")
    model.phoenix

timeoutPushes : Model -> Dict String (List PushConfig)

Pushes that have timed out and are waiting to be sent again in accordance with their RetryStrategy.

Pushes with a RetryStrategy of Drop, won't make it here.

pushTimedOut : (PushConfig -> Basics.Bool) -> Model -> Basics.Bool

Determine if a Push has timed out and will be tried again in accordance with it's RetryStrategy.

pushTimedOut
    (\push -> push.ref == "custom ref")
    model.phoenix

dropTimeoutPush : (PushConfig -> Basics.Bool) -> Model -> Model

Cancel a timed out Push.

dropTimeoutPush
    (\push -> push.topic == "topic:subTopic")
    model.phoenix

This will only work after a push has timed out and before it is re-tried.

pushTimeoutCountdown : (PushConfig -> Basics.Bool) -> Model -> Maybe Basics.Int

Maybe get the number of seconds until a push is retried.

This is useful if you want to show a countdown timer to your users.

dropPush : (PushConfig -> Basics.Bool) -> Model -> Model

Cancel a Push.

This will cancel pushes that are queued to be sent when their Channel joins. It will also prevent pushes that timeout from being re-tried.

Presence Information

presenceState : Topic -> Model -> List Presence

A list of Presences on the Channel referenced by Topic.

presenceDiff : Topic -> Model -> List PresenceDiff

A list of Presence diffs on the Channel referenced by Topic.

presenceJoins : Topic -> Model -> List Presence

A list of Presences that have joined the Channel referenced by Topic.

presenceLeaves : Topic -> Model -> List Presence

A list of Presences that have left the Channel referenced by Topic.

lastPresenceJoin : Topic -> Model -> Maybe Presence

Maybe the last Presence to join the Channel referenced by Topic.

lastPresenceLeave : Topic -> Model -> Maybe Presence

Maybe the last Presence to leave the Channel referenced by Topic.

Batching

batch : List (Model -> ( Model, Platform.Cmd.Cmd Msg )) -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Batch a list of functions together.

import Phoenix exposing (pushConfig)

Phoenix.batch
    [ Phoenix.join "topic:subTopic3"
    , Phoenix.leave "topic:subTopic2"
    , Phoenix.push
        { pushConfig
        | topic = "topic:subTopic1"
        , event = "hello"
        }
    ]
    model.phoenix

batchWithParams : List ( a -> Model -> ( Model, Platform.Cmd.Cmd Msg ), List a ) -> Model -> ( Model, Platform.Cmd.Cmd Msg )

Batch a list of parameters onto their functions.

import Phoenix

Phoenix.batchWithParams
    [ (Phoenix.join, [ "topic:subTopic1", "topic:subTopic2" ])
    , (Phoenix.leave, [ "topic:subTopic3", "topic:subTopic4" ])
    , (Phoenix.push, [ pushConfig1, pushConfig2, pushConfig3 ])
    ]
    model.phoenix

Logging

Here you can log data to the console, and activate and deactive the socket's logger, but be warned, there is no safeguard when you compile such as you get when you use Debug.log. Be sure to deactive the logging before you deploy to production.

However, the ability to easily toggle logging on and off leads to a possible use case where, in a deployed production environment, an admin is able to see all the logging, while regular users do not.

log : String -> String -> Json.Encode.Value -> Model -> Platform.Cmd.Cmd Msg

Log some data to the console.

import Json.Encode as JE

log "info" "foo"
    (JE.object
        [ ( "bar", JE.string "foo bar" ) ]
    )
    model.phoenix

-- info: foo {bar: "foo bar"}

In order to receive any output in the console, you first need to activate the socket's logger. There are two ways to do this. You can use the startLogging function, or you can set the Logger True ConnectOption.

import Phoenix
import Phoenix.Socket exposing (ConnectOption(..))
import Ports.Phoenix as Ports

init : Model
init =
    { phoenix =
        Phoenix.init Ports.config
            |> Phoenix.setConnectOptions
                [ Logger True ]
    ...
    }

startLogging : Model -> Platform.Cmd.Cmd Msg

Activate the socket's logger function. This will log all messages that the socket sends and receives.

stopLogging : Model -> Platform.Cmd.Cmd Msg

Deactivate the socket's logger function.