Files
Beam/lib/forum/ws_handler.ex
2026-06-10 11:51:56 -05:00

111 lines
3.4 KiB
Elixir

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