defmodule Skuld.PageMachine do @moduledoc """ LiveView integration for FiberPool.Server — a multi-fiber page machine with bidirectional message passing. ## Usage use Skuld.PageMachine, tag: :checkout, on_yield: &handle_yield/2, on_complete: &handle_complete/2 ## Multi-spindle mode For pages with concurrent spindles, use `/3` callbacks: on_yield: &handle_yield/3 defp handle_yield(:cart, status, socket), do: ... defp handle_yield(:checkout, step, socket), do: ... ## Event routing Events route to spindles by name: def_pipe_event "search", into: :products, before: &start_spinner/1 def_pipe_event "buy", into: :products ## Typed contract For compile-time validation of the entire spindle ↔ LiveView contract, define a contract module with `use Skuld.PageMachine.Contract` and pass it via the `:contract` option: defmodule MyApp.StoreContract do use Skuld.PageMachine.Contract defevent "search", SearchEvent, params: [query: String.t()] defevent "buy", BuyEvent, params: [product: Product.t()] defyield :browsing defyield :results, params: [products: [Product.t()], total: integer()] defyield :checkout, :shipping end use Skuld.PageMachine, contract: MyApp.StoreContract, on_yield: &handle_yield/3 The contract auto-generates `handle_event/3` clauses for each event, so manual `def_pipe_event` declarations are unnecessary (though still available for ad-hoc additions). """ alias Skuld.FiberPool.Server, as: FiberServer @default_tag Module.concat(__MODULE__, Default) @default_assign_key Module.concat(__MODULE__, DefaultAssign) @doc "The default PageMachine tag." def default_tag, do: @default_tag @doc "The default assign key used to look up the PageMachine pid from socket.assigns." def default_assign_key, do: @default_assign_key @doc """ Start a page machine in a separate FiberPool.Server process. Takes a keyword list of `{spindle_key, computation}` pairs: {:ok, pid} = PageMachine.run(products: ProductBrowserSpindle.run(%{})) When a socket is passed as the first argument, the pid is stored automatically in `socket.assigns` under the default assign key (`Skuld.PageMachine.DefaultAssign`): socket = PageMachine.run(socket, products: ProductBrowserSpindle.run(%{})) Multiple spindles can be started at once: PageMachine.run(products: product_comp, checkout: checkout_comp) ## Options - `:tag` — the PMC tag for message routing. Defaults to `Skuld.PageMachine.Default`. Only needed for multi-PMC pages. """ def run(fibers, _opts \\ []) def run(fibers, _opts) when is_list(fibers) do FiberServer.start_link(fibers) end def run(%{assigns: assigns} = socket, fibers) when is_list(fibers) do {:ok, pid} = FiberServer.start_link(fibers) %{socket | assigns: Map.put(assigns, @default_assign_key, pid)} end @doc "Resume a yielded spindle with a value." def resume(pid, fiber_key, value) do FiberServer.resume(pid, fiber_key, value) end @doc "Cancel a running page machine and all its spindles." def cancel(pid) do Process.exit(pid, :normal) end ############################################################################# ## def_pipe_event macros ############################################################################# defmacro def_pipe_event(event, opts \\ []) do generate_clause(event, nil, opts) end defmacro def_pipe_event(event, pattern, opts) do generate_clause(event, pattern, opts) end defp generate_clause(event, pattern, opts) do into = Keyword.get(opts, :into) before = Keyword.get(opts, :before) assign_key = Keyword.get(opts, :assign, @default_assign_key) block = Keyword.get(opts, :do) body = case pattern do nil -> quote do def handle_event(unquote(event), params, socket) do unquote(before_call(before)) Skuld.PageMachine.resume( Map.fetch!(socket.assigns, unquote(assign_key)), unquote(into), {unquote(event), params} ) {:noreply, socket} end end _ -> quote do def handle_event(unquote(event), unquote(pattern), socket) do unquote(before_call(before)) value = unquote(block) Skuld.PageMachine.resume( Map.fetch!(socket.assigns, unquote(assign_key)), unquote(into), value ) {:noreply, socket} end end end body end defp before_call(nil), do: nil defp before_call(callback), do: quote(do: socket = unquote(callback).(socket)) ############################################################################# ## __using__ macro ############################################################################# defmacro __using__(opts) do contract = Keyword.get(opts, :contract) tag = Keyword.get(opts, :tag, @default_tag) on_yield_ref = Keyword.fetch!(opts, :on_yield) on_complete = Keyword.get(opts, :on_complete) on_error = Keyword.get(opts, :on_error) on_cancel = Keyword.get(opts, :on_cancel) on_throw = Keyword.get(opts, :on_throw) yield_arity = callback_arity(on_yield_ref) clauses = [ build_clause( on_yield_ref, yield_arity, quote(do: %Skuld.Comp.ExternalSuspend{value: value}), quote(do: value) ), if(on_error, do: build_clause(on_error, 2, quote(do: {:error, reason}), quote(do: reason)) ), if(on_cancel, do: build_clause( on_cancel, 2, quote(do: %Skuld.Comp.Cancelled{reason: reason}), quote(do: reason) ) ), if(on_throw, do: build_clause( on_throw, 2, quote(do: %Skuld.Comp.Throw{error: error}), quote(do: error) ) ), if(on_complete, do: build_clause(on_complete, 2, quote(do: value), quote(do: value)) ) ] |> Enum.filter(& &1) contract_clauses = if contract do contract = resolve_contract(contract, __CALLER__) contract.__contract_events__() |> Enum.map(fn %{ event: event_name, spindle: spindle, struct_name: struct_name, params: params } -> value = cond do struct_name && params != [] -> struct_module = Module.concat(spindle, struct_name) quote do atom_params = for {key, val} <- params, into: %{}, do: {String.to_existing_atom(key), val} struct(unquote(struct_module), atom_params) end params != [] -> quote(do: params) true -> quote(do: {unquote(event_name), params}) end quote do def handle_event(unquote(event_name), params, socket) do Skuld.PageMachine.resume( Map.fetch!(socket.assigns, @pmc_assign_key), unquote(spindle), unquote(value) ) {:noreply, socket} end end end) else [] end quote do @pmc_tag unquote(tag) @pmc_assign_key unquote(@default_assign_key) import Skuld.PageMachine, only: [def_pipe_event: 1, def_pipe_event: 2, def_pipe_event: 3] (unquote_splicing(clauses)) (unquote_splicing(contract_clauses)) end end defp resolve_contract(contract, caller) do contract = Macro.expand(contract, caller) unless Code.ensure_loaded?(contract) do raise CompileError, description: "Contract module #{inspect(contract)} is not loaded. " <> "Ensure it is compiled before the module using it.", file: caller.file, line: 0 end unless function_exported?(contract, :__contract_events__, 0) do raise CompileError, description: "#{inspect(contract)} does not export __contract_events__/0. " <> "Did you `use Skuld.PageMachine.Contract`?", file: caller.file, line: 0 end contract end defp callback_arity({:&, _, [{:/, _, [_, arity]}]}), do: arity defp callback_arity(_), do: 2 defp build_clause(callback_ref, arity, pattern, value_expr) do callback = callback_ref if arity == 3 do quote do def handle_info( {Skuld.FiberPool.Server, fiber_key, unquote(pattern)}, socket ) do unquote(callback).(fiber_key, unquote(value_expr), socket) end end else quote do def handle_info( {Skuld.FiberPool.Server, @pmc_tag, unquote(pattern)}, socket ) do unquote(callback).(unquote(value_expr), socket) end end end end end