defmodule Drab do @moduledoc """ Drab allows to query and manipulate the User Interface directly from the Phoenix server backend. All Drab functions (callbacks and event handlers) should be placed in a module called a 'commander'. It is very similar to controller, but it does not render any pages - it works with the live page instead. For example, pages generated by `DrabExample.PageController` are handled by commander with the corresponding name, in this case `DrabExample.PageCommander`: defmodule DrabExample.PageCommander do use Drab.Commander onload :page_loaded # Drab Callbacks def page_loaded(socket) do set_prop socket, "div.jumbotron h2", innerHTML: "This page has been DRABBED" end # Drab Events defhandler button_clicked(socket, sender) do set_prop socket, this(sender), innerText: "already clicked" end end More on commander is in `Drab.Commander` documentation. Drab handler are launched from the client (browser) side by running javascript function [`Drab.exec_elixir()`](Drab.Client.html#module-drab-exec_elixir-elixir_function_name-argument), or defining the handler function name directly in the html: More on event handlers in `Drab.Core` documentation. ## Debugging Drab in IEx When started with iex (`iex -S mix phoenix.server`) Drab shows the helpful message on how to debug its functions: Started Drab for /drab/docs, handling events in DrabPoc.DocsCommander You may debug Drab functions in IEx by copy/paste the following: import Drab.{Core, Query, Modal, Waiter} socket = Drab.get_socket(pid("0.443.0")) Examples: socket |> select(:htmls, from: "h4") socket |> exec_js("alert('hello from IEx!')") socket |> alert("Title", "Sure?", buttons: [ok: "Azaliż", cancel: "Poniechaj"]) All you need to do is to copy/paste the line with `socket = ...` and now you can run Drab function directly from IEx, observing the results on the running browser in the realtime. ## Handling Exceptions Drab intercepts all exceptions from event handler function and let it die, but before it presents the error message in the logs and an alert for a user on the page. By default it is just an `alert()`, but you can easly override it by creating the template in the `priv/templates/drab/drab.error_handler.js` folder with your own javascript presenting the message. You may use the local variable `message` there to get the exception description, like: alert(<%= message %>); ## Modules Drab is modular. You may choose which modules to use in the specific Commander by using `:module` option in `use Drab.Commander` directive. By default, `Drab.Live` and `Drab.Element` are loaded, but you may override it using `modules` option with `use Drab.Commander` directive. Every module must have the corresponding javascript template, which is added to the client code in case the module is loaded. `Drab.Core` module is always loaded. ## Learnig Drab There is a [tutorial/demo page](https://tg.pl/drab). The point to start reading docs should be `Drab.Core`. """ require Logger use GenServer @type t :: %Drab{ store: map, session: map, commander: atom, commanders: list, controller: atom, socket: Phoenix.Socket.t(), priv: map } defstruct store: %{}, session: %{}, commander: nil, commanders: [], controller: nil, socket: nil, priv: %{} @doc false @spec start_link(Phoenix.Socket.t()) :: GenServer.on_start() def start_link(socket) do GenServer.start_link(__MODULE__, %Drab{ commander: Drab.get_commander(socket), commanders: Drab.get_commanders(socket), controller: Drab.get_controller(socket) }) end @doc false @spec init(t) :: {:ok, t} def init(state) do Process.flag(:trap_exit, true) {:ok, state} end @doc false @spec terminate(any, t) :: {:noreply, t} def terminate(_reason, %Drab{store: store, session: session, commander: commander} = state) do if commander.__drab__().ondisconnect do # TODO: timeout :ok = apply(commander, commander_config(commander).ondisconnect, [store, session]) end {:noreply, state} end @doc false @spec handle_info(tuple, t) :: {:noreply, t} def handle_info({:EXIT, pid, :normal}, state) when pid != self() do # ignore exits of the subprocesses # Logger.debug "************** #{inspect pid} process exit normal" {:noreply, state} end @doc false def handle_info({:EXIT, pid, :killed}, state) when pid != self() do failed(state.socket, %RuntimeError{message: "Drab Process #{inspect(pid)} has been killed."}) {:noreply, state} end @doc false def handle_info({:EXIT, pid, {reason, stack}}, state) when pid != self() do # subprocess died Logger.error(""" Drab Process #{inspect(pid)} died because of #{inspect(reason)} #{Exception.format_stacktrace(stack)} """) {:noreply, state} end @doc false @spec handle_cast(tuple, t) :: {:noreply, t} def handle_cast({:onconnect, socket, payload}, %Drab{commander: commander} = state) do socket = transform_socket(payload["payload"], socket, state) Drab.Core.save_session( socket, Drab.Core.detokenize_store(socket, payload["drab_session_token"]) ) Drab.Core.save_store(socket, Drab.Core.detokenize_store(socket, payload["drab_store_token"])) Drab.Core.save_socket(socket) onconnect = commander_config(commander).onconnect handle_callback(socket, commander, onconnect) for shared_commander <- state.commanders do onconnect = commander_config(shared_commander).onconnect handle_callback(socket, shared_commander, onconnect) end {:noreply, state} end @doc false def handle_cast({:onload, socket, payload}, %Drab{commander: commander} = state) do socket = transform_socket(payload["payload"], socket, state) onload = commander_config(commander).onload handle_callback(socket, commander, onload) for shared_commander <- state.commanders do onload = commander_config(shared_commander).onload handle_callback(socket, shared_commander, onload) end {:noreply, state} end # casts for update values from the state Enum.each([:store, :session, :socket, :priv], fn name -> msg_name = "set_#{name}" |> String.to_atom() @doc false def handle_cast({unquote(msg_name), value}, state) do new_state = Map.put(state, unquote(name), value) {:noreply, new_state} end end) @doc false # any other cast is an event handler def handle_cast({event_name, socket, payload, event_handler_function, reply_to}, state) do handle_event(socket, event_name, event_handler_function, payload, reply_to, state) end # calls for get values from the state Enum.each([:store, :session, :socket, :priv], fn name -> msg_name = "get_#{name}" |> String.to_atom() @doc false def handle_call(unquote(msg_name), _from, state) do value = Map.get(state, unquote(name)) {:reply, value, state} end end) @spec handle_callback(Phoenix.Socket.t(), atom, atom) :: Phoenix.Socket.t() defp handle_callback(socket, commander, callback) do if callback do # TODO: rethink the subprocess strategies - now it is just spawn_link spawn_link(fn -> try do Process.put(:__drab_event_handler_or_callback, true) apply(commander, callback, [socket]) rescue e -> failed(socket, e) end end) end socket end @spec transform_payload(map, t) :: map defp transform_payload(payload, state) do all_modules = DrabModule.all_modules_for(state.commander.__drab__().modules) # transform payload via callbacks in DrabModules Enum.reduce(all_modules, payload, fn m, p -> m.transform_payload(p, state) end) end @spec transform_socket(map, Phoenix.Socket.t(), t) :: Phoenix.Socket.t() defp transform_socket(payload, socket, state) do all_modules = DrabModule.all_modules_for(state.commander.__drab__().modules) # transform socket via callbacks Enum.reduce(all_modules, socket, fn m, s -> m.transform_socket(s, payload, state) end) end @spec handle_event(Phoenix.Socket.t(), any, atom, map, atom, t) :: {:noreply, t} defp handle_event( socket, _event_name, event_handler_function, payload, reply_to, %Drab{commander: commander_module, controller: controller_module} = state ) do spawn_link(fn -> try do Process.put(:__drab_event_handler_or_callback, true) argument = payload["__additional_argument"] payload = Map.delete(payload, "__additional_argument") arity = if argument, do: 3, else: 2 {commander_module, event_handler} = case event_handler(event_handler_function) do {nil, function} -> raise_if_handler_not_exists(commander_module, function, arity) {module, function} -> raise_if_handler_is_not_public(module, function) end # Warn if handler is not public # TODO: in v0.8.0, all handler must be public unless is_public?(commander_module, event_handler) do Logger.warn(""" handler #{commander_module}.#{event_handler} must be declared as public in the commander. Please use Drab.Commander.public or Drab.Commander.defhandler macro as following: defhandler #{event_handler}(socket, sender) do ... end This warning will become an error in v0.8.0. """) end # Warn if shared commander is not declared # TODO: in v0.8.0, all shared commanders must be declared unless is_commander_declared?(commander_module, controller_module) do Logger.warn(""" shared commander #{commander_module} is not declared in #{controller_module}. Please whitelist all shared commanders in the controller: use Drab.Controller, commanders: [#{commander_module}] This warning will become an error in v0.8.0. """) end payload = transform_payload(payload, state) socket = transform_socket(payload, socket, state) commander_cfg = commander_config(commander_module) # run before_handlers first returns_from_befores = Enum.map(callbacks_for(event_handler, commander_cfg.before_handler), fn callback_handler -> apply(commander_module, callback_handler, [socket, payload]) end) # if ANY of them fail (return false or nil), do not proceed unless Enum.any?(returns_from_befores, &(!&1)) do # run actuall event handler arguments = if argument, do: [socket, payload, argument], else: [socket, payload] returned_from_handler = apply(commander_module, event_handler, arguments) Enum.map(callbacks_for(event_handler, commander_cfg.after_handler), fn callback_handler -> apply(commander_module, callback_handler, [socket, payload, returned_from_handler]) end) end rescue e -> failed(socket, e) after # push reply to the browser, to re-enable controls push_reply(socket, reply_to, commander_module, event_handler_function) end end) {:noreply, state} end @spec event_handler(String.t()) :: {atom | nil, atom} defp event_handler(function_name) do case String.split(function_name, ".") do [function] -> {nil, String.to_existing_atom(function)} module_and_function -> module = module_and_function |> List.delete_at(-1) |> Module.safe_concat() unless Code.ensure_loaded?(module) do raise """ module #{inspect(module)} does not exist. """ end function = module_and_function |> List.last() |> String.to_existing_atom() {module, function} end end @spec raise_if_handler_not_exists(atom, atom, integer) :: {atom, atom} | no_return defp raise_if_handler_not_exists(module, function, arity) do if !({function, arity} in apply(module, :__info__, [:functions])) || is_callback?(module, function) do raise """ handler `#{function}/#{arity}` does not exist. """ end {module, function} end @spec is_callback?(atom, atom) :: boolean defp is_callback?(module, function) do options = apply(module, :__drab__, []) # TODO: group callbacks in compile time callbacks = Map.get(options, :before_handler, []) ++ Map.get(options, :after_handler, []) function in callbacks end @spec raise_if_handler_is_not_public(atom, atom) :: {atom, atom} | no_return defp raise_if_handler_is_not_public(module, function) do unless is_public?(module, function) do raise """ handler #{module}.#{function} is not public. Use `Drab.Commander.public/1` macro to make it executable from any page. """ end {module, function} end @spec is_public?(atom, atom) :: boolean | no_return defp is_public?(module, function) do if {:__drab__, 0} in apply(module, :__info__, [:functions]) do options = apply(module, :__drab__, []) function in Map.get(options, :public_handlers, []) else raise """ #{module} is not a Drab module. """ end end @spec is_commander_declared?(atom, atom) :: boolean defp is_commander_declared?(commander, controller) do cond do commander in Drab.Config.drab_internal_commanders() -> true commander.__drab__().controller == controller -> true {:__drab__, 0} in apply(controller, :__info__, [:functions]) -> commander in controller.__drab__().commanders true -> false end end @spec failed(Phoenix.Socket.t(), Exception.t()) :: :ok defp failed(socket, e) do error = """ Drab Handler failed with the following exception: #{Exception.format_banner(:error, e)} #{Exception.format_stacktrace(System.stacktrace())} """ Logger.error(error) if socket do js = Drab.Template.render_template( "drab.error_handler.js", message: Drab.Core.encode_js(error) ) {:ok, _} = Drab.Core.exec_js(socket, js) end :ok end @spec push_reply(Phoenix.Socket.t(), atom, any, any) :: :ok defp push_reply(socket, reply_to, _, _) do Phoenix.Channel.push(socket, "event", %{ finished: reply_to }) end @doc false # Returns the list of callbacks (before_handler, after_handler) defined in handler_config @spec callbacks_for(atom, list) :: list def callbacks_for(_, []) do [] end @doc false def callbacks_for(event_handler_function, handler_config) do # :uppercase, [{:run_before_each, []}, {:run_before_uppercase, [only: [:uppercase]]}] handler_config |> Enum.map(fn {callback_name, callback_filter} -> case callback_filter do [] -> callback_name [only: handlers] -> if event_handler_function in handlers, do: callback_name, else: false [except: handlers] -> if event_handler_function in handlers, do: false, else: callback_name _ -> false end end) |> Enum.filter(& &1) end # setter and getter functions Enum.each([:store, :session, :socket, :priv], fn name -> get_name = "get_#{name}" |> String.to_atom() update_name = "set_#{name}" |> String.to_atom() @doc false def unquote(get_name)(pid) do GenServer.call(pid, unquote(get_name)) end @doc false def unquote(update_name)(pid, new_value) do GenServer.cast(pid, {unquote(update_name), new_value}) end end) @doc false @spec push_and_wait_for_response(Phoenix.Socket.t(), pid, String.t(), Keyword.t(), Keyword.t()) :: Drab.Core.result() def push_and_wait_for_response(socket, pid, message, payload \\ [], options \\ []) do ref = make_ref() push(socket, pid, ref, message, payload) timeout = options[:timeout] || Drab.Config.get(:browser_response_timeout) receive do {:got_results_from_client, status, ^ref, reply} -> {status, reply} after timeout -> # TODO: message is still in a queue {:timeout, "timed out after #{timeout} ms."} end end @doc false @spec push_and_wait_forever(Phoenix.Socket.t(), pid, String.t(), Keyword.t()) :: Drab.Core.result() def push_and_wait_forever(socket, pid, message, payload \\ []) do push(socket, pid, nil, message, payload) receive do {:got_results_from_client, status, _, reply} -> {status, reply} end end @doc false @spec push(Phoenix.Socket.t(), pid, reference | nil, String.t(), Keyword.t()) :: :ok | no_return def push(socket, pid, ref, message, payload \\ []) do do_push_or_broadcast(socket, pid, ref, message, payload, &Phoenix.Channel.push/3) end @doc false @spec broadcast(Drab.Core.subject(), pid | nil, String.t(), Keyword.t()) :: :ok | no_return def broadcast(subject, pid, message, payload \\ []) def broadcast(%Phoenix.Socket{} = socket, pid, message, payload) do do_push_or_broadcast(socket, pid, nil, message, payload, &Phoenix.Channel.broadcast/3) end def broadcast(subject, _pid, message, payload) when is_binary(subject) do Phoenix.Channel.Server.broadcast( Drab.Config.pubsub(), "__drab:#{subject}", message, Map.new(payload) ) end @doc false def broadcast(topics, _pid, message, payload) when is_list(topics) do for topic <- topics do broadcast(topic, nil, message, payload) end :ok end @spec do_push_or_broadcast( Phoenix.Socket.t() | Drab.Core.subject(), pid | nil, reference | nil, String.t(), Keyword.t(), function ) :: :ok | no_return defp do_push_or_broadcast(socket, pid, ref, message, payload, function) do m = payload |> Enum.into(%{}) |> Map.merge(%{sender: tokenize(socket, {pid, ref})}) function.(socket, message, m) end @doc false @spec tokenize(Phoenix.Socket.t() | Plug.Conn.t(), term(), String.t()) :: String.t() def tokenize(socket, what, salt \\ "drab token") do Phoenix.Token.sign(socket, salt, what) end @doc false @spec detokenize(Phoenix.Socket.t() | Plug.Conn.t(), String.t(), String.t()) :: term() | no_return def detokenize(socket, token, salt \\ "drab token") do case Phoenix.Token.verify(socket, salt, token, max_age: 86_400) do {:ok, detokenized} -> detokenized {:error, reason} -> # let it die raise "Can't verify the token `#{salt}`: #{inspect(reason)}" end end # returns the commander name for the given controller (assigned in socket) @doc false @spec get_commander(Phoenix.Socket.t()) :: atom def get_commander(socket) do socket.assigns.__commander end # returns the list of shared commanders name for the given controller (assigned in socket) @doc false @spec get_commanders(Phoenix.Socket.t()) :: list def get_commanders(socket) do controller = get_controller(socket) if controller && {:__drab__, 0} in apply(controller, :__info__, [:functions]) do controller.__drab__().commanders else [] end # socket.assigns.__commanders end # returns the controller name used with the socket @doc false @spec get_controller(Phoenix.Socket.t()) :: atom def get_controller(socket) do socket.assigns.__controller end # returns the view name used with the socket @doc false @spec get_view(Phoenix.Socket.t()) :: atom def get_view(socket) do socket.assigns.__view end # returns the drab_pid from socket @doc "Extract Drab PID from the socket" @spec pid(Phoenix.Socket.t()) :: pid def pid(socket) do socket.assigns.__drab_pid end # if module is commander or controller with drab enabled, it has __drab__/0 function with Drab configuration @spec commander_config(atom) :: map defp commander_config(module) do module.__drab__() end # @doc false # def config() do # Drab.Config.config() # end end