Libero

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:

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:

FilePurpose
src/generated/libero/dispatch.gleamServer dispatch code for your handlers
src/generated/libero/rpc_decoders.gleamGleam wrapper for generated decoders
src/generated/libero/rpc_decoders_ffi.mjsJavaScript decoders for discovered types
src/generated@rpc_atoms.erlErlang 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

Defense stack (in order)

  1. Transport frame size limit. Your WebSocket server (mist, cowboy, etc.) should cap frame size. This is outside Libero but is the first gate.
  2. 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 bare binary_to_term/1 calls; none exist in the codebase.
  3. Atom pre-registration. The generated rpc_atoms module calls binary_to_atom/2 for every constructor atom at boot. With [safe], binary_to_term only succeeds for atoms that already exist in the table.
  4. 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

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

License

MIT. See LICENSE.

Search Document