defmodule Dialup.UserSessionProcess do @moduledoc false use GenServer @session_timeout :timer.minutes(5) # DynamicSupervisor から起動される(引数はタプル) # registry_key: タブごとの一意キー(tab_id || session_id) # session_id: Cookie由来のセッションID(ETS永続化に使用) def start_link({socket_pid, app_module, session_id, registry_key}) do GenServer.start_link(__MODULE__, %{ socket_pid: socket_pid, app_module: app_module, session_id: session_id, registry_key: registry_key }) end def init_session(pid, path), do: GenServer.cast(pid, {:init, path}) def navigate(pid, path), do: GenServer.cast(pid, {:navigate, path}) def event(pid, event, value), do: GenServer.cast(pid, {:event, event, value}) def session_id(pid), do: GenServer.call(pid, :get_session_id) def take_over(pid, new_socket_pid), do: GenServer.cast(pid, {:take_over, new_socket_pid}) def reconnect(pid, path), do: GenServer.cast(pid, {:reconnect, path}) @impl GenServer def init(%{socket_pid: socket_pid, app_module: app_module, session_id: session_id, registry_key: registry_key}) do {:ok, _} = Registry.register(Dialup.SessionRegistry, registry_key, nil) ref = Process.monitor(socket_pid) base_state = %{ path: nil, socket_pid: socket_pid, app_module: app_module, session_id: session_id, session: %{}, session_keys: MapSet.new(), assigns: %{}, params: %{}, subscriptions: [], monitor_ref: ref, timeout_ref: nil } state = if uses_ets_store?(app_module) do case Dialup.SessionStore.restore(session_id) do {:ok, %{session: session, assigns: assigns, path: path}} -> session_keys = MapSet.new(Map.keys(session)) %{base_state | session: session, session_keys: session_keys, assigns: assigns, path: path} :error -> base_state end else base_state end {:ok, state} end @impl GenServer def handle_call(:get_session_id, _from, state) do {:reply, state.session_id, state} end # 初回接続:layout.mount → session、page.mount → assigns @impl GenServer def handle_cast({:init, path}, state) do try do params = state.app_module.path_params(path) Process.put(:dialup_subscriptions, []) {session, session_keys} = mount_session(path, state.app_module) assigns = mount_page(path, params, session, session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) {:noreply, %{state | path: path, params: params, session: session, session_keys: session_keys, assigns: assigns, subscriptions: subs}} rescue e -> new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) {:noreply, new_state} end end # 再接続:プロセスが生存していれば現在のstateで再描画、タイムアウト済みならフルmount @impl GenServer def handle_cast({:reconnect, path}, state) do if state.path == nil do try do Process.put(:dialup_subscriptions, []) params = state.app_module.path_params(path) {session, session_keys} = mount_session(path, state.app_module) assigns = mount_page(path, params, session, session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) new_state = update_page(%{state | path: path, params: params, session: session, session_keys: session_keys, assigns: assigns, subscriptions: subs}) {:noreply, new_state} rescue e -> new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) {:noreply, new_state} end else {:noreply, update_page(state)} end end # 再接続時のsocket_pid引き継ぎ @impl GenServer def handle_cast({:take_over, new_socket_pid}, state) do if state.monitor_ref, do: Process.demonitor(state.monitor_ref, [:flush]) if state.timeout_ref, do: Process.cancel_timer(state.timeout_ref) ref = Process.monitor(new_socket_pid) {:noreply, %{state | socket_pid: new_socket_pid, monitor_ref: ref, timeout_ref: nil}} end @impl GenServer def handle_cast({:event, event, value}, state) do case state.app_module.page_for(state.path) do nil -> {:noreply, state} page_module -> merged = merge_for_render(state) start_time = Dialup.Telemetry.event_start(event, state.path) try do result = case page_module.handle_event(event, value, merged) do {:noreply, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) {:noreply, %{state | session: new_session, assigns: new_assigns}} {:update, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = update_page(%{state | session: new_session, assigns: new_assigns}) {:noreply, new_state} {:patch, target, rendered, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) html = to_html(rendered) payload = Jason.encode!(%{target: target, html: html}) send(state.socket_pid, {:send_html, payload}) {:noreply, %{state | session: new_session, assigns: new_assigns}} {:redirect, path, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) {:noreply, do_navigate(path, %{state | session: new_session, assigns: new_assigns})} {:push_event, event_name, event_payload, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = %{state | session: new_session, assigns: new_assigns} send_push_event(new_state, event_name, event_payload) {:noreply, new_state} end Dialup.Telemetry.event_stop(start_time, event, state.path) result rescue e -> Dialup.Telemetry.event_exception(start_time, event, state.path, :error, e, __STACKTRACE__) send_error(state, e, __STACKTRACE__) {:noreply, state} end end end # ページ遷移:session は保持、assigns をリセットして page.mount を呼ぶ @impl GenServer def handle_cast({:navigate, path}, state) do {:noreply, do_navigate(path, state)} end # ホットリロード:ファイル変更時に現在の state で再描画 @impl GenServer def handle_info(:dialup_reload, state) do try do {:noreply, update_page(state)} rescue e -> send_error(state, e, __STACKTRACE__) {:noreply, state} end end # WebSocketプロセスが落ちたら即死せず、タイムアウトまで生存する @impl GenServer def handle_info({:DOWN, ref, :process, _pid, _reason}, %{monitor_ref: ref} = state) do timeout_ref = Process.send_after(self(), :session_timeout, @session_timeout) {:noreply, %{state | socket_pid: nil, monitor_ref: nil, timeout_ref: timeout_ref}} end def handle_info(:session_timeout, state) do if uses_ets_store?(state.app_module) and state.path do Dialup.SessionStore.save(state.session_id, state.session, state.assigns, state.path) end {:stop, :normal, state} end # その他のメッセージは現在のページモジュールの handle_info/2 に委譲する def handle_info(msg, state) do case state.app_module.page_for(state.path) do nil -> {:noreply, state} page_module -> merged = merge_for_render(state) try do case page_module.handle_info(msg, merged) do {:noreply, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) {:noreply, %{state | session: new_session, assigns: new_assigns}} {:update, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) {:noreply, update_page(%{state | session: new_session, assigns: new_assigns})} {:patch, target, rendered, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) html = to_html(rendered) payload = Jason.encode!(%{target: target, html: html}) send(state.socket_pid, {:send_html, payload}) {:noreply, %{state | session: new_session, assigns: new_assigns}} {:redirect, path, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) {:noreply, do_navigate(path, %{state | session: new_session, assigns: new_assigns})} {:push_event, event_name, event_payload, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = %{state | session: new_session, assigns: new_assigns} send_push_event(new_state, event_name, event_payload) {:noreply, new_state} end rescue e -> send_error(state, e, __STACKTRACE__) {:noreply, state} end end end # session を保持しつつ新しいパスに遷移する(navigate / redirect 共通) defp do_navigate(path, state) do start_time = Dialup.Telemetry.navigate_start(path) try do Enum.each(state.subscriptions, fn {pubsub, topic} -> Phoenix.PubSub.unsubscribe(pubsub, topic) end) Process.put(:dialup_subscriptions, []) params = state.app_module.path_params(path) assigns = mount_page(path, params, state.session, state.session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) result = update_page(%{state | path: path, params: params, assigns: assigns, subscriptions: subs}) Dialup.Telemetry.navigate_stop(start_time, path) result rescue e -> Dialup.Telemetry.navigate_exception(start_time, path, :error, e, __STACKTRACE__) new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) new_state end end defp send_push_event(state, event_name, event_payload) do if state.socket_pid do html = render(state) payload = %{html: html, path: state.path, push_event: event_name, payload: event_payload} payload = put_title(payload, state) send(state.socket_pid, {:send_html, Jason.encode!(payload)}) end end defp send_error(state, exception, stacktrace) do if state.socket_pid do html = render_error_page(500, state, exception, stacktrace) payload = Jason.encode!(%{html: html, path: state.path || "/"}) send(state.socket_pid, {:send_html, payload}) end end defp render_error_page(status, state, exception \\ nil, stacktrace \\ nil) do case state.app_module.error_page_for(state.path) do nil -> default_error_html(status, exception, stacktrace) error_module -> error_assigns = %{ status: status, message: if(exception, do: Exception.message(exception), else: status_message(status)) } error_assigns = if exception && Mix.env() == :dev do error_assigns |> Map.put(:exception, exception) |> Map.put(:stacktrace, Exception.format(:error, exception, stacktrace || [])) else error_assigns end try do error_html = error_module.render(status, error_assigns) |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() use_layout = if function_exported?(error_module, :__layout__, 0), do: error_module.__layout__(), else: true if use_layout do layouts = state.app_module.layouts_for(state.path || "/") render_assigns = merge_for_render(state) Dialup.Router.render_with_layouts_raw(error_html, error_module, layouts, render_assigns) else wrap_error_with_scope(error_html, error_module) end rescue _ -> default_error_html(status, exception, stacktrace) end end end defp wrap_error_with_scope(html, error_module) do if function_exported?(error_module, :__css_scope__, 0) do case error_module.__css_scope__() do nil -> html scope -> css = error_module.__css__() || "" css_tag = if css != "", do: ~s(), else: "" css_tag <> ~s(
#{formatted_trace}
#{escaped}
) end end) formatted = lines |> Enum.map(fn line -> escaped = escape_html(line) cond do String.contains?(line, "(dialup") or String.contains?(line, "(elixir") or String.contains?(line, "(stdlib") -> ~s(#{escaped}) String.contains?(line, ".ex:") -> ~s(#{escaped}) String.starts_with?(String.trim(line), "**") -> ~s(#{escaped}) true -> escaped end end) |> Enum.join("\n") {file_line, formatted} end defp escape_html(str) do str |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") end # layout.mount を順番に呼び、session と session_keys を構築する # 親レイアウトの結果が子レイアウトの mount に渡される defp mount_session(path, app_module) do layouts = app_module.layouts_for(path) Enum.reduce(layouts, {%{}, MapSet.new()}, fn layout_mod, {session, keys} -> {:ok, new_session} = layout_mod.mount(session) new_keys = MapSet.new(Map.keys(new_session)) {new_session, MapSet.union(keys, new_keys)} end) end # page.mount を呼び、session キーを除いた純粋な page assigns を返す defp mount_page(path, params, session, session_keys, app_module) do case app_module.page_for(path) do nil -> %{} page_module -> # page.mount は session を reads として受け取れる(current_user 等を参照可能) {:ok, result} = page_module.mount(params, session) # session キーを除いた部分だけが page assigns Map.drop(result, MapSet.to_list(session_keys)) end end # session と assigns を合成して render 用 assigns を作る defp merge_for_render(state) do state.session |> Map.merge(state.assigns) |> Map.put(:params, state.params) |> Map.put(:current_path, state.path) end # handle_event/handle_info の返り値を session と assigns に分割する defp split_assigns(merged, session_keys) do new_session = Map.take(merged, MapSet.to_list(session_keys)) new_assigns = Map.drop(merged, MapSet.to_list(session_keys) ++ [:params]) {new_session, new_assigns} end defp render(state) do render_assigns = merge_for_render(state) case state.app_module.dispatch(state.path, render_assigns) do {:ok, html} -> html {:error, :not_found} -> render_error_page(404, state) end end defp put_title(payload, state) do case state.app_module.page_for(state.path) do nil -> payload page_module -> merged = merge_for_render(state) case page_module.page_title(merged) do nil -> payload title -> Map.put(payload, :title, title) end end end defp uses_ets_store?(app_module) do function_exported?(app_module, :__session_store__, 0) and app_module.__session_store__() == :ets end defp to_html(rendered) when is_binary(rendered), do: rendered defp to_html(rendered) do rendered |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() end defp update_page(state) do if state.socket_pid do html = render(state) payload = %{html: html, path: state.path} payload = put_title(payload, state) send(state.socket_pid, {:send_html, Jason.encode!(payload)}) end state end end