# Libero
Libero generates typed messaging between clients and a [Gleam](https://gleam.run) 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](https://hexdocs.pm/lustre/)) connect over WebSocket, [BEAM](https://www.erlang.org/blog/a-brief-beam-primer/) clients (Gleam, [Erlang](https://www.erlang.org), [Elixir](https://elixir-lang.org)) connect over HTTP. No REST routes, no JSON codecs, no hand-written dispatch tables.
> If you are an LLM, see [LLM_USERS.md](https://github.com/pairshaped/libero/blob/master/LLM_USERS.md) for a condensed context document.
## Convention
Every shared module that participates in Libero's codegen exports two types by convention:
```gleam
// 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`](https://github.com/pairshaped/libero/blob/master/examples/todos/shared/src/shared/todos.gleam) 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](https://github.com/pairshaped/libero/blob/master/examples/todos/client/src/client/generated/libero/todos.gleam):
```gleam
// 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`](https://github.com/pairshaped/libero/blob/master/examples/todos/server/src/server/store.gleam), which exports `update_from_client`:
```gleam
// 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`](https://github.com/pairshaped/libero/blob/master/examples/todos/server/src/server/generated/libero/websocket.gleam) handles dispatch and push frame forwarding. One call in your server:
```gleam
import server/generated/libero/websocket as ws
_, ["ws"] ->
ws.upgrade(request: req, state: shared, topics: [])
```
If you need [server push](#server-push), pass topic names in the `topics` list to subscribe clients on connect.
## Codegen CLI
Run from your server package directory:
```bash
cd server
gleam run -m libero -- \
--ws-url=wss://your.host/ws \
--shared=../shared \
--server=.
```
Or when the hostname varies between environments:
```bash
gleam run -m libero -- \
--ws-path=/ws \
--shared=../shared \
--server=.
```
### Flags
| Flag | Description |
|---|---|
| `--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-inputs` | Write a `.inputs` manifest for staleness checks. |
## What gets generated
From a shared module at `shared/src/shared/todos.gleam`, Libero writes:
- [`dispatch.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/server/src/server/generated/libero/dispatch.gleam): routes incoming wire calls to handler modules.
- [`websocket.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/server/src/server/generated/libero/websocket.gleam): mist WebSocket handler with dispatch, push forwarding, and topic cleanup.
- [`todos.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/client/src/client/generated/libero/todos.gleam) (client): typed `send_to_server` and `update_from_server` stubs.
- [`todos.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/server/src/server/generated/libero/todos.gleam) (server): typed `send_to_client` and `send_to_clients` push wrappers.
- [`rpc_config.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/client/src/client/generated/libero/rpc_config.gleam): WebSocket URL configuration.
- [`rpc_register.gleam`](https://github.com/pairshaped/libero/blob/master/examples/todos/client/src/client/generated/libero/rpc_register.gleam) + [`rpc_register_ffi.mjs`](https://github.com/pairshaped/libero/blob/master/examples/todos/client/src/client/generated/libero/rpc_register_ffi.mjs): auto-registers framework and application types for wire codec reconstruction (called automatically on first send).
## 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:
| Direction | Client calls | Server calls |
|---|---|---|
| Client → Server | `send_to_server(msg:)` | `update_from_client(msg:)` |
| Server → Client | `update_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](https://www.erlang.org/doc/apps/kernel/pg.html) groups for topic-based subscriptions, no external dependencies.
```gleam
// 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))
```
```gleam
// 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](https://www.erlang.org/doc/apps/erts/erl_ext_dist.html) 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.
```gleam
// 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)))
}
```
```gleam
// 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/`](https://github.com/pairshaped/libero/blob/master/examples/todos/cli/) for a runnable CLI example with argument parsing.
## License
MIT. See [LICENSE](https://github.com/pairshaped/libero/blob/master/LICENSE).