Libero

Libero generates typed messaging between clients and a Gleam server. You define message types in a shared module, and Libero produces a server dispatch function and client stubs from them. Browser clients (like Lustre) connect over WebSocket, BEAM clients (Gleam, Erlang, Elixir) connect over HTTP. No REST routes, no JSON codecs, no hand-written dispatch tables.

If you are an LLM, see LLM_USERS.md for a condensed context document.

Convention

Every shared module that participates in Libero’s codegen exports two types by convention:

// shared/src/shared/todos.gleam

pub type MsgFromClient {
  Create(params: TodoParams)
  Toggle(id: Int)
  Delete(id: Int)
  LoadAll
}

pub type MsgFromServer {
  TodoCreated(Result(Todo, TodoError))
  TodoToggled(Result(Todo, TodoError))
  TodoDeleted(Result(Int, TodoError))
  TodosLoaded(Result(List(Todo), TodoError))
}

MsgFromClient contains messages from the client to the server. MsgFromServer contains messages the server sends back, both as responses and as server-initiated pushes. A module can define one or both.

Each MsgFromServer variant wraps a single value, typically a Result(payload, error) for use with RemoteData. If a response needs multiple values, group them in a record or tuple.

Example usage

The client sends a message using the generated stub:

// In your Lustre update:
import client/generated/libero/todos as todos_rpc

ToggleTodo(id) ->
  #(model, todos_rpc.send_to_server(msg: Toggle(id:), on_response: GotResponse))

The server handles it in store.gleam, which exports update_from_client:

// server/src/server/store.gleam

import shared/todos.{type MsgFromClient, type MsgFromServer, TodosLoaded, TodoCreated}
import server/shared_state.{type SharedState}
import server/app_error.{type AppError}

pub fn update_from_client(
  msg msg: MsgFromClient,
  state state: SharedState,
) -> Result(#(MsgFromServer, SharedState), AppError) {
  case msg {
    todos.LoadAll -> Ok(#(TodosLoaded(Ok(all())), state))
    todos.Create(params:) -> Ok(#(TodoCreated(Ok(insert(params.title))), state))
    todos.Toggle(id:) -> ...
    todos.Delete(id:) -> ...
  }
}

WebSocket setup

The generated websocket.gleam handles dispatch and push frame forwarding. One call in your server:

import server/generated/libero/websocket as ws

_, ["ws"] ->
  ws.upgrade(request: req, state: shared, topics: [])

If you need server push, pass topic names in the topics list to subscribe clients on connect.

Codegen CLI

Run from your server package directory:

cd server
gleam run -m libero -- \
  --ws-url=wss://your.host/ws \
  --shared=../shared \
  --server=.

Or when the hostname varies between environments:

gleam run -m libero -- \
  --ws-path=/ws \
  --shared=../shared \
  --server=.

Flags

FlagDescription
--ws-url=<url>Hardcode a full WebSocket URL. One of --ws-url or --ws-path is required.
--ws-path=<path>Resolve the WebSocket URL at runtime from window.location.
--shared=<path>Path to the shared package root.
--server=<path>Path to the server package root.
--client=<path>Path to the client package root (defaults to ../client).
--namespace=<name>Optional prefix for multi-SPA setups.
--write-inputsWrite a .inputs manifest for staleness checks.

What gets generated

From a shared module at shared/src/shared/todos.gleam, Libero writes:

How it works

The wire format is ETF over binary WebSocket frames. Gleam’s custom types, lists, options, and primitives all serialize automatically without explicit codecs.

The client sends a {module_path, MsgFromClient_value} tuple. The server dispatch decodes it, routes by module path, and calls the handler. Codec registration happens automatically on the first send_to_server call.

The generator scans shared modules for MsgFromClient and MsgFromServer types, walks their type graphs to find all types that need codec registration, and emits the dispatch and stub files.

Naming

Libero’s API uses a directional naming convention:

DirectionClient callsServer calls
Client → Serversend_to_server(msg:)update_from_client(msg:)
Server → Clientupdate_from_server(handler:)generated send_to_client(client_id:, ...) / send_to_clients(topic:, ...)

Server push (optional)

The server can push messages to connected clients without a prior request. Uses BEAM pg groups for topic-based subscriptions, no external dependencies.

// Server — in a handler, push to all subscribers via generated wrapper
import server/generated/libero/todos as todos_push
todos_push.send_to_clients(topic: "todos", msg: AllLoaded(all()))

// Server — targeted push to one client
push.register(client_id: "user:42")
todos_push.send_to_client(client_id: "user:42", msg: Created(item))
// Client — subscribe to pushes (in init)
todos_rpc.update_from_server(handler: fn(raw) { GotPush(wire.coerce(raw)) })

Push is opt-in. If you never call update_from_server, push frames are silently dropped. If unused, tree shaking removes the generated code.

HTTP clients (optional)

The generated dispatch.handle(state:, data:) function takes a BitArray and returns a BitArray. It doesn’t know or care about the transport. This means any BEAM process can be a Libero client by sending ETF-encoded messages over HTTP POST. No WebSocket and no Libero dependency needed.

This works because ETF is the BEAM’s native serialization format. Any BEAM client (Gleam, Erlang, Elixir) can call term_to_binary on the same shared types the browser uses, POST the bytes, and decode the response with binary_to_term. The server runs the same dispatch logic either way.

// Server: add an HTTP route that calls the same dispatch
fn handle_rpc(req, state) {
  use body <- wisp.require_body(req)
  let #(response, _, _) = dispatch.handle(state:, data: body)
  wisp.ok() |> wisp.set_body(wisp.Bytes(bytes_tree.from_bit_array(response)))
}
// Any BEAM client: encode, POST, decode
let payload = term_to_binary(#("shared/todos", LoadAll))
let assert Ok(response) = httpc.request(Post, url, payload)
let result = binary_to_term(response.body)

See examples/todos/cli/ for a runnable CLI example with argument parsing.

License

MIT. See LICENSE.

Search Document