Pushing events directly to Socket without going through (LiveView) Channel

I asked this on community Slack, but I think it could be useful to get more feedback/ideas here.

Objective: I am wanting to push notifications from server → client so I can display a desktop notifications. I am using LiveView, and I’d like to avoid adding a separate Channel or a second LiveView (firm on this).

I’m looking for ideas on how to handle this. It would be ideal if this was just natively built in, but I can’t find it if it does exist. Maybe this is on the roadmap or something, which is one reason I’m publicly asking and sharing my ideas.

I am not trying to solve notifications during server disconnection. That’s a whole can of worms that frankly I’m okay with not worrying about atm.

First Pass: My initial idea is to leverage a mount hook (I’m not using on_mount, but something similar I wrote before that was added) and do something like this:

  defp register_notification_hooks(socket = %{assigns: %{user: user}}) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(Clove.PubSub, Clove.Notifications.user_notification_topic(user))
      attach_hook(socket, :desktop_notification_listener, :handle_info, &__MODULE__.notification_callback/2)
    else
      socket
    end
  end

  def notification_callback({:notifications_new, _notification}, socket) do
    # push_event here
    {:cont, socket}
  end

  def notification_callback(_msg, socket) do
    {:cont, socket}
  end

This would result in an event being pushed to the client. It’s pretty seamless and would work across the entire app due to how my mount code is implemented. It also uses public functions supported by LiveView. But I see a pretty big issue:

Each time that I redirect in the application, the old Channel is torn down and a new one is made. This will run the mount code again and everything ends up correct, but events would be missed in the interim because the process that was subscribed (LiveView.Channel) does not exist for a small period of time. (The transport process exists during the entire transition.)

Second Idea: My next idea is to leverage the Phoenix.Socket transport and directly push events to the client, even if the Channel doesn’t exist. I have prototyped pushing messages, but haven’t wired it all up. The general idea is something like:

  defp register_notification_hooks(socket = %{assigns: %{user: user}}) do
    if connected?(socket) do
      NotificationWatchdog.start_server(transport_pid(socket))
    else
      socket
    end
  end

defmodule NotificationWatchdog do
  # This would be a GenServer that uses Registry to guarantee a unique link per transport_pid
  # Upon init, PubSub.subscribe would be used to register this process to the PubSub
  # The transport_pid would be linked to make sure the processes always exist in unison
  #
  # When the event comes in, the notification would be directly pushed to the transport
  # using something like the function below

  defp push_event(transport_pid, notification) do
    message = %Phoenix.Socket.Message{join_ref: nil, topic: nil, event: "notification", payload: notification}
    send(transport_pid, Phoenix.Socket.V2.JSONSerializer.encode!(message))
  end
end

I can leverage liveSocket.getSocket().onMessage in JavaScript to handle the message.

This should work, and would not disconnect during redirection. It uses internal messages supported by Phoenix.Socket, and the serializer is not publicly exposed from LiveView (so it’s hardcoded). I don’t really like those aspects.

Is there something simpler I’m missing?

6 Likes

If you really want to go this route, you need a separate process. Can be a regular channel, or a sticky LiveView that is on the page for the notifications container. That said, for a complete solution, you’re going to want to handle small disconnects, so a last_seen_id type parameter kept by the client and passed up via connect params is what you’d want to use to fetch the most recent (and missed) notifications. If you have that, then you can use the first solution you proposed where the root LV has a lifecycle hook that handles everything, and no extra process is required.

9 Likes

I ended up going the watchdog + raw push approach, mainly because it worked best in my particular case. That said, I’d recommend people follow the guidance of Chris and not push raw messages over the Socket. It’s entering “at your own risk” territory. I ended up with an additional sidecar process connected to the transport_pid, but did not need an additional connection or Channel.

There’s a lot of things people need to handle (no matter what solution you go with) when you build this type of thing, so it does get a bit involved. Adding handling for things like enabling/disabling notifications, multi-tab ramifications, keeping the server efficient (don’t push / enrich if there’s no one listening), etc. Some of that was handled for me by the client-side Notifications API, but still fairly involved.

That said, I’m happy with how it turned out!

2 Likes