Francis
Simple boilerplate killer using Plug and Bandit inspired by Sinatra for Ruby.
Focused on reducing time to build as it offers automatic request parsing, automatic response parsing, easy DSL to build quickly new endpoints, websocket and SSE listeners.
Installation
If available in Hex, the package can be installed by adding francis to your list of dependencies in mix.exs:
def deps do
[
{:francis, "~> 0.4.0"}
]
end
You can also use the Francis generator to create all the initial project files. You need to install the francis tasks first.
mix archive.install hex francis
Then you can create a new project with:
mix francis.new my_app
You can also create a project with a supervisor structure:
mix francis.new my_app --sup
mix francis.new my_app --sup MyApp
Use mix help francis.new to see all the available options.
Claude Code Skill
If you use Claude Code, install the Francis skill for AI-aware assistance with routes, WebSocket, SSE, security patterns, and gotchas:
npx skills add francis-build/francis@francis-thinking
Usage
To start the server up you can run mix francis.server or if you need a iex console you can run with iex -S mix francis.server.
Deployment
To create the Dockerfile that can be used for deployment you can run:
mix francis.release
Static Asset Management
Francis provides utilities for managing static assets, including content-based hashing for cache busting.
Digest Task
The mix francis.digest task generates digested versions of static files with content-based hashes in their filenames:
mix francis.digest
mix francis.digest priv/static
mix francis.digest priv/static --output priv/static
Options:
--output- The output path for generated files (defaults to input path)--age- Cache control max age in seconds (defaults to 31536000, 1 year)--gzip- Generate gzipped files (defaults to true)--exclude- File patterns to exclude (e.g.,--exclude '*.txt' --exclude '*.json')--clean- Remove previously digested files and manifest before regenerating. Safe for single-instance deploys; avoid in rolling/blue-green deployments where old instances may still serve previous asset hashes.
Static Module
The Francis.Static module provides functions to work with digested assets:
# Get the digested path for an asset
Francis.Static.static_path("app.css")
# => "/app-a1b2c3d4.css"
# Check if an asset exists in the manifest
Francis.Static.exists?("app.css")
# => true
# Get all assets from the manifest
Francis.Static.all()
# => %{"app.css" => %{"digest" => "a1b2c3d4", ...}, ...}
Configuration
You can configure Francis in your config/config.exs file. The following options are available:
dev- If set totrue, it will enable the development mode which will automatically reload the server when you change your code. Defaults tofalse.bandit_opts- Options to be passed to Banditstatic- Configure Plug.Static to serve static filesparser- Overrides the default configuration for Plug.Parserserror_handler- Defines a custom error handler for the serverlog_level- Sets the log level for Plug.Logger (default is:info)
import Config
config :francis,
dev: false,
bandit_opts: [port: 4000],
static: [from: "priv/static", at: "/"],
parser: [parsers: [:json, :urlencoded], pass: ["*/*"]],
error_handler: &Example.error/2,
log_level: :info
You can also set the values in use macro:
defmodule Example do
use Francis,
bandit_opts: [port: 4000],
static: [from: "priv/static", at: "/"],
parser: [parsers: [:json, :urlencoded], pass: ["*/*"]],
error_handler: &Example.error/2,
log_level: :info
end
Note: The dev option can only be set in your config/config.exs file, not in the use macro.
When to configure what:
bandit_optsis read when the server starts anderror_handlerwhen an error is handled, soconfig/runtime.exsworks for both.static,parserandlog_levelbuild the plug pipeline, so they are read at compile time. Set them inconfig/config.exsor the environment-specific files.- When
bandit_optshas no:port, Francis uses thePORTenvironment variable if it is set. static: falseturns static file serving off.
Error Handling
By default, Francis will return a styled HTML error page if you return a tuple {:error, any()} or an exception is raised during the request handling. Built-in error pages are provided for common status codes (400, 404, 500, 502, 503). You can also generate custom error pages using Francis.ErrorPage.render/3.
Unmatched Routes
If a request does not match any defined route, you can use the unmatched/1 macro to define a custom response:
unmatched(fn _conn -> "not found" end)
Custom Error Responses
For more advanced error handling, you can setup a custom error handler by providing the function that will handle the errors of your application:
defmodule Example do
use Francis, error_handler: &__MODULE__.error/2
get("/", fn _ -> {:error, :custom_error} end)
def error(conn, {:error, :custom_error}) do
# Return a custom response
Plug.Conn.send_resp(conn, 502, "Custom error response")
end
end
The error handler receives one of:
{:error, reason}, returned from a route- an exception, raised in a route or earlier in the pipeline (for example
Plug.Parsers.ParseErrorfor malformed JSON) {kind, reason}, for athroworexit
Without a custom handler, Francis renders an error page with the status the exception carries: 400 for malformed JSON, 404 for exceptions like Ecto.NoResultsError, and 500 for anything else, including {:error, reason}. If the custom handler itself raises, both errors are logged and the default page is rendered.
Example of a router
defmodule Example do
use Francis
get("/", fn _ -> "<html>world</html>" end)
get("/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end)
post("/", fn conn -> conn.body_params end)
ws("/ws", fn {:received, "ping"}, _socket -> {:reply, "pong"} end)
sse("/events", fn
:join, socket -> {:reply, %{status: "connected", id: socket.id}}
{:received, msg}, _socket -> {:reply, msg}
end)
unmatched(fn _ -> "not found" end)
end
And in your mix.exs file add that this module should be the one used for
startup:
def application do
[
extra_applications: [:logger],
mod: {Example, []}
]
end
This will ensure that Mix knows what module should be the entrypoint.
WebSocket Support
Francis provides a simple DSL for WebSocket endpoints using the ws/2 and ws/3 macros.
Basic Usage
defmodule Example do
use Francis
# Simple echo server
ws("/echo", fn {:received, message}, _socket ->
{:reply, message}
end)
end
Events
The handler receives different event types that can be pattern matched:
:join- Sent when a client connects{:close, reason}- Sent when the connection closes{:received, message}- Regular WebSocket text messages from the client{:info, message}- Messages sent tosocket.transportfrom other processes (optional)
If the handler has no {:info, _} clause, messages sent with send(socket.transport, message) go straight to the client when they are a binary, map, list or {type, payload} frame. Any other message is logged and dropped. Add an {:info, _} clause to handle PubSub broadcasts or monitor :DOWN messages yourself:
ws("/feed", fn
{:info, {:new_post, post}}, _socket -> {:reply, %{type: "post", title: post.title}}
{:received, _message}, _socket -> :noreply
end)
Socket State
The socket state map includes:
:id- A unique identifier for the WebSocket connection:transport- The transport process for sending messages:path- The actual request path of the WebSocket connection:params- A map of path parameters extracted from the route
Full Example with Lifecycle Events
defmodule Chat do
use Francis
require Logger
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"]
{:reply, "[#{room}] #{message}"}
end)
end
Options
:timeout- The timeout for the WebSocket connection in milliseconds (default: 60_000):heartbeat_interval- The interval in milliseconds between ping frames (default: 30_000). Set tonilto disable.:max_frame_size- The maximum allowed size in bytes for incoming frames (default: 65_536). Protects against memory exhaustion.
ws("/ws", fn {:received, msg}, _socket -> {:reply, msg} end,
heartbeat_interval: 10_000,
max_frame_size: 1_048_576 # 1 MB
)
Server-Sent Events (SSE) Support
Francis provides a simple DSL for SSE endpoints using the sse/2 and sse/3 macros. SSE connections are unidirectional (server-to-client) and use the same event-based API as WebSockets.
Basic Usage
defmodule Example do
use Francis
sse("/events", fn
:join, socket ->
{:reply, %{type: "connected", id: socket.id}}
{:received, message}, _socket ->
{:reply, message}
end)
end
Events
The handler receives the same event types as the WebSocket macro:
:join- Sent when a client connects{:close, reason}- Sent when the connection closes{:received, message}- Messages sent tosocket.transportfrom other processes
Pushing Events
Since SSE is server-to-client only, events are pushed by sending messages to the transport process from elsewhere in your application:
# From any process that has the transport PID:
send(socket.transport, "hello")
send(socket.transport, %{event: "update", data: %{count: 42}})
Named Events
You can send structured SSE events with event, id, and retry fields:
send(socket.transport, %{event: "user_joined", data: %{name: "Alice"}})
send(socket.transport, %{event: "update", data: "payload", id: "42", retry: 5000})
Full Example with Lifecycle Events
defmodule EventServer do
use Francis
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)
end
Options
:keepalive_interval- Interval in milliseconds between keepalive comments (default: 15_000). Set tonilto disable.
sse("/events", fn {:received, msg}, _socket -> {:reply, msg} end,
keepalive_interval: 5_000
)
Socket State
The socket state map is the same as for WebSockets:
:id- A unique identifier for the SSE connection:transport- The transport process PID for pushing events viasend/2:path- The actual request path of the SSE connection:params- A map of path parameters extracted from the route
Example of a router with Static serving
With the static option, you are able to setup the options for Plug.Static to serve static assets easily.
defmodule Example do
use Francis, static: [from: "priv/static", at: "/"]
end
Response Helpers
Francis provides convenient helper functions for common response types through the Francis.ResponseHandlers module, which is automatically imported when you use Francis.
Redirect
get("/old", fn conn -> redirect(conn, "/new") end)
get("/old", fn conn -> redirect(conn, 301, "/new") end)
JSON
get("/api/data", fn conn -> json(conn, %{message: "success"}) end)
get("/api/data", fn conn -> json(conn, 201, %{id: 123, created: true}) end)
Text
get("/text", fn conn -> text(conn, "Hello, World!") end)
get("/text", fn conn -> text(conn, 201, "Resource created") end)
Markdown
get("/readme", fn conn -> markdown(conn, "# Hello, World!") end)
get("/readme", fn conn -> markdown(conn, 201, "# Created") end)
HTML
get("/", fn conn -> html(conn, "<h1>Hello, World!</h1>") end)
get("/", fn conn -> html(conn, 201, "<h1>Created</h1>") end)
Warning: The html/2 and html/3 functions do not escape HTML content. Only use with trusted, static HTML content to avoid XSS vulnerabilities.
Safe HTML
For untrusted or user-generated content, use safe_html/2 and safe_html/3 to render the entire string as escaped text, or use Francis.HTML.escape/1 to escape only the interpolated values inside trusted markup:
# safe_html escapes the ENTIRE string — use when the whole response is untrusted input:
get("/raw", fn conn ->
safe_html(conn, conn.params["name"])
end)
Use Francis.HTML.escape/1 directly when mixing trusted markup with user content:
get("/profile", fn conn ->
name = Francis.HTML.escape(conn.params["name"])
bio = Francis.HTML.escape(conn.params["bio"])
html(conn, """
<h1>#{name}</h1>
<p>#{bio}</p>
""")
end)
Security
Secure Headers
The Francis.Plug.SecureHeaders plug adds sensible default security headers to every response:
defmodule Example do
use Francis
plug Francis.Plug.SecureHeaders
get("/", fn conn -> html(conn, "<h1>Secured!</h1>") end)
end
Default headers include X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, and Strict-Transport-Security. You can override any of them:
plug Francis.Plug.SecureHeaders,
headers: %{"x-frame-options" => "SAMEORIGIN"}
Content Security Policy
The Francis.Plug.CSP plug sets a restrictive Content-Security-Policy header:
defmodule Example do
use Francis
plug Francis.Plug.CSP
get("/", fn conn -> html(conn, "<h1>CSP Protected!</h1>") end)
end
Customize directives or use report-only mode for testing:
plug Francis.Plug.CSP,
directives: %{
"script-src" => "'self' https://cdn.example.com",
"style-src" => "'self' 'unsafe-inline'"
}
# Or test without enforcing:
plug Francis.Plug.CSP, report_only: true
Redirect Safety
The redirect/2 and redirect/3 helpers only accept relative paths to prevent open redirect vulnerabilities. Absolute URLs raise an ArgumentError and protocol-relative URLs (e.g. //evil.com) are neutralized:
get("/old", fn conn -> redirect(conn, "/new") end) # OK
get("/old", fn conn -> redirect(conn, 301, "/new") end) # OK
get("/old", fn conn -> redirect(conn, "http://evil.com") end) # Raises ArgumentError
Example of a router with Plugs
With the plugs option you are able to apply a list of plugs that happen
between before dispatching the request.
In the following example we're adding the Plug.BasicAuth plug to setup basic
authentication on all routes
defmodule Example do
import Plug.BasicAuth
use Francis
plug(:basic_auth, username: "test", password: "test")
get("/", fn _ -> "<html>world</html>" end)
get("/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end)
ws("/ws", fn {:received, "ping"}, _socket -> {:reply, "pong"} end)
sse("/events", fn {:received, msg}, _socket -> {:reply, msg} end)
unmatched(fn _ -> "not found" end)
end
Example of multiple routers
You can also define multiple routers in your application by using the forward/2 function provided by Plug .
For example, you can have an authenticated router and a public router.
defmodule Public do
use Francis
get("/", fn _ -> "ok" end)
end
defmodule Private do
use Francis
import Plug.BasicAuth
plug(:basic_auth, username: "test", password: "test")
get("/", fn _ -> "hello" end)
end
defmodule TestApp do
use Francis
forward("/path1", to: Public)
forward("/path2", to: Private)
unmatched(fn _ -> "not found" end)
end
Check the folder examples to see examples of how to use Francis, including a chat app (WebSocket), an API with Ecto, and an MCP server (SSE).