defmodule Francis do @moduledoc """ Module responsible for starting the Francis server and to wrap the Plug functionality This module performs multiple tasks: * Uses the Application module to start the Francis server * Defines the Francis.Router which uses Francis.Plug.Router, :match and :dispatch * Defines the macros get, post, put, delete, patch, ws and sse to define routes for each operation * Setups Plug.Static with the given options * Sets up Plug.Parsers with the default configuration of: * ```elixir plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], json_decoder: Jason ) ``` * Defines a default error handler that returns a 500 status code and a generic error message. You can override this by passing the function name on `:error_handler` option to the `use Francis` macro which will override the default error handler. You can also set the following options: * :bandit_opts - Options to be passed to Bandit * :static - Configure Plug.Static to serve static files * :parser - Overrides the default configuration for Plug.Parsers * :error_handler - Defines a custom error handler for the server * :log_level - Sets the log level for Plug.Logger (default is `:info`) """ require Logger import Plug.Conn @default_heartbeat_interval 30_000 @default_ws_timeout 60_000 @default_max_frame_size 65_536 @default_sse_keepalive_interval 15_000 defmacro __using__(opts \\ []) do quote location: :keep do use Application use Plug.ErrorHandler use Francis.Plug.Router require Logger import Francis.ResponseHandlers def start, do: start(:normal, []) static = get_configuration(:static, unquote(opts), from: "priv/static", at: "/") parser = get_configuration(:parser, unquote(opts), parsers: [:urlencoded, :multipart, :json], json_decoder: Jason ) log_level = get_configuration(:log_level, unquote(opts), :info) if static, do: plug(Plug.Static, static) plug(Plug.Parsers, parser) plug(Plug.Logger, log: log_level) plug(Plug.Head) def start(_type, _args) do dev = Application.get_env(:francis, :dev, false) watcher_spec = if dev, do: [{Francis.Watcher, []}], else: [] children = [ {Bandit, [plug: __MODULE__] ++ Keyword.get(unquote(opts), :bandit_opts, [])} ] ++ watcher_spec Supervisor.start_link(children, strategy: :one_for_one) end defoverridable(start: 2) def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start, opts}, type: :supervisor, restart: :permanent, shutdown: 5000, modules: [__MODULE__] } end @spec handle_response( (Plug.Conn.t() -> binary() | map() | Plug.Conn.t()), Plug.Conn.t(), integer() ) :: Plug.Conn.t() def handle_response(handler, conn, status \\ 200) do case handler.(conn) do res when is_struct(res, Plug.Conn) -> res res when is_binary(res) -> conn |> send_resp(status, res) |> halt() res when is_map(res) or is_list(res) -> conn |> put_resp_content_type("application/json") |> send_resp(status, Jason.encode!(res)) |> halt() {:error, res} -> handle_errors(conn, {:error, res}) end rescue e -> handle_errors(conn, e) end # Error handling chain: custom handler -> fallback to generic 500 page. # If the custom error handler itself raises, we catch that and still # return a 500 page to avoid crashing the connection. @spec handle_errors(Plug.Conn.t(), any()) :: Plug.Conn.t() @impl true def handle_errors(conn, reason) do error_handler = Keyword.get(unquote(opts), :error_handler) case error_handler do nil -> Logger.error("Unhandled error: #{inspect(reason)}") internal_server_error(conn) handler -> handler.(conn, reason) end rescue e -> Logger.error("Unhandled error: #{inspect(e)}") internal_server_error(conn) end defp internal_server_error(conn) do conn |> put_resp_content_type("text/html") |> send_resp(500, Francis.ErrorPage.render(500)) |> halt() end end end @http_methods [:get, :post, :put, :delete, :patch] for method <- @http_methods do @doc """ Defines a #{String.upcase(to_string(method))} route ## Examples ```elixir defmodule Example.Router do use Francis #{method} "/hello", fn conn -> "Hello World!" end end ``` """ @spec unquote(method)(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) :: Macro.t() defmacro unquote(method)(path, handler) do method = unquote(method) quote location: :keep do Plug.Router.unquote(method)( unquote(path), do: handle_response(unquote(handler), var!(conn)) ) end end end @doc """ Defines a WebSocket route with a unified event handler. The handler function uses pattern matching on events, providing an idiomatic Elixir approach. All events flow through a single function with distinct shapes for easy pattern matching. ## Events The handler receives different event types that can be pattern matched: - `:join` - Sent when a client connects. Return `{:reply, message}` to send a welcome message. - `{:close, reason}` - Sent when the connection closes. Return `:ok` or `:noreply`. - `{:received, message}` - Regular WebSocket text messages from the client. Messages sent via `send(socket.transport, message)` are automatically forwarded to the client. ## Return Values - `{:reply, response}` - where `response` can be a binary, a map, or a list (maps/lists will be JSON encoded) - `:noreply` or `:ok` - to not send a response ## Socket State The socket state map includes: - `:transport` - The transport process that can be used to send messages back to the client using `send/2` - `:id` - A unique identifier for the WebSocket connection that can be used to track the connection - `:path` - The actual request path of the WebSocket connection (e.g., `/chat/general`) - `:params` - A map of path parameters extracted from the route (e.g., `%{"room" => "general"}` for route `/:room`) ## Options - `:timeout` - The timeout for the WebSocket connection in milliseconds (default: 60_000) - `:heartbeat_interval` - The interval in milliseconds between ping frames for heartbeat (default: 30_000). Set to `nil` to disable heartbeat. - `:max_frame_size` - The maximum allowed size in bytes for incoming WebSocket frames (default: 65_536). Protects against memory exhaustion from oversized messages. ## Examples ```elixir defmodule Example.Router do use Francis # Simple echo server ws "/echo", fn {:received, message}, socket -> {:reply, message} end # Pattern matching on specific messages ws "/ping", fn {:received, "ping"}, socket -> {:reply, "pong"} end # Full lifecycle handling with pattern matching ws "/chat/:room", fn :join, socket -> room = socket.params["room"] {:reply, %{type: "welcome", room: room, id: socket.id}} {:close, reason}, socket -> Logger.info("Client \#{socket.id} left: \#{inspect(reason)}") :ok {:received, message}, socket -> room = socket.params["room"] # Broadcast to self (will be forwarded to client) send(socket.transport, "Someone said: " <> message) {:reply, "[" <> room <> "] " <> message} end # JSON responses ws "/json", fn {:received, message}, socket -> {:reply, %{status: "ok", message: message}} end # No reply needed ws "/fire-and-forget", fn {:received, message}, socket -> Logger.info("Received: \#{message}") :noreply end # Custom heartbeat interval (ping every 10 seconds) ws "/heartbeat", fn {:received, message}, socket -> {:reply, message} end, heartbeat_interval: 10_000 # Disable heartbeat ws "/no-heartbeat", fn {:received, message}, socket -> {:reply, message} end, heartbeat_interval: nil end ``` """ @spec ws( String.t(), (event :: :join | {:close, term()} | {:received, binary()}, socket :: %{id: binary(), transport: pid(), path: binary(), params: map()} -> {:reply, binary() | map() | {atom(), any()}} | :noreply | :ok), Keyword.t() ) :: Macro.t() defmacro ws(path, handler, opts \\ []) do module_name = generate_ws_module_name(path) handler_ast = build_ws_handler_ast(module_name, handler) Code.compile_quoted(handler_ast) quote location: :keep do get(unquote(path), fn conn -> socket_state = %{ id: 32 |> :crypto.strong_rand_bytes() |> Base.encode16(), path: conn.request_path, params: conn.params } heartbeat_interval = Keyword.get(unquote(opts), :heartbeat_interval, unquote(@default_heartbeat_interval)) conn |> var!() |> WebSockAdapter.upgrade( unquote(module_name), Map.put(socket_state, :heartbeat_interval, heartbeat_interval), timeout: Keyword.get(unquote(opts), :timeout, unquote(@default_ws_timeout)), max_frame_size: Keyword.get(unquote(opts), :max_frame_size, unquote(@default_max_frame_size)) ) |> halt() end) end end # Private helper functions for WebSocket macro defp generate_ws_module_name(path) do path |> URI.parse() |> Map.get(:path) |> String.split("/") |> Enum.map_join(".", &Macro.camelize/1) |> then(&Module.concat([__MODULE__, &1])) end defp build_ws_handler_ast(module_name, handler) do quote do defmodule unquote(module_name) do require Logger def init(opts) do state = opts |> Map.put(:transport, self()) |> Francis.Websocket.setup_heartbeat() send(self(), :__francis_join__) {:ok, state} end def handle_control({_payload, [opcode: :ping]}, state), do: {:ok, state} def handle_control({_payload, [opcode: :pong]}, state), do: {:ok, state} def handle_in({message, _opts}, state) do unquote(handler).({:received, message}, state) |> Francis.Websocket.format_response(state) rescue e -> Logger.error("WS Handler error: #{inspect(e)}") {:stop, :error, state} end def handle_info(:__francis_join__, state), do: Francis.Websocket.call_join(unquote(handler), state) def handle_info(:__francis_heartbeat__, state), do: Francis.Websocket.handle_heartbeat(state) def handle_info(msg, state), do: Francis.Websocket.format_response({:reply, msg}, state) def terminate(reason, state) do Francis.Websocket.cancel_heartbeat(state) Francis.Websocket.call_close(unquote(handler), {:close, reason}, state) :ok end end end end @doc """ Defines a Server-Sent Events (SSE) route with a unified event handler. The handler function uses pattern matching on events, providing a consistent API with the WebSocket macro. SSE connections are unidirectional (server-to-client), so the handler receives messages via `send(socket.transport, message)` from other processes and forwards them to the client as SSE events. ## Events The handler receives different event types that can be pattern matched: - `:join` - Sent when a client connects. Return `{:reply, message}` to send an initial event. - `{:close, reason}` - Sent when the connection closes. Return `:ok` or `:noreply`. - `{:received, message}` - Messages sent to `socket.transport` from other processes. ## Return Values - `{:reply, response}` - where `response` can be: - a binary – sent as `data: \\n\\n` - a map or list – JSON-encoded as `data: \\n\\n` - a map with `:event`, `:data`, and optionally `:id` / `:retry` keys – sent with the corresponding SSE fields - `:noreply` or `:ok` - to not send an event ## Socket State The socket state map includes: - `:transport` - The transport process PID. Use `send(socket.transport, msg)` to push events. - `:id` - A unique identifier for the SSE connection. - `:path` - The actual request path (e.g., `/events/news`). - `:params` - A map of path parameters extracted from the route. ## Options - `:keepalive_interval` - Interval in ms between keepalive comments (default: 15_000). Set to `nil` to disable keepalive. ## Examples ```elixir defmodule Example.Router do use Francis # Simple event stream sse "/events", fn :join, socket -> {:reply, %{type: "connected", id: socket.id}} end # With named events and full lifecycle sse "/feed/:topic", fn :join, socket -> topic = socket.params["topic"] {:reply, %{event: "welcome", data: %{topic: topic}}} {:close, _reason}, _socket -> :ok {:received, message}, _socket -> {:reply, message} end # Disable keepalive sse "/raw", fn {:received, msg}, _socket -> {:reply, msg} end, keepalive_interval: nil end ``` """ @spec sse( String.t(), (event :: :join | {:close, term()} | {:received, term()}, socket :: %{id: binary(), transport: pid(), path: binary(), params: map()} -> {:reply, binary() | map() | list()} | :noreply | :ok), Keyword.t() ) :: Macro.t() defmacro sse(path, handler, opts \\ []) do module_name = generate_sse_module_name(path) handler_ast = build_sse_handler_ast(module_name, handler) Code.compile_quoted(handler_ast) quote location: :keep do get(unquote(path), fn conn -> socket_state = %{ id: 32 |> :crypto.strong_rand_bytes() |> Base.encode16(), path: conn.request_path, params: conn.params } keepalive_interval = Keyword.get( unquote(opts), :keepalive_interval, unquote(@default_sse_keepalive_interval) ) state = Map.put(socket_state, :keepalive_interval, keepalive_interval) unquote(module_name).run(conn, state) end) end end defp generate_sse_module_name(path) do path |> URI.parse() |> Map.get(:path) |> String.split("/") |> Enum.map_join(".", &Macro.camelize/1) |> then(&Module.concat([__MODULE__, "SSE", &1])) end defp build_sse_handler_ast(module_name, handler) do quote do defmodule unquote(module_name) do @doc false def run(conn, state), do: Francis.SSE.run(conn, state, unquote(handler)) end end end @doc """ Defines a catch-all action for unmatched routes (returns 404). """ @spec unmatched((Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) :: Macro.t() defmacro unmatched(handler) do quote location: :keep do match _ do handle_response(unquote(handler), var!(conn), 404) end end end @doc """ Retrieves the configuration for a given key, checking both the macro options and the application environment. """ @spec get_configuration(atom(), Keyword.t(), any()) :: any() def get_configuration(key, opts, default) do opts = Keyword.get(opts, key) config = Application.get_env(:francis, key) if opts && config do Logger.warning( "Both application configuration and macro option provided for #{key}. Using macro option." ) opts else opts || config || default end end end