defmodule Forum.WsHandler do @moduledoc """ One instance of this module runs per connected WebSocket client. Each instance is its own BEAM process — isolated state, isolated crashes. Client messages arrive in `handle_in/2`. Reply with `{:push, frame, state}`, do nothing with `{:ok, state}`, or close with `{:stop, reason, state}`. """ @behaviour WebSock require Logger @impl true def init(opts) do Registry.register(Forum.FormsSubscriberRegistry, :forms, nil) Logger.info("ws connected: user_id=#{opts[:user_id]}") state = %{ message_count: 0, user_id: opts[:user_id], email: opts[:email] } welcome = Jason.encode!(%{ type: "welcome to this session: ", user_id: opts[:user_id] }) schedule_heartbeat() {:push, {:text, welcome}, state} end # Text frame from client. # Supported commands (sent as JSON over the wire): # {"type": "ping"} -> {"type": "pong"} # {"type": "send", "text": "hi"} -> inserts row, returns the row # {"type": "list"} -> returns latest 50 rows # {"type": "get_doc"} -> returns the backend body HTML # {"type": "reload_forms"} -> re-reads forms.html and reconciles runtimes # {"type": "list_processes"} -> returns a snapshot of all live processes # {"type": "list_logs"} -> returns recent request logs # {"type": "list_vm_memory"} -> returns BEAM VM memory categories # Anything else echoes back. @impl true def handle_in({raw, [opcode: :text]}, state) do state = %{state | message_count: state.message_count + 1} response = case Jason.decode(raw) do {:ok, %{"type" => "ping"}} -> %{type: "pong"} {:ok, %{"type" => "send", "text" => text}} when is_binary(text) and text != "" -> row = Forum.DB.insert_message(text) %{type: "inserted", row: row} {:ok, %{"type" => "list"}} -> %{type: "list", rows: Forum.DB.list_messages(limit: 50)} {:ok, %{"type" => "get_doc"}} -> %{type: "doc", html: Forum.Forms.html()} {:ok, %{"type" => "reload_forms"}} -> :ok = Forum.Forms.reload() %{type: "doc", html: Forum.Forms.html()} {:ok, %{"type" => "list_processes"}} -> %{type: "processes", rows: Forum.Processes.list()} {:ok, %{"type" => "list_modules"}} -> %{type: "modules", rows: Forum.Modules.list()} {:ok, %{"type" => "list_logs"}} -> %{type: "logs", rows: Forum.LogStore.list()} {:ok, %{"type" => "list_vm_memory"}} -> %{type: "vm_memory", rows: Forum.VmMemory.list()} _ -> %{type: "echo", raw: raw, count: state.message_count} end {:push, {:text, Jason.encode!(response)}, state} end # Binary frame from client — ignore in this scaffold. def handle_in({_data, [opcode: :binary]}, state), do: {:ok, state} # Messages sent to this process's mailbox from other processes. @impl true def handle_info({:forms_reloaded, html}, state) do {:push, {:text, Jason.encode!(%{type: "doc", html: html})}, state} end def handle_info(_msg, state), do: {:ok, state} def handle_info(:heartbeat, state) do schedule_heartbeat() {:push, {:ping, ""}, state} end defp schedule_heartbeat, do: Process.send_after(self(), :heartbeat, 30_000) @impl true def terminate(reason, _state) do Logger.info("ws disconnected: #{inspect(reason)}") :ok end end