--- name: francis-thinking description: Invoke on ANY Francis task — routes, WebSocket, SSE, streaming, real-time, chat, Plug middleware, auth, CORS, static assets, deploy, Dockerfile, JSON API, uploads, sessions, `use Francis`, `ws/2`, `sse/2`, `socket.transport`, `Francis.Plug`, `Francis.HTML`, `Francis.Static`, `bandit_opts`, or contributing to the framework itself. Contains the unified event model, all API details, gotchas, and red flags. license: MIT metadata: author: francis-build version: "1.0.0" --- Invoke this skill BEFORE doing ANYTHING else on a Francis task — including exploring the codebase. Francis macros generate hidden modules at compile time. Exploring first means you won't know what to look for, and you'll miss critical safety rules (HTML escaping, SSE directionality, redirect safety). ## The Rule ``` Francis task → Read this skill FIRST → Then explore → Then write code ``` Even "where is the route defined?" needs this skill first — `get/ws/sse` macros generate hidden modules; grepping for the handler won't find them. --- ## STOP: The One Footgun That Causes XSS ```elixir # WRONG — html/2 does NOT escape. XSS vulnerability: html(conn, "

Hello #{user_input}

") # WRONG — safe_html/2 escapes the ENTIRE string, including your

tags: safe_html(conn, "

Hello #{user_input}

") # renders: <p>Hello user input</p> — raw text, not HTML # RIGHT — escape only the interpolation, keep your trusted markup: html(conn, "

Hello #{Francis.HTML.escape(user_input)}

") # RIGHT — safe_html/2 is for rendering untrusted content as escaped plain text: safe_html(conn, user_input) ``` --- ## The Unified Event Model All three transports share the same shape: ``` HTTP: fn conn -> response WS/SSE: fn event, socket -> reply ``` **Return value dispatch (HTTP handlers):** | Return value | What Francis sends | |---|---| | Binary string | 200, no content-type set (use `text/2` to force `text/plain`) | | Map or list | 200 JSON (`application/json`) | | `Plug.Conn` struct | Sent as-is (full control) | | `{:error, reason}` | Calls error handler | Status is 200 for route macros, 404 for `unmatched/1`. Return `Plug.Conn` to override either. **Reply value dispatch (WS/SSE handlers):** | Return value | What Francis sends | |---|---| | `{:reply, binary}` | Text frame / SSE `data:` line | | `{:reply, map \| list}` | JSON-encoded text frame / SSE `data:` line | | `{:reply, {type, payload}}` | Typed WS frame: `type` in `:text`, `:binary`, `:ping`, `:pong` | | `:noreply` or `:ok` | Nothing sent | `{:reply, {:binary, bytes}}` is the only way to send binary WebSocket frames. --- ## HTTP Routes ```elixir defmodule MyApp do use Francis get("/", fn _conn -> "hello" end) get("/users/:id", fn conn -> "user #{conn.params["id"]}" end) post("/users", fn conn -> conn.body_params end) put("/users/:id", fn conn -> %{updated: conn.params["id"]} end) delete("/users/:id", fn conn -> %{deleted: conn.params["id"]} end) patch("/users/:id", fn conn -> conn.body_params end) unmatched(fn _conn -> "not found" end) end ``` **`unmatched/1` must be declared last** — it shadows any routes declared after it. **Accessing data:** - Path params + query string: `conn.params["id"]` - Request body: `conn.body_params` — requires a matching `content-type` header (`application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`). Without it, `body_params` is `%{}`. Body params are also merged into `conn.params` after parsing. **Response helpers** (auto-imported via `Francis.ResponseHandlers`): ```elixir json(conn, %{ok: true}) json(conn, 201, %{id: 1}) text(conn, "hello") html(conn, "

Trusted static HTML only

") safe_html(conn, user_input) # escapes the whole string as plain text safe_html(conn, 201, user_input) redirect(conn, "/new") # relative paths only redirect(conn, 301, "/new") ``` **HEAD requests** — no `head/2` macro. `Plug.Head` (installed by default) converts HEAD requests to GET automatically. --- ## WebSockets ```elixir ws("/chat/:room", fn :join, socket -> {:reply, %{type: "welcome", room: socket.params["room"]}} {:received, msg}, socket -> {:reply, "[#{socket.params["room"]}] #{msg}"} {:close, _reason}, _socket -> :ok end) ws("/live", handler_fn, heartbeat_interval: 10_000, timeout: 120_000) ``` **Events:** - `:join` — client connected - `{:received, message}` — client sent a text message over the wire - `{:close, reason}` — connection closed **`:join` and `{:close, _}` are optional** — succeed silently if unmatched. Easy to lose cleanup logic on close. **Socket state:** ```elixir %{ id: "64-character hex string (32 random bytes)", transport: pid, path: "/chat/general", params: %{"room" => "general"} } ``` **`send(socket.transport, msg)` for WS bypasses the handler entirely.** Messages are forwarded directly to the client. They do NOT pass through your `{:received, _}` clause. Store `socket.transport` to broadcast from other processes. **Options:** - `:heartbeat_interval` (default: 30_000 ms) — ping frames; `nil` to disable - `:timeout` (default: 60_000 ms) — idle connection timeout - `:max_frame_size` (default: 65_536 bytes) — memory protection **Module name collision:** each `ws/3` call generates a module named from the route path. Two routes with structurally identical paths generate the same module name and silently overwrite each other. --- ## Server-Sent Events (SSE) SSE is **server→client only**. The SSE client has no upstream channel. All events the handler receives come from other processes via `send(socket.transport, msg)`. ```elixir sse("/events", fn :join, socket -> {:reply, %{event: "connected", data: %{id: socket.id}}} {:received, msg}, socket -> {:reply, msg} {:close, _reason}, _socket -> :ok end) sse("/stream", handler_fn, keepalive_interval: 30_000) ``` **`send(socket.transport, msg)` for SSE routes through the handler's `{:received, msg}` clause** — unlike WS where it bypasses the handler. You can transform or filter messages before they reach the client. Real close reasons include `:chunk_failed` (client disconnected) and `:keepalive_failed`. Use `{:close, _}` to clean up subscriptions. **SSE event formats:** ```elixir {:reply, "plain text"} # => data: plain text\n\n {:reply, %{status: "ok"}} # => data: {"status":"ok"}\n\n {:reply, %{event: "user_joined", data: %{name: "Alice"}, id: "42", retry: 5000}} # => event: user_joined\ndata: {...}\nid: 42\nretry: 5000\n\n ``` **Options:** - `:keepalive_interval` (default: 15_000 ms) — comment line to keep connection alive; `nil` to disable **WS vs SSE — critical difference:** | | WS `send(socket.transport, msg)` | SSE `send(socket.transport, msg)` | |---|---|---| | Routes through handler? | No — sent directly to client | Yes — delivered to `{:received, msg}` | | `{:received, _}` source | Client text frames over the wire | Other processes only | --- ## Plug Composition Plugs run **before route handlers**, in declaration order. Auth plugs must come before route macros. ```elixir defmodule MyApp do use Francis import Plug.BasicAuth plug Francis.Plug.SecureHeaders plug Francis.Plug.CSP plug :basic_auth, username: "admin", password: "secret" get("/", fn _ -> "authenticated" end) end ``` **Router forwarding** for scoped middleware: ```elixir defmodule Public do use Francis get("/", fn _ -> "public" end) end defmodule Private do use Francis import Plug.BasicAuth plug :basic_auth, username: "admin", password: "secret" get("/", fn _ -> "private" end) end defmodule Main do use Francis forward("/public", to: Public) forward("/private", to: Private) unmatched(fn _ -> "not found" end) end ``` `forward/2` and `plug/1-2` are from `Plug.Router`/`Plug.Builder` — see Plug docs for full options. --- ## Security ```elixir plug Francis.Plug.SecureHeaders plug Francis.Plug.SecureHeaders, headers: %{"x-frame-options" => "SAMEORIGIN"} plug Francis.Plug.CSP plug Francis.Plug.CSP, directives: %{"script-src" => "'self' https://cdn.example.com"}, report_only: true ``` `redirect/2` and `redirect/3` accept relative paths only. Absolute URLs raise `ArgumentError`. Protocol-relative URLs (`//evil.com`) are converted to `/`. --- ## Error Handling ```elixir defmodule MyApp do use Francis, error_handler: &__MODULE__.handle_error/2 get("/risky", fn _ -> {:error, :unavailable} end) def handle_error(conn, {:error, :unavailable}), do: Plug.Conn.send_resp(conn, 503, "Service unavailable") def handle_error(conn, _), do: Plug.Conn.send_resp(conn, 500, "Internal error") end ``` The error handler receives both `{:error, reason}` tuples and raised exceptions. If the error handler itself raises, Francis catches it and renders the default 500 page. --- ## Configuration Keys valid in both `use Francis` opts and `config.exs`: `bandit_opts`, `static`, `log_level`, `error_handler`, `parser`. `dev: true` is **only read from `config.exs`**, never from `use Francis` opts. If both locations set the same key, `use` opts win and a warning is logged. ```elixir config :francis, bandit_opts: [port: 4000], static: [from: "priv/static", at: "/"], parser: [parsers: [:json, :urlencoded, :multipart], json_decoder: Jason], error_handler: &MyApp.Errors.handle/2, log_level: :info, dev: true ``` Note: the outer key is singular `:parser`; the inner `Plug.Parsers` key is plural `:parsers`. --- ## Static Assets & Digestion ```elixir use Francis, static: [from: "priv/static", at: "/"] ``` ```bash mix francis.digest # hash all assets, write cache_manifest.json mix francis.digest --clean # remove old digested files, then re-digest mix francis.digest --gzip false mix francis.digest --exclude '*.json' mix francis.digest --age 86400 # cache-control max-age in seconds (default: 31536000) ``` ```elixir Francis.Static.static_path("app.css") # => "/app-a1b2c3d4.css" ``` --- ## Mix Tasks ```bash mix francis.server iex -S mix francis.server mix francis.new my_app mix francis.new my_app --sup mix francis.new my_app --sup MyApp mix francis.release --port 8080 --elixir-version 1.18.4 --otp-version 27.3.4 ``` --- ## Testing ```elixir defmodule MyAppTest do use ExUnit.Case, async: true use Plug.Test @opts MyApp.init([]) test "GET /" do conn = conn(:get, "/") |> MyApp.call(@opts) assert conn.status == 200 assert conn.resp_body == "hello" end test "POST /users" do conn = conn(:post, "/users", Jason.encode!(%{name: "Alice"})) |> put_req_header("content-type", "application/json") |> MyApp.call(@opts) assert conn.status == 201 assert %{"name" => "Alice"} = Jason.decode!(conn.resp_body) end end ``` Always prefix mix commands with `unbuffer`: `unbuffer mix test` --- ## Gotchas & Red Flags | Situation | Correct approach | |---|---| | Rendering user input in HTML | `html(conn, "

#{Francis.HTML.escape(input)}

")` | | Rendering untrusted text | `safe_html(conn, input)` — escapes entire string; do NOT wrap in markup | | SSE pushing from handler | SSE is server→client; push via `send(socket.transport, msg)` from another process | | WS broadcasting from another process | `send(socket.transport, msg)` bypasses handler — goes direct to client | | Forgetting `:close` handler | Silent success — add `{:close, _}` to clean up subscriptions and ETS entries | | Absolute URL in `redirect` | Francis raises `ArgumentError` — relative paths only | | `dev: true` not working | Only valid in `config.exs`, not in `use Francis` opts | | Auth inside a route handler | Move to a `plug` before routes, or scope with `forward/2` | | `body_params` is `%{}` | Caller must send a matching `content-type` header | | Two ws/sse routes with same path shape | Generate the same module name — silently overwrite each other | | `unmatched/1` not catching routes | Must be declared **last** — shadows everything after it |