Current section
Files
Jump to
Current section
Files
lib/musubi/lifecycle.ex
defmodule Musubi.Lifecycle do
@moduledoc """
Lifecycle hook helpers for Musubi runtime stages.
## Stages
| Stage | Arity | Hook arguments |
| :----------------- | :---- | :-------------------------------------- |
| `:before_command` | 3 | `(command_name, payload, socket)` |
| `:after_command` | 4 | `(command_name, payload, reply, socket)`|
| `:handle_async` | 3 | `(name, async_result, socket)` |
| `:handle_info` | 2 | `(message, socket)` |
| `:after_render` | 2 | `(resolved_elixir_term, socket)` |
| `:after_serialize` | 2 | `(wire_term, socket)` |
`:after_render` runs after `Musubi.Resolver` substitutes child placeholders;
it sees the Elixir-form output (atom keys, structs, atom values).
`:after_serialize` runs after `Musubi.Wire.to_wire/1` converts the resolved
output to wire form (string keys, plain maps, atoms-as-strings).
"""
alias Musubi.Socket
@type stage() ::
:before_command
| :after_command
| :handle_async
| :handle_info
| :after_render
| :after_serialize
@type hook_id() :: term()
@type hook_result() :: {:cont, Socket.t()} | {:halt, Socket.t()} | {:halt, term(), Socket.t()}
@type hook_fun() :: function()
@type hook_entry() :: %{id: hook_id(), fun: hook_fun()}
@type hook_table() :: %{optional(stage()) => [hook_entry()]}
@stages [
:before_command,
:after_command,
:handle_async,
:handle_info,
:after_render,
:after_serialize
]
@stage_arity %{
before_command: 3,
after_command: 4,
handle_async: 3,
handle_info: 2,
after_render: 2,
after_serialize: 2
}
@doc """
Attaches a lifecycle hook for the given stage.
## Examples
iex> socket = %Musubi.Socket{}
iex> socket =
...> Musubi.Lifecycle.attach_hook(socket, :audit, :after_render, fn _output, socket ->
...> {:cont, socket}
...> end)
iex> Musubi.Socket.get_private(socket, :hooks)[:after_render] |> length()
1
"""
@spec attach_hook(Socket.t(), hook_id(), stage(), hook_fun()) :: Socket.t()
def attach_hook(%Socket{} = socket, id, stage, fun)
when stage in @stages and is_function(fun) do
validate_hook_arity!(stage, fun)
hooks = hooks(socket)
stage_hooks = Map.get(hooks, stage, [])
if Enum.any?(stage_hooks, &(&1.id == id)) do
raise ArgumentError, "hook #{inspect(id)} already attached for stage #{inspect(stage)}"
end
next_stage_hooks = List.insert_at(stage_hooks, -1, %{id: id, fun: fun})
put_hooks(socket, Map.put(hooks, stage, next_stage_hooks))
end
@doc """
Detaches a lifecycle hook when one is present.
## Examples
iex> socket =
...> Musubi.Lifecycle.attach_hook(%Musubi.Socket{}, :audit, :after_render, fn _output, socket ->
...> {:cont, socket}
...> end)
iex> socket = Musubi.Lifecycle.detach_hook(socket, :audit, :after_render)
iex> Musubi.Socket.get_private(socket, :hooks)
%{}
"""
@spec detach_hook(Socket.t(), hook_id(), stage()) :: Socket.t()
def detach_hook(%Socket{} = socket, id, stage) when stage in @stages do
hooks = hooks(socket)
stage_hooks = Map.get(hooks, stage, [])
filtered_hooks = Enum.reject(stage_hooks, &(&1.id == id))
if filtered_hooks == stage_hooks do
socket
else
next_hooks =
case filtered_hooks do
[] -> Map.delete(hooks, stage)
_hooks -> Map.put(hooks, stage, filtered_hooks)
end
put_hooks(socket, next_hooks)
end
end
@doc """
Runs every hook registered for a stage until one halts or all continue.
## Examples
iex> socket =
...> Musubi.Lifecycle.attach_hook(%Musubi.Socket{}, :mark, :after_render, fn _output, socket ->
...> {:cont, Musubi.Socket.assign(socket, :seen?, true)}
...> end)
iex> {:cont, socket} = Musubi.Lifecycle.run_hooks(socket, :after_render, [%{title: "Inbox"}], false)
iex> socket.assigns.seen?
true
"""
@spec run_hooks(Socket.t(), stage(), list(), boolean()) ::
{:cont, Socket.t()} | {:halt, Socket.t()} | {:halt, term(), Socket.t()}
def run_hooks(%Socket{} = socket, stage, hook_args, halt_payloads_allowed?)
when stage in @stages and is_list(hook_args) and is_boolean(halt_payloads_allowed?) do
socket
|> hooks()
|> Map.get(stage, [])
|> Enum.reduce_while({:cont, socket}, fn %{fun: fun}, {:cont, current_socket} ->
# credo:disable-for-next-line Credo.Check.Refactor.AppendSingleItem
case apply(fun, hook_args ++ [current_socket]) do
{:cont, %Socket{} = next_socket} ->
{:cont, {:cont, next_socket}}
{:halt, %Socket{} = next_socket} ->
{:halt, {:halt, next_socket}}
{:halt, reply, %Socket{} = next_socket} when halt_payloads_allowed? ->
{:halt, {:halt, reply, next_socket}}
{:halt, _reply, %Socket{}} ->
raise ArgumentError,
"halt payloads are only allowed when halt_payloads_allowed? is true"
other ->
raise ArgumentError, "invalid hook result: #{inspect(other)}"
end
end)
end
@doc """
Returns the supported lifecycle stages in execution order.
## Examples
iex> Musubi.Lifecycle.stages()
[:before_command, :after_command, :handle_async, :handle_info, :after_render, :after_serialize]
"""
@spec stages() :: [stage()]
def stages, do: @stages
@doc """
Returns the required hook function arity for a lifecycle stage.
| Stage | Arity | Hook arguments |
| :----------------- | :---- | :-------------------------------------- |
| `:before_command` | 3 | `(command_name, payload, socket)` |
| `:after_command` | 4 | `(command_name, payload, reply, socket)`|
| `:handle_async` | 3 | `(name, async_result, socket)` |
| `:handle_info` | 2 | `(message, socket)` |
| `:after_render` | 2 | `(resolved_elixir_term, socket)` |
| `:after_serialize` | 2 | `(wire_term, socket)` |
## Examples
iex> Musubi.Lifecycle.stage_arity(:before_command)
3
iex> Musubi.Lifecycle.stage_arity(:after_command)
4
iex> Musubi.Lifecycle.stage_arity(:after_serialize)
2
"""
@spec stage_arity(stage()) :: 2 | 3 | 4
def stage_arity(stage) when stage in @stages do
Map.fetch!(@stage_arity, stage)
end
def stage_arity(stage) do
raise ArgumentError, "unknown lifecycle stage: #{inspect(stage)}"
end
@spec hooks(Socket.t()) :: hook_table()
defp hooks(%Socket{private: private}), do: Map.get(private, :hooks, %{})
@spec validate_hook_arity!(stage(), function()) :: :ok
defp validate_hook_arity!(stage, fun) when is_function(fun) do
expected_arity = stage_arity(stage)
{:arity, actual_arity} = :erlang.fun_info(fun, :arity)
if actual_arity == expected_arity do
:ok
else
raise ArgumentError,
"expected fun arity #{expected_arity} for stage #{inspect(stage)}, got arity #{actual_arity}"
end
end
@spec put_hooks(Socket.t(), hook_table()) :: Socket.t()
defp put_hooks(%Socket{private: private} = socket, hooks) do
%{socket | private: Map.put(private, :hooks, hooks)}
end
end