defmodule Nex.Handler do @moduledoc """ Request handler that dispatches to Pages and API modules. """ import Plug.Conn require Logger @doc "Handle incoming request" def handle(conn) do # Register cleanup callback to clear process dictionary after response conn = register_before_send(conn, fn conn -> Nex.Store.clear_process_dictionary() conn end) try do method = conn.method |> String.downcase() |> String.to_atom() path = conn.path_info cond do # Live reload WebSocket endpoint path == ["nex", "live-reload-ws"] -> WebSockAdapter.upgrade(conn, Nex.LiveReloadSocket, %{}, []) # Live reload HTTP endpoint (fallback for old clients) path == ["nex", "live-reload"] -> handle_live_reload(conn) # API routes: /api/* match?(["api" | _], path) -> handle_api(conn, method, path) # Page routes true -> handle_page(conn, method, path) end rescue e -> Logger.error("Unhandled error: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}") send_error_page(conn, 500, "Internal Server Error", e) catch kind, reason -> Logger.error("Caught #{kind}: #{inspect(reason)}") send_error_page(conn, 500, "Internal Server Error", reason) end end # API handlers defp handle_api(conn, method, path) do api_path = case path do ["api" | rest] -> rest _ -> path end case Nex.RouteDiscovery.resolve(:api, api_path) do {:ok, module, params} -> handle_api_endpoint(conn, method, module, Map.merge(conn.params, params)) :error -> send_json_error(conn, 404, "Not Found") end end # Handle regular API endpoint defp handle_api_endpoint(conn, method, module, params) do page_id = get_page_id_from_request(conn) Nex.Store.set_page_id(page_id) # Convert Plug.Conn to Nex.Req (only path params are extracted from resolving) req = Nex.Req.from_plug_conn(conn, params) try do # API 2.0: Always pass `req` struct # We intentionally do not check for arity 0 or 1 with Map to force upgrade if function_exported?(module, method, 1) do result = apply(module, method, [req]) send_api_response(conn, result) else send_api_response(conn, :method_not_allowed) end rescue e in FunctionClauseError -> # Check if the error happened in the called function due to argument mismatch if e.function == method and e.arity == 1 and e.module == module do Logger.error(""" [Nex] API Breaking Change Detected! The API signature for #{inspect(module)}.#{method}/1 has changed. It now expects a `Nex.Req` struct instead of a map. Please update your code: def #{method}(req) do id = req.query["id"] # Use req.query for path/query params name = req.body["name"] # Use req.body for POST data # ... Nex.json(%{data: ...}) end """) send_json_error(conn, 500, "Internal Server Error: API signature mismatch") else reraise e, __STACKTRACE__ end end end # Format data for SSE streaming (used by Nex.stream/1) defp format_sse_chunk(data) when is_binary(data) do "data: #{data}\n\n" end defp format_sse_chunk(%{event: event, data: data}) do "event: #{event}\ndata: #{encode_sse_data(data)}\n\n" end defp format_sse_chunk(data) when is_map(data) or is_list(data) do "data: #{Jason.encode!(data)}\n\n" end defp encode_sse_data(data) when is_binary(data), do: data defp encode_sse_data(data), do: Jason.encode!(data) defp send_api_response(conn, %Nex.Response{content_type: "text/event-stream"} = response) do # Handle SSE streaming response conn = conn |> put_resp_header("content-type", "text/event-stream; charset=utf-8") |> put_resp_header("cache-control", "no-cache, no-transform") |> put_resp_header("connection", "keep-alive") # Apply additional headers conn = Enum.reduce(response.headers, conn, fn {k, v}, conn -> put_resp_header(conn, to_string(k), to_string(v)) end) conn = send_chunked(conn, response.status) # Execute the callback function callback = response.body send_fn = fn data -> chunk = format_sse_chunk(data) case Plug.Conn.chunk(conn, chunk) do {:ok, conn} -> conn {:error, :closed} -> throw(:connection_closed) end end try do callback.(send_fn) conn rescue e -> Logger.error("SSE stream error: #{inspect(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}") conn catch :connection_closed -> Logger.debug("SSE connection closed by client") conn end end defp send_api_response(conn, %Nex.Response{} = response) do conn = Enum.reduce(response.headers, conn, fn {k, v}, conn -> put_resp_header(conn, to_string(k), to_string(v)) end) body = if response.content_type == "application/json" and not is_binary(response.body) do Jason.encode!(response.body) else response.body || "" end conn |> put_resp_content_type(response.content_type) |> send_resp(response.status, body) end defp send_api_response(conn, :method_not_allowed) do send_json_error(conn, 405, "Method Not Allowed") end defp send_api_response(conn, other) do # API 2.0 Enforce Nex.Response # We do NOT implicitly convert Maps/Lists to JSON anymore to ensure strict DX. error_msg = """ [Nex] API Response Error! Your API handler returned an invalid response type. It must return a `%Nex.Response{}` struct using one of the helper functions: * `Nex.json(data, opts \\\\ [])` * `Nex.text(string, opts \\\\ [])` * `Nex.html(content, opts \\\\ [])` * `Nex.redirect(to, opts \\\\ [])` * `Nex.status(code)` Received: #{inspect(other)} """ Logger.error(error_msg) # Development: return detailed error in response # Production: return generic error if Mix.env() == :dev do send_json(conn, 500, %{ error: "Internal Server Error: Invalid Response Type", details: %{ received: inspect(other), expected: "Nex.Response struct", available_helpers: [ "Nex.json(data, opts \\\\ [])", "Nex.text(string, opts \\\\ [])", "Nex.html(content, opts \\\\ [])", "Nex.redirect(to, opts \\\\ [])", "Nex.status(code)" ] } }) else send_json_error(conn, 500, "Internal Server Error") end end defp send_json(conn, status, data) do conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(data)) end defp send_json_error(conn, status, message) do send_json(conn, status, %{error: message}) end defp handle_page(conn, method, path) do case method do :get -> # GET requests render pages case Nex.RouteDiscovery.resolve(:pages, path) do {:ok, module, params} -> handle_page_render(conn, module, Map.merge(conn.params, params)) :error -> send_error_page(conn, 404, "Page Not Found", nil) end method when method in [:post, :delete, :put, :patch] -> # Attempt to validate CSRF token for requests # Note: In current stateless implementation, strict validation only occurs # if token was generated in the same request (rare for POST) or if signed tokens are implemented. case Nex.CSRF.validate(conn) do :ok -> # Requests call action functions # e.g., POST /create_todo → Index.create_todo/2 # e.g., DELETE /delete_todo → Index.delete_todo/2 case Nex.RouteDiscovery.resolve(:action, path) do {:ok, module, action, params} -> handle_page_action(conn, module, action, Map.merge(conn.params, params)) :error -> send_error_page(conn, 404, "Action Not Found", nil) end {:error, :missing_token} -> send_error_page(conn, 403, "CSRF token missing", nil) {:error, :invalid_token} -> send_error_page(conn, 403, "CSRF token invalid", nil) end _ -> send_error_page(conn, 405, "Method Not Allowed", nil) end end defp handle_page_render(conn, module, params) do # Generate a new page_id for this page view page_id = Nex.Store.generate_page_id() Nex.Store.set_page_id(page_id) # Generate CSRF token for this page csrf_token = Nex.CSRF.generate_token() assigns = if function_exported?(module, :mount, 1) do module.mount(params) else %{} end # Add page_id and csrf_token to assigns for template injection assigns = assigns |> Map.put(:_page_id, page_id) |> Map.put(:_csrf_token, csrf_token) if function_exported?(module, :render, 1) do content = module.render(assigns) # Convert to string for layout embedding content_html = Phoenix.HTML.Safe.to_iodata(content) |> IO.iodata_to_binary() # Inject page_id and CSRF token for HTMX # Live reload script only in dev environment nex_script = build_nex_script(page_id, csrf_token) # Try to get layout module from app config layout_module = get_layout_module() html = if layout_module && function_exported?(layout_module, :render, 1) do layout_module.render(%{ inner_content: content_html <> nex_script, title: Map.get(assigns, :title, "Nex App") }) else content end # Handle both HEEx output (Phoenix.HTML.Safe) and plain strings final_html = case html do binary when is_binary(binary) -> binary _ -> Phoenix.HTML.Safe.to_iodata(html) |> IO.iodata_to_binary() end conn |> put_resp_content_type("text/html") |> send_resp(200, final_html) else send_resp(conn, 500, "Page module missing render/1") end end defp handle_live_reload(conn) do last_reload = Nex.Reloader.last_reload_time() conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(%{time: last_reload})) end defp handle_page_action(conn, module, action, params) do # Set page_id from HTMX request header page_id = get_page_id_from_request(conn) Nex.Store.set_page_id(page_id) # Use to_existing_atom to prevent atom exhaustion attacks case safe_to_existing_atom(action) do {:ok, action_atom} -> if function_exported?(module, action_atom, 1) do result = apply(module, action_atom, [params]) send_action_response(conn, result) else send_resp(conn, 404, "Action not found: #{action}") end :error -> send_resp(conn, 404, "Action not found: #{action}") end end defp send_action_response(conn, :empty) do send_resp(conn, 200, "") end defp send_action_response(conn, {:redirect, to}) do conn |> put_resp_header("hx-redirect", to) |> send_resp(200, "") end defp send_action_response(conn, {:refresh, _}) do conn |> put_resp_header("hx-refresh", "true") |> send_resp(200, "") end defp send_action_response(conn, heex) do html = Phoenix.HTML.Safe.to_iodata(heex) |> IO.iodata_to_binary() conn |> put_resp_content_type("text/html") |> send_resp(200, html) end defp get_app_module do Application.get_env(:nex_core, :app_module, "MyApp") end defp get_layout_module do app_module = get_app_module() case safe_to_existing_module("#{app_module}.Layouts") do {:ok, module} -> module :error -> nil end end defp send_error_page(conn, status, message, error) do # Check if request is from HTMX is_htmx = get_req_header(conn, "hx-request") != [] # Check if request expects JSON is_json = match?(["api" | _], conn.path_info) or get_req_header(conn, "accept") |> Enum.any?(&String.contains?(&1, "application/json")) cond do is_json -> send_json_error(conn, status, message) is_htmx -> # For HTMX requests, return a simple error fragment html = """
#{html_escape(inspect(error, pretty: true))}"
else
""
end
html = """