Libero
A full-stack Gleam framework with typed RPC. Define your handler functions, Libero generates dispatch, client stubs, and server bootstrap from the signatures. No message types to write, no dispatch to maintain.
Like server components, but your client is a real SPA with typed RPC, and the same server logic works for any client out of the box.
If you are an LLM, see llms.txt for a condensed context document.
Getting started
Read the Getting Started guide. It walks from an empty directory to a working checklist app, with every command and file shown. When you want persistent storage, follow Step 2: SQLite.
The rest of this README explains what libero is and how it works.
Project Structure
my_app/
├── bin/
│ ├── gen # libero codegen (dispatch + client stubs)
│ ├── build # build the JS client
│ ├── server # start the server
│ ├── dev # gen + build + server, in order
│ └── test # run server tests
├── server/
│ ├── gleam.toml # target=erlang, [tools.libero] config
│ └── src/
│ ├── my_app.gleam # server entry (auto-generated, customizable)
│ ├── handler.gleam # your RPC endpoints
│ ├── handler_context.gleam # server context type
│ ├── page.gleam # SSR load_page + render_page
│ └── generated/ # dispatch, websocket (auto-generated)
├── shared/
│ ├── gleam.toml # cross-target shared types + views
│ └── src/shared/
│ ├── router.gleam # Route, parse_route, route_to_path
│ ├── types.gleam # domain types used in handlers
│ └── views.gleam # Model, Msg, view function (cross-target)
└── clients/
└── web/
├── gleam.toml # target=javascript
└── src/
├── app.gleam # Lustre client (hydrates SSR HTML)
└── generated/ # client RPC stubs (auto-generated)
Three peer Gleam packages (server/, shared/, clients/web/), each with its own gleam.toml. Matches Lustre's recommended fullstack shape with one extension: clients/ is plural because most real apps grow more than one client. See Multiple Clients for the typical shapes.
shared/ is target-agnostic: it compiles to both Erlang (used by the server) and JavaScript (used by the client). All types crossing the wire and all view functions live here.
server/ runs gleam run -m libero to regenerate dispatch and client stubs. The bin/dev script wraps that plus gleam build and gleam run so you don't have to think about it.
Handler-as-Contract
Your handler function signatures ARE the API definition. Libero's scanner detects RPC endpoints by checking four criteria:
- Public function (not private)
- Last parameter is
HandlerContext - Return type is one of:
Result(value, error)for read-only handlers (the common case)#(Result(value, error), HandlerContext)for handlers that emit a new context
- All types in the signature come from
shared/or are builtins
Read-only handlers return Result(_, _) directly; libero's generated dispatch threads the inbound context through unchanged. Use the tuple form only when the handler produces a new HandlerContext (login flows, session swaps, anything that mutates server state).
// server/src/handler.gleam
import gleam/list
import handler_context.{type HandlerContext, HandlerContext}
import shared/types.{
type Item, type ItemError, type ItemParams, Item, TitleRequired,
}
// Read-only handler: bare Result.
pub fn get_items(
handler_ctx handler_ctx: HandlerContext,
) -> Result(List(Item), ItemError) {
Ok(handler_ctx.items)
}
// Mutating handler: tuple form. The new HandlerContext flows back into
// the session.
pub fn create_item(
params params: ItemParams,
handler_ctx handler_ctx: HandlerContext,
) -> #(Result(Item, ItemError), HandlerContext) {
case params.title {
"" -> #(Error(TitleRequired), handler_ctx)
title -> {
let item = Item(id: handler_ctx.next_id, title:, completed: False)
let new_state =
HandlerContext(
items: list.append(handler_ctx.items, [item]),
next_id: handler_ctx.next_id + 1,
)
#(Ok(item), new_state)
}
}
}From these signatures, Libero generates:
-
A
ClientMsgtype with variants:GetItems,CreateItem(params: ItemParams) - A dispatch module that routes each variant to its handler function
-
Typed client stubs:
rpc.get_items(on_response: GotItems)
The return type Result(a, e) maps directly to RpcData on the client:
Ok(value)becomesSuccess(value)Error(err)becomesFailure(DomainError(err))(typed domain error)-
A framework-level failure (malformed wire, unknown function, server panic) becomes
Failure(TransportError(rpc_err))with a typedRpcError
Shared Types
Define your domain types in shared/src/shared/. These are the types used in handler signatures and shared between server and client:
// shared/src/shared/types.gleam
pub type Item {
Item(id: Int, title: String, completed: Bool)
}
pub type ItemParams {
ItemParams(title: String)
}
pub type ItemError {
NotFound
TitleRequired
}Client Usage
The generated stubs let clients send typed messages. Use RpcData to track loading state. Domain errors stay typed; transport errors carry a typed RpcError:
import generated/messages as rpc
import libero/remote_data.{type RpcData, Failure, Loading, Success}
import shared/types.{type Item, type ItemError}
pub type Model {
Model(items: RpcData(List(Item), ItemError), input: String)
}
pub type Msg {
GotItems(RpcData(List(Item), ItemError))
GotCreated(RpcData(Item, ItemError))
UserToggled(id: Int)
// ...
}
fn init(_flags) -> #(Model, Effect(Msg)) {
#(Model(items: Loading, input: ""), rpc.get_items(on_response: GotItems))
}
In the update function, store load responses directly and use remote_data.map to update loaded data:
GotItems(rd) -> #(Model(..model, items: rd), effect.none())
GotCreated(Success(item)) -> #(
Model(..model, items: remote_data.map(data: model.items, transform: fn(items) {
list.append(items, [item])
})),
effect.none(),
)
In the view, pattern match on the four states. Use format_failure to render either error tier with one helper, supplying your own formatter for the domain side:
case model.items {
NotAsked -> element.none()
Loading -> html.text("Loading...")
Failure(outcome) ->
html.text(remote_data.format_failure(
outcome:,
format_domain: format_error,
))
Success(items) -> view_item_list(items)
}If transport and domain errors need different UX, drill into the outcome:
import libero/remote_data.{DomainError, TransportError}
Failure(DomainError(err)) -> format_error(err)
Failure(TransportError(rpc_err)) ->
html.text("Connection error: " <> remote_data.format_transport_error(rpc_err))Connection Management
The WebSocket auto-reconnects with exponential backoff (500ms to 30s with jitter) on unexpected disconnects. Pending requests reject with a connection-lost error when the socket drops. Push handlers persist across reconnects.
Hook into the connection lifecycle:
import libero/rpc
pub type Msg {
Connected
Disconnected(reason: String)
// ...
}
fn init(_flags) -> #(Model, Effect(Msg)) {
#(
Model(..),
effect.batch([
rpc.on_connect(handler: fn() { Connected }),
rpc.on_disconnect(handler: Disconnected),
]),
)
}on_connect fires on the initial connection and every successful reconnect, so loading (or reloading) state uses a single code path. on_disconnect provides a human-readable reason string suitable for display.
Configuration
All config lives in server/gleam.toml under the [tools.libero] section:
name = "my_app"
version = "0.1.0"
target = "erlang"
[dependencies]
gleam_stdlib = ">= 0.69.0 and < 2.0.0"
gleam_erlang = "~> 1.0"
gleam_http = "~> 4.0"
mist = "~> 6.0"
lustre = "~> 5.6"
shared = { path = "../shared" }
libero = "~> 5.0"
[tools.libero]
port = 8080
[tools.libero.clients.web]
target = "javascript"Commands
From the project root:
bin/gen: regenerates dispatch, websocket, and client stubs (gleam run -m liberofromserver/).bin/build: builds the JS client (gleam build --target javascriptfromclients/web/).bin/server: starts the mist server on port 8080 (gleam runfromserver/).bin/dev: convenience wrapper that runsgen,build, thenserver.bin/test: runsgleam testin the server package.
Use bin/dev after changing handler signatures or shared types. Use bin/server alone when only handler bodies have changed.
What Gets Generated
Server-side (server/src/generated/):
dispatch.gleam--ClientMsgtype + per-function routing to handlerswebsocket.gleam-- Mist WebSocket handler with push support
Server entry point (server/src/<app_name>.gleam):
- Boots Mist with WebSocket, HTTP RPC, and static file serving
-
Serves HTML shell at
/that loads the first JS client
Atom registration (server/src/<app_name>@generated@rpc_atoms.erl):
- Pre-registers every constructor atom so ETF decoding is safe before the first message arrives
Per client (clients/<name>/src/generated/):
messages.gleam-- typed stubs per handler function (e.g.rpc.get_items,rpc.create_item)rpc_config.gleam(+rpc_config_ffi.mjsfor path-only mode) -- WebSocket URL resolutionrpc_decoders.gleam(+rpc_decoders_ffi.mjs) -- typed decoder registrationssr.gleam(+ssr_ffi.mjs) -- SSR flag reader for hydration
Generation rules:
-
Starter apps and client
gleam.toml: generated once, never overwritten -
Everything in
generated/: regenerated on everygleam run -m liberorun
How It Works
The wire format is ETF (Erlang Term Format) over binary WebSocket frames. Gleam types serialize automatically without explicit codecs.
The client sends a typed message over the WebSocket. The server dispatch decodes it, routes by function, and calls the handler. The response flows back as Result(Result(payload, domain), RpcError), which the client stub converts to RpcData(payload, domain).
Multiple Clients
clients/ is plural because most real apps end up with more than one. Common shapes:
clients/web/+clients/admin/: a public-facing SPA and a separate admin SPA, both compiled to JavaScript. Different routes, different views, often different bundle sizes (you don't ship the admin code to every visitor).clients/web/+clients/cli/: a web SPA plus a BEAM-target CLI that talks to the same server. Useful for ops tools, scripted workflows, or letting power users hit the same RPC endpoints from a terminal.clients/web/+clients/native/: a web client plus a Lustre-driven native client (e.g. iOS or Android via embedded JS), or any other JavaScript target with different dependencies.
The handlers don't change. Each client gets typed stubs generated from the same handler.gleam signatures, so the contract stays consistent across surfaces. You can't accidentally drift the admin client's idea of Item from the web client's, because both decode the same shared/types.Item.
To add a client: create clients/<name>/gleam.toml, add [tools.libero.clients.<name>] to server/gleam.toml, then run bin/gen to generate its stubs.
Two SSR-hydrated SPAs (admin + public)
This is the question every two-app team hits: how does adding a second SPA affect the rest of the code? Here's what changes per peer.
server/ stays mostly intact.handler.gleam is still one set of RPC endpoints; both SPAs call whichever they need. handler_context.gleam doesn't change. page.gleam splits per role into admin_page.gleam and public_page.gleam, each with its own load_page and render_page pair. The server entry routes /admin/* to the admin pair and /* to the public pair, and serves both client bundles via static-file routes (/web/admin/app.mjs, /web/public/app.mjs).
shared/ splits along the UI seam. Domain types in shared/types.gleam stay unified: both SPAs decode the same Item, so wire compatibility is automatic and free. View modules and routers split per role into shared/admin/{router,views}.gleam and shared/public/{router,views}.gleam. Each gets its own Route, Model, Msg, view. Reusable widgets (a date picker, a table component) extract into shared/ui/ and get imported by both.
Why split the views? Bundle-bleed protection. Cram both UIs into one shared/views.gleam and every public visitor downloads your admin code. Splitting keeps clients/admin/'s output to admin code and clients/public/'s output to public code, with shared types and shared widgets as the bridge.
The wire contract is shared. The UI surface stays per-client.
HTTP Clients
Any BEAM process can call the server over HTTP POST without WebSocket or a Libero dependency:
// Envelope: #(module_path, request_id, ClientMsg). The request_id is
// echoed back in the 4-byte response header so concurrent calls match.
let payload = term_to_binary(#("rpc", 1, GetItems))
let assert Ok(response) = httpc.request(Post, "http://localhost:8080/rpc", payload)
let result = binary_to_term(response.body)When to Use Libero
Libero is a good fit when:
- You want a real SPA (offline support, low-latency UI, mobile)
- You want multiple client types from one server
- You want typed end-to-end communication without JSON codecs
- You want clear client/server state boundaries
Examples
examples/checklist-- SSR-hydrated Lustre SPA with CRUD over WebSocket. Output of the Getting Started guide.examples/default-- Bare SSR scaffold with one ping handler. The starting pointbin/newclones.
Prior Art & Credits
Libero's JS-side ETF codec is independently implemented but aligns with arnu515/erlang-etf.js (MIT) on BIT_BINARY_EXT handling and atom-length validation. Credit to that project for clear spec references. Libero's codec adds encoding, a BEAM-native path, the float field registry, and offset-based parsing.
License
MIT. See LICENSE.