brazier

Types

An app being configured. Build one with simple, application or full_application, pipe it through the options, then call start.

  • state: what the server holds per session.
  • model: the client-visible part (sync(state)), sent to the browser.
  • local: client-only state, never sent to the server.
  • msg: the app’s messages.
pub opaque type App(state, model, local, msg)

What init knows about the request it runs for: the page URL, the cookies, whether this is the connected mount (WebSocket) or the dead render (HTTP), and the browser session.

init runs twice per page, like LiveView’s mount: once for the HTML response (is_connected False, effects dropped) and once when the browser connects (is_connected True, effects run).

pub type Context =
  @internal Context

Why init refused to start a session. Build one with error.unavailable (503) or error.refuse(status, reason) from brazier/error.

Every constructor’s init returns Result(_, InitError), like LiveView’s mount returning {:ok, socket}. An Error is answered on the dead render with an error page for its status, and on the connected mount by closing the WebSocket with 4000 + status (4503 for unavailable). The reason is logged and never sent to the browser. A crash in init is answered with 500 on the dead render and closes the WebSocket with 1011.

pub type InitError =
  error.InitError

Values

pub fn api_routes(
  app: App(state, model, local, msg),
  handler: fn(request.Request(server.Connection)) -> option.Option(
    response.Response(server.ResponseBody),
  ),
) -> App(state, model, local, msg)

Register an API route handler. It runs before page rendering and static files on every HTTP request: Some(response) answers the request, None falls through.

Prefer brazier/api.routes with api.get, api.post and the response helpers for ordinary JSON/text endpoints.

pub fn application(
  init init: fn(Context) -> Result(
    #(model, effect.Effect(msg)),
    error.InitError,
  ),
  update update: fn(model, msg) -> #(model, effect.Effect(msg)),
  view view: fn(model) -> element.Element(msg),
) -> App(model, model, Nil, msg)

An app whose whole model is synced to the client. update runs only on the server and returns effects.

pub fn broadcast(topic: String) -> Nil

Broadcast a notification to a topic. Every session subscribed to it gets its on_notify message.

pub fn broadcast_payload(topic: String, payload: a) -> Nil

Broadcast a typed payload to a topic (read it in on_notification with notification_payload).

pub fn cluster(
  app: App(state, model, local, msg),
  config: cluster.ClusterConfig,
) -> App(state, model, local, msg)

Join a cluster: the cluster manager (built with cluster.new) runs in the application’s supervision tree right after PubSub, connecting to and checking peers. Start fails when the configuration or this node’s distribution does not pass cluster’s checks.

pub fn cookie(ctx: Context, name: String) -> Result(String, Nil)

A request cookie by name.

pub fn dev_mode(
  app: App(state, model, local, msg),
  enabled: Bool,
) -> App(state, model, local, msg)

Enable Brazier’s client dev tracing.

pub fn full_application(
  init init: fn(Context) -> Result(
    #(state, effect.Effect(msg)),
    error.InitError,
  ),
  update update: fn(state, msg) -> #(state, effect.Effect(msg)),
  sync sync: fn(state) -> model,
  local local: local.Layer(model, local, msg),
  view view: fn(model, local) -> element.Element(msg),
) -> App(state, model, local, msg)

An app with all three state layers:

  • state, held only by the server (secrets, history, handles);
  • the Model, sync(state), rendered by view and sent to the browser;
  • Local, client-only state from the local layer (local.layer or local.none()).

Only the types sync, view and the Local layer reach are compiled for the browser, so the rest of state never leaves the server.

pub fn head_html(
  app: App(state, model, local, msg),
  html: String,
) -> App(state, model, local, msg)

Extra HTML for the page’s <head> (stylesheets, meta tags, fonts).

pub fn is_connected(ctx: Context) -> Bool

True for the connected mount, False for the dead render (LiveView’s connected?/1).

pub fn notification_payload(
  notification: notification.Notification,
  decoder: decode.Decoder(a),
) -> Result(a, List(decode.DecodeError))

Decode a notification’s payload.

pub fn on_notification(
  app: App(state, model, local, msg),
  handler: fn(notification.Notification) -> msg,
) -> App(state, model, local, msg)

The message for a typed notification on a subscribed topic.

pub fn on_notify(
  app: App(state, model, local, msg),
  handler: fn(String) -> msg,
) -> App(state, model, local, msg)

The message for a notification on a subscribed topic (by topic).

pub fn presence(
  app: App(state, model, local, msg),
  tracker tracker: presence.PresenceConfig(meta),
  track track: fn(state) -> List(presence.Tracking(meta)),
  on_presence on_presence: fn(sync.Diff(meta)) -> msg,
) -> App(state, model, local, msg)

Track who is here (Phoenix.Presence). The tracker (built with presence.new) runs in the application’s supervision tree, and every session tracks what track returns for its state: computed after every update, tracked, updated and untracked as the list changes, and gone when the session ends. Every change on a tracked topic, from any node, arrives as on_presence(diff): the session’s first message for a topic lists everyone already there as joins, and up to 100 queued changes are coalesced into one message. Keep the entries with brazier/presence/sync.merge. The diff, entry and meta types live in brazier/presence/sync, which runs in the browser, so Msg and the Model can carry them.

pub type Msg {
  PresenceChanged(sync.Diff(Profile))
}

brazier.application(init:, update:, view:)
|> brazier.presence(
  tracker: presence.new("online", encode_user, user_decoder()),
  track: fn(model) {
    [presence.Tracking(topic: "room:" <> model.room, key: model.user, meta: model.profile)]
  },
  on_presence: PresenceChanged,
)

A tracking the tracker refuses (a limit, an invalid topic or key, the same key twice on a topic) stops the session with a logged error. When a shard of the tracker restarts, every session tracks its entries on it again. Start fails when the tracker configuration is invalid.

pub fn pubsub_config(
  app: App(state, model, local, msg),
  config: pubsub.PubSubConfig,
) -> App(state, model, local, msg)

Configure PubSub (partitions, pool size, limits). Start from pubsub.default_config(). PubSub runs as the first child of the application’s supervision tree.

pub fn secret_key(
  app: App(state, model, local, msg),
  key: String,
) -> App(state, model, local, msg)

The app’s secret key, required: at least 32 bytes. It signs join and session tokens, and the cluster peer check derives its key from it with a purpose label, so every node of a cluster needs the same one. Read it from the environment with config.secret_key() (SECRET_KEY). start refuses an app without one: a generated key would invalidate every token at each restart and make every node’s key different.

pub fn security_limits(
  app: App(state, model, local, msg),
  limits: transport.SecurityLimits,
) -> App(state, model, local, msg)

Override the transport’s security limits. Start from transport.default_security_limits().

pub fn session_id(ctx: Context) -> Result(String, Nil)

An opaque, stable id for the browser session (a hash of Brazier’s session cookie), the same for the dead render and the connected mount. Error when the request has no Brazier session cookie (its first page load).

pub fn simple(
  init init: fn(Context) -> Result(model, error.InitError),
  update update: fn(model, msg) -> model,
  view view: fn(model) -> element.Element(msg),
) -> App(model, model, Nil, msg)

A Lustre-style app with a pure update.

The build compiles update to JavaScript, so a click updates the page at once. The message is still sent to the server, which runs the same update and stays authoritative: when the two models differ, the server’s model wins and the browser logs the mismatch.

update may only use code that can run in the browser (the same rule as view); the build fails, naming the call, when it reaches anything else. Use application for effects or server-only work.

pub fn start(
  app: App(state, model, local, msg),
  port: Int,
) -> Result(Nil, error.BrazierError)

Start the app on port under its supervision tree and block while it runs (application.wait_forever). It returns Error when the app cannot start, and Ok(Nil) once it has stopped: after SIGTERM, when the listener has drained its connections, or when the tree was stopped elsewhere. If the tree gives up (a child keeps crashing), the calling process crashes with the reason instead of running on without it.

The server codec and the client bundle are build artifacts of gleam run -m brazier/build (or gleam run -m brazier/dev); starting without them is an error.

pub fn static_dir(
  app: App(state, model, local, msg),
  dir: String,
) -> App(state, model, local, msg)

Serve static files from dir under /static.

pub fn subscriptions(
  app: App(state, model, local, msg),
  compute: fn(state) -> List(String),
) -> App(state, model, local, msg)

Topics this session subscribes to, computed from the state after every update. Brazier diffs the list and subscribes or unsubscribes as needed.

brazier.application(init:, update:, view:)
|> brazier.subscriptions(fn(model) { ["room:" <> model.current_room] })
|> brazier.on_notify(RoomUpdated)
pub fn title(
  app: App(state, model, local, msg),
  title: fn(model) -> String,
) -> App(state, model, local, msg)

The page title for the client model. The server render puts it in the document’s <title>; after that, the browser’s title follows live updates: each join sends the current title, and so does every update that changes it.

pub fn track_and_watch_presence(
  app: App(state, model, local, msg),
  tracker tracker: presence.PresenceConfig(meta),
  track track: fn(state) -> List(presence.Tracking(meta)),
  watch watch: fn(state) -> List(String),
  on_presence on_presence: fn(sync.Diff(meta)) -> msg,
) -> App(state, model, local, msg)

presence and watch_presence together: every session tracks what track returns for its state and also follows the topics watch returns, without being on them. A chat room that shows who is in the room and how many are in the lobby uses this. Both lists are computed after every update. A topic that is both tracked and watched is followed once, so its diffs arrive once. The diffs, limits and failures are those of presence and watch_presence.

brazier.application(init:, update:, view:)
|> brazier.track_and_watch_presence(
  tracker: presence.new("online", encode_user, user_decoder()),
  track: fn(model) {
    [presence.Tracking(topic: "room:" <> model.room, key: model.user, meta: model.profile)]
  },
  watch: fn(_model) { ["lobby"] },
  on_presence: PresenceChanged,
)

It, presence and watch_presence set the same thing: the later call replaces the earlier.

pub fn uri(ctx: Context) -> uri.Uri

The page URL (for the connected mount, the URL the browser shows).

pub fn watch_presence(
  app: App(state, model, local, msg),
  tracker tracker: presence.PresenceConfig(meta),
  watch watch: fn(state) -> List(String),
  on_presence on_presence: fn(sync.Diff(meta)) -> msg,
) -> App(state, model, local, msg)

Watch who is here without being there: every session follows the topics watch returns for its state (computed after every update) and tracks nothing. A dashboard or a lobby list uses this. The diffs are those of presence: the first message for a topic lists everyone already there as joins, up to 100 queued changes are coalesced into one message, every entry is reported exactly once, and a topic the session stops watching reports everyone it knew there as leaves.

brazier.application(init:, update:, view:)
|> brazier.watch_presence(
  tracker: presence.new("online", encode_user, user_decoder()),
  watch: fn(model) { ["room:" <> model.room] },
  on_presence: PresenceChanged,
)

It, presence and track_and_watch_presence set the same thing: the later call replaces the earlier. To track and watch, use track_and_watch_presence. An invalid topic (empty, or over presence.max_topic_bytes) fails the session’s start, or stops it later, with a logged error. Start fails when the tracker configuration is invalid.

pub fn with_middleware(
  app: App(state, model, local, msg),
  mw: fn(
    request.Request(server.Connection),
    fn(request.Request(server.Connection)) -> response.Response(
      server.ResponseBody,
    ),
  ) -> response.Response(server.ResponseBody),
) -> App(state, model, local, msg)

Add a middleware to the HTTP pipeline (after the ones already added).

pub fn with_router(
  app: App(state, model, local, msg),
  parse parse: fn(uri.Uri) -> Result(route, Nil),
  on_change on_change: fn(route) -> msg,
) -> App(state, model, local, msg)

Route the app by URL. Declare one Route type and a parse function (usually a case over router.segments(uri)); Brazier runs update(state, on_change(route)) right after init, before the first render, and again on every client navigation (LiveView’s handle_params). A URL parse rejects is answered with 404, and a URL whose percent-encoding does not decode with 400.

pub fn with_state_recovery(
  app: App(state, model, local, msg),
  restore restore: fn(model, state) -> state,
) -> App(state, model, local, msg)

Recover the session after a reconnect to a new server process: the page carries the Model in a signed join token. init (and the initial route) run first for the new connection, as LiveView re-runs mount; restore then gets the recovered Model and that fresh state and returns the state to start from. Server-only fields the Model does not carry (a user loaded from the session, a handle) come from the fresh state, so they are never left empty. For simple and application, where the state is the Model, pass fn(model, _fresh) { model }.

pub fn ws_auth(
  app: App(state, model, local, msg),
  auth: fn(request.Request(server.Connection)) -> Result(
    Nil,
    String,
  ),
) -> App(state, model, local, msg)

Authenticate WebSocket connections. Runs before the upgrade; Error rejects the connection with 401.

✨ Search Document