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 # JSON API: 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({:raw, data}) when is_binary(data) do data end defp format_sse_chunk(data) when is_binary(data) do if String.starts_with?(data, "data: ") or String.starts_with?(data, "event: ") do data else "data: #{data}\n\n" end end defp format_sse_chunk(%{event: event, data: data}) do encoded = encode_sse_data(data) # Ensure multiline data each have data: prefix formatted_data = encoded |> String.split("\n") |> Enum.map(&"data: #{&1}") |> Enum.join("\n") "event: #{event}\n#{formatted_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{} = response) do if String.starts_with?(response.content_type || "", "text/event-stream") do handle_sse_response(conn, response) else handle_regular_response(conn, response) end 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 # JSON API: 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: %{ message: "Your API handler returned an invalid response type", received_type: other.__struct__ || "unknown", hint: "Return a `%Nex.Response{}` struct using helper functions like `Nex.json/2`" } }) else send_json_error(conn, 500, "Internal Server Error") end end defp handle_sse_response(conn, 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 handle_regular_response(conn, 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_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 # Get current page context from Referer header referer_path = get_referer_path(conn) case Nex.RouteDiscovery.resolve(:action, path, referer_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 html_binary = case html do binary when is_binary(binary) -> binary _ -> Phoenix.HTML.Safe.to_iodata(html) |> IO.iodata_to_binary() end # Automatically inject CSRF token into all POST/PUT/PATCH/DELETE forms # This removes the need for manual {csrf_input_tag()} boilerplate final_html = String.replace( html_binary, ~r/(