Current section

Files

Jump to
nex_core lib nex handler.ex
Raw

lib/nex/handler.ex

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 = """
<div class="p-4 bg-red-100 border border-red-400 text-red-700 rounded">
<strong>Error #{status}:</strong> #{html_escape(message)}
</div>
"""
conn
|> put_resp_content_type("text/html")
|> send_resp(status, html)
true ->
# Full error page
error_detail = if error && Mix.env() == :dev do
"<pre class=\"mt-4 p-4 bg-gray-800 text-green-400 rounded overflow-auto text-sm\">#{html_escape(inspect(error, pretty: true))}</pre>"
else
""
end
html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>#{status} - #{html_escape(message)}</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
<div class="text-center p-8">
<h1 class="text-6xl font-bold text-gray-300 mb-4">#{status}</h1>
<p class="text-xl text-gray-600 mb-8">#{html_escape(message)}</p>
<a href="/" class="text-blue-500 hover:underline">← Back to Home</a>
#{error_detail}
</div>
</body>
</html>
"""
conn
|> put_resp_content_type("text/html")
|> send_resp(status, html)
end
end
defp html_escape(text) when is_binary(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
end
defp html_escape(text), do: html_escape(to_string(text))
# Safe atom/module conversion to prevent atom exhaustion attacks
# Only converts to atom if it already exists (i.e., module was compiled)
defp safe_to_existing_atom(string) do
{:ok, String.to_existing_atom(string)}
rescue
ArgumentError -> :error
end
defp safe_to_existing_module(module_name) do
case safe_to_existing_atom("Elixir.#{module_name}") do
{:ok, module} ->
if Code.ensure_loaded?(module), do: {:ok, module}, else: :error
:error ->
:error
end
end
# Get page_id from request (header or fallback to param for backward compatibility)
defp get_page_id_from_request(conn) do
case get_req_header(conn, "x-nex-page-id") do
[page_id | _] when is_binary(page_id) and page_id != "" -> page_id
_ -> conn.params["_page_id"] || "unknown"
end
end
# Build the Nex script for page_id, CSRF token, and optional live reload
defp build_nex_script(page_id, csrf_token) do
base_script = """
<script>
// Store page_id and CSRF token, configure HTMX to send them via headers
document.body.dataset.pageId = "#{page_id}";
document.body.dataset.csrfToken = "#{csrf_token}";
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-Nex-Page-Id'] = document.body.dataset.pageId;
evt.detail.headers['X-CSRF-Token'] = document.body.dataset.csrfToken;
});
"""
# Only add live reload script in dev environment
live_reload_script = if Nex.Reloader.enabled?() do
"""
// Live reload via WebSocket (dev only)
(function() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(protocol + '//' + window.location.host + '/nex/live-reload-ws');
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.reload) {
console.log('[Nex] File changed, reloading...');
window.location.reload();
}
};
ws.onerror = function() {
console.log('[Nex] WebSocket error');
};
ws.onclose = function() {
setTimeout(function() { window.location.reload(); }, 1000);
};
})();
"""
else
""
end
base_script <> live_reload_script <> "</script>"
end
end