Francis (Francis v0.3.2)

View Source

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:
    • 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)

Summary

Functions

Defines a DELETE route

Defines a GET route

Retrieves the configuration for a given key, checking both the macro options and the application environment.

Defines a PATCH route

Defines a POST route

Defines a PUT route

Defines a Server-Sent Events (SSE) route with a unified event handler.

Defines a catch-all action for unmatched routes (returns 404).

Defines a WebSocket route with a unified event handler.

Functions

delete(path, handler)

(macro)
@spec delete(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
  Macro.t()

Defines a DELETE route

Examples

defmodule Example.Router do
  use Francis

  delete "/hello", fn conn ->
    "Hello World!"
  end
end

get(path, handler)

(macro)
@spec get(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
  Macro.t()

Defines a GET route

Examples

defmodule Example.Router do
  use Francis

  get "/hello", fn conn ->
    "Hello World!"
  end
end

get_configuration(key, opts, default)

@spec get_configuration(atom(), Keyword.t(), any()) :: any()

Retrieves the configuration for a given key, checking both the macro options and the application environment.

patch(path, handler)

(macro)
@spec patch(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
  Macro.t()

Defines a PATCH route

Examples

defmodule Example.Router do
  use Francis

  patch "/hello", fn conn ->
    "Hello World!"
  end
end

post(path, handler)

(macro)
@spec post(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
  Macro.t()

Defines a POST route

Examples

defmodule Example.Router do
  use Francis

  post "/hello", fn conn ->
    "Hello World!"
  end
end

put(path, handler)

(macro)
@spec put(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
  Macro.t()

Defines a PUT route

Examples

defmodule Example.Router do
  use Francis

  put "/hello", fn conn ->
    "Hello World!"
  end
end

sse(path, handler, opts \\ [])

(macro)
@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()

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: <string>\n\n
    • a map or list – JSON-encoded as data: <json>\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

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

unmatched(handler)

(macro)
@spec unmatched((Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) :: Macro.t()

Defines a catch-all action for unmatched routes (returns 404).

ws(path, handler, opts \\ [])

(macro)
@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()

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

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