
Libero
Libero helps a Gleam client and server share a typed RPC contract. The contract is the main thing: both sides agree on which calls exist, what arguments they take, and what each call returns.
Encoding and decoding are part of that, but they are not the whole point. The hard part is keeping the client and server agreement true across the protocol layer: request messages, response decoders, server dispatch, client state, and wire-format details all need to match the handler signatures.
Libero treats the server handler as the source of truth. It scans your handler functions, follows the types used in their signatures, and generates the RPC plumbing around them. That gives the client and server a shared typed contract without hand-written protocol messages or decoders.
What Libero Replaces
A server handler is a Gleam function that runs on the server:
import gleam/result.{type Result}
import server_context.{type ServerContext}
pub fn server_get_items(
server_context server_context: ServerContext,
) -> Result(List(Item), ItemError) {
Ok(server_context.items)
}
Libero treats a public function as an RPC handler when its name starts with
server_, it takes a ServerContext, and it returns either a read-only result
or a result with an updated context. The context type must appear unqualified in
the signature (ServerContext, not ctx.ServerContext). Functions that use a
qualified context type are silently skipped.
From just this handler, Libero writes all of the surrounding RPC code for you:
- A request variant such as
ServerGetItems, which represents this call at the protocol boundary. The server dispatch decodes it, and generated client or framework code sends the matching shape. - An encoder that turns
ServerGetItemsinto bytes or JSON - Server dispatch code that receives the message and calls
server_get_items - A response shape for
Result(List(Item), ItemError) - A client decoder that turns the response back into Gleam values
- Client state for loading, success, domain errors, and transport errors
If the handler signature changes, you simply regenerate instead. The wire format is the bytes or JSON sent over the network; Libero owns that shape so application code can stay focused on typed messages and handler results.
Quick Start
Add Libero to your project and run the generator:
gleam add libero
gleam run -m libero
Libero scans src/, finds RPC handlers, discovers the types they use, and writes
generated files under src/generated/libero/.
Generated Files
After gleam run -m libero, you will see files like these:
| File | Purpose |
|---|---|
src/generated/libero/dispatch.gleam | Server dispatch code for your handlers |
src/generated/libero/rpc_decoders.gleam | Gleam wrapper for generated decoders |
src/generated/libero/rpc_decoders_ffi.mjs | JavaScript decoders for discovered types |
src/generated@rpc_atoms.erl | Erlang atom pre-registration for safe ETF decoding |
Import the generated server modules in your app like any other Gleam module.
Transport Is Yours
Libero leaves transport code to your app or framework. WebSocket setup, HTTP routes, reconnect behavior, and app-specific routing stay outside the generator.
Advanced Usage
Client Decoders
If your client lives in another package, mirror the generated JavaScript decoder files into that package:
LIBERO_CLIENT_OUT_DIR="../clients/web/src/generated/libero" gleam run -m libero
This copies the client decoder output only. Libero still writes the server
dispatch files to src/generated/libero/.
Library API
You can also call the pipeline from your own codegen tool:
import libero
let assert Ok(endpoints) = libero.scan()
let seeds = libero.collect_seeds(endpoints)
let assert Ok(discovered) = libero.walk(seeds)
let dispatch_src = libero.generate_dispatch(endpoints)
let decoders_js = libero.generate_decoders_ffi(discovered, endpoints)
let decoders_gleam = libero.generate_decoders_gleam()
The API returns generated source as strings, so you choose where to write it.
Multiple Protocols
Libero supports ETF for BEAM-first applications and JSON for generated SDKs, tools, logs, and easier inspection. Both protocols are owned by the generated contract boundary: app code should call Libero helpers instead of assembling wire messages by hand.
For untrusted ETF input, decode through the generated helpers or
libero/etf/wire.decode_safe. ETF safe decoding prevents atom and function-term injection,
but callers should still set process memory limits for hostile input.
Security: ETF Threat Model
Libero uses Erlang Term Format (ETF) for its primary wire protocol because ETF preserves type fidelity that JSON does not: Int vs Float, BitArray, and atom-tagged variants all survive the round trip without lossy coercion. This matters for a typed RPC pipeline where the contract depends on exact types.
The ERLEF serialisation guide recommends against using ETF with untrusted parties. Libero does it anyway, with a defense stack designed for a specific threat model.
Trust assumptions
- The WebSocket endpoint requires authentication (cookie, session, or token) upstream of the handler. Libero does not enforce this; your transport layer must.
- The browser is adversarial despite serving your own JS. DevTools, XSS, browser extensions, and MITM (if HTTPS is broken) can all craft arbitrary ETF.
- The server’s BEAM process is trusted. Libero never decodes untrusted ETF into the server without the defenses below.
Defense stack (in order)
- Transport frame size limit. Your WebSocket server (mist, cowboy, etc.) should cap frame size. This is outside Libero but is the first gate.
binary_to_term(Bin, [safe])on every decode path. This blocks atom creation (atom-table exhaustion DoS) and function deserialisation (remote code execution via FUN_EXT/EXPORT_EXT). Libero audits for barebinary_to_term/1calls; none exist in the codebase.- Atom pre-registration. The generated
rpc_atomsmodule callsbinary_to_atom/2for every constructor atom at boot. With[safe],binary_to_termonly succeeds for atoms that already exist in the table. - Typed dispatch. The generated dispatch verifies the decoded term’s constructor tag against a known handler set before invoking any handler function. Unknown tags return a wire error, not a crash.
What would weaken this model
- Adding a bare
binary_to_term/1call (without[safe]) on any request path. - Accepting ETF from unauthenticated connections.
- Passing decoded ETF terms to
erlang:apply/3or similar without dispatch tag verification. - Removing atom pre-registration while still accepting ETF from browsers.
If you modify Libero’s decode path, verify that [safe] is present and that the
decoded term flows through typed dispatch before reaching handler code.
More Docs
- Contract boundary: what Libero owns and what app code owns
- ETF wire protocol: ETF frames, safe decode, and hashed type identity
- JSON wire protocol: readable JSON envelopes, validation, and contract hashes
- Wire type identity: how custom types stay unique across protocols
- llms.txt: raw package context for language models
License
MIT. See LICENSE.